How to make a Text Adventure game in Rust - IX - Adding a Game File - Part I

In this project, we're making a fully functional text adventure game from scratch. The post assumes you've read the earlier posts in the series. To get caught up jump to the first post and start at the beginningThis project is heavily based on the work of Ruud Helderman and closely follows the content and structure of his excellent tutorial series on the same topic using the C language.

This content and all code is licensed under the MIT License.

Photo by Frank Cone

9 - Adding a Game File - Part I

Until this post, we've been building the game by defining items, locations, and paths in our source code directly. However, there are problems with this approach. Most obvious is that by defining the game in source code, any time we change the game, we must recompile the game program which slows down editing the game. Second, text adventure games can have tens or hundreds of items, locations, and paths; adding them all to the source code makes the program larger and harder to navigate. Third, so far our game objects only have a few attributes, as we add more complexity to the game, we'll be adding more attributes; maintaining all of these attributes in source code will be difficult. Finally, if we define our game in source code, we must follow all of the rules that Rust requires for writing source code; it would be much nicer if we could define our own format for storing game content that is less verbose and easier to edit.

Considering the above issues, we can identify a more ideal method for defining game information. What we'd like is a way to define the game that:

  • Is separate from the game's source code so we can change the game without re-compiling,
  • Is written using a format of our choosing that is less verbose than source code,
  • Allows us to easily add and edit game objects without having to change multiple places, and
  • Allows us to have many attributes on objects and add new ones as needed.

Eventually we'll want to allow the player to save and load game saves so they can save their place if they do not finish the game in one sitting. Any solution we come up with should move us toward addressing this future need as well.

In this post and the next one we'll address all of the above issues by moving our game definition out of the source code and into a separate file. This file will be read by the game at runtime to 'load' the game and allow the player to begin playing. We won't address saving and loading saved games in this post, but the solution here will lay all the groundwork we need to add those features later. In fact our game file could be thought of as a kind of special saved game that is loaded whenever the player starts a new game.

We'll implement the changes to the game as a series of steps that each take us incrementally closer to our final destination.

Adding a Game File Step 0 - Where we are now

Before we begin, lets have a look at the current structures we're using.


To store game information, the game uses two structs: World, and Object. The World struct represents the entire game world and all game state is stored directly in, or associated with an instance of this struct. World (currently) has one attribute, objects, that stores a vector of Object instances.

The Object struct represents a single in-game object, which could be a location, an item in the game, or a path between locations. Objects (currently) have 4 attributes - labels, description, location and destination. The various object types (locations, items, and paths) are all represented by instances of the same Object struct each with different attributes defined as shown in the table above. Also note that the location and destination fields contain references to other Objects in the game. These references are stored as integer indexes to the other Objects in the vector, a fact that will become of great interest in this post and the next and ultimately drive our solution implementation.

Looking at the code for these two structures we see the following:
pub struct World {
    pub objects: Vec<Object>,
}

// --snip--

pub struct Object {
    pub labels: Vec<String>,
    pub description: String,
    pub location: Option<usize>,
    pub destination: Option<usize>,
}
So, to read in a game state, we need to be able to do the following things:
  1. Read a file from the file system,
  2. Define a file format to represent the World, and Object structs that we desire to read,
  3. Unpack (deserialize) the file into instances of the World, and Object structs as needed, and
  4. To do all three of those things in reverse to save the World.  
Lets start by adding the capability to read a file to our game.

Adding a Game File Step 1 - Reading from a File

To begin we'll start by adding a function that reads the game file when the program runs. This function will be responsible for reading the game file and returning a populated World struct.

The standard filesystem (std::fs) module includes the function read_to_string(), which does exactly what we need. We'll wrap it up in function read_from_file() which we'll use as the basis for our future additions. For this step, we just need to insert the function into the game and get it to run when the game starts.

First, we'll add the function to rlib.rs as part of the implementation for World.

use std::io::read_from_string;
use std::path::Path;

// --snip--

impl World {

    // --snip--

    pub fn read_from_file(game_file: &str) -> Result<World, std::io::Error> {
        let game_file_path = Path::new(game_file);
        let game_file_data_res = read_to_string(game_file_path);

        match game_file_data_res {
            Ok(_) => {
                // TODO - For now just make a new world and return
                Ok(World::new())
            }
            Err(file_err) => Err(file_err),
        }
    }
    
    // --snip--
}

Explanation
1,2 - Newly added use statements to bring in the read_from_string function and the Path struct.
10 - The implementation of read_from_file. The function takes in a string that is the location of the game file, and returns a Result since the function can fail.
11 - Path structs represent a file path. We can construct one by passing a string slice to Path::new().
12 - Reads in the file using the newly created Path.
14-20 - Check the Result and respond appropriately.
16,17 - Here is where we will process the returned file. For now just return a default instance of the World struct.

The read_from_file() function reads a file and returns an instance of a World struct. Our next task is to integrate the function into our game startup. For that, we'll go into to main.rs to modify the startup of the game. While we're there, we'll refactor the main() function to shift code out of the main() function into two new functions init_game(), and do_game(). init_game() is where we'll load the game file.

Lets look at the changes to main() first.

// Game file location
const GAME_FILE_LOC: &str = "./game_file.ron";

fn main() {
    let world_res = init_game(GAME_FILE_LOC);

    match world_res {
        Ok(world) => {
            //
            // Run Game
            do_game(world);
        }
        Err(file_err) => {
            //
            // Shutdown and exit with error
            println!("ERROR - {}", file_err);
        }
    }
}

Explanation
2 - Define the location of the game file. This is a relative path that puts the file in the same directory as where we run the file. For a production game we might want this in a different location.
4 - The main() function. All Rust programs have one.
5 - Here is the call to init_game(). The function returns a World struct wrapped in a Result so we can bubble up errors if needed.
7-18 - match on world_res to run the game or do error reporting as needed.
8-12 - For Ok() results, pass the World struct into do_game() to run the game.
13-17 - For Err() results, just print the error, there isn't much else we can do.

Now, lets have a look at init_game(). All this function does is call read_from_file() and return the result. Since it is one line we probably could have left it in main() but it is more clear to move it into its own function while we are refactoring main(). init_game() returns a Result so it can bubble up errors.

fn init_game(file_loc: &str) -> Result<rlib::World, std::io::Error> {
    // Read the game file and return the returned world.
    // Bubble up any error result
    rlib::World::read_from_file(file_loc)
}

Explanation
1 - The init_game() function. The function returns a World struct wrapped in a Result so we can bubble up errors if needed.
4 - The call read_from_file().

Last, we can look at do_game(). This function is all of the code that used to be in the main() function.

fn do_game(mut world: rlib::World) {
    let mut command: rlib::Command;
    let mut output: String;

    //
    // Introduction and Setup
    //
    println!("Welcome to Reentry. A space adventure.");
    println!();
    println!("You awake in darkness with a pounding headache.");
    println!("An alarm is flashing and beeping loudly. This doesn't help your headache.");
    println!();

    //
    // Main Loop
    //
    loop {
        command = rlib::get_input();
        output = world.update_state(&command);
        rlib::update_screen(output);

        if matches!(command, rlib::Command::Quit) {
            break;
        }
    }

    //
    // Shutdown and Exit
    //
    println!("Bye!");
}

Explanation
1-31 - The do_game() function. Code refactored here from the previous main() implementation.

With these changes, the game will attempt to read a game file (game_file.ron) at startup. We've not created the file yet though, so running the game will end in failure.

$ cargo -q run
ERROR - No such file or directory (os error 2)

We can fix this error by creating a file named 'game_file.ron'.

$ touch game_file.ron
$ cargo -q run
Welcome to Reentry. A space adventure.

You awake in darkness with a pounding headache.
An alarm is flashing and beeping loudly. This doesn't help your headache.

> quit

Quitting.
Thank you for playing!
Bye!

Next up, we'll modify the game to process the file we're reading to create an instance of the World struct.

Adding a Game File Step 2 - Reading the World with Serde

Each time the game starts, it attempts to read a file 'game_file.ron.' In this step we'll develop code to both read and write the game file and a format for the data in it. We'll be using the amazing Serde crate for that.

About Serde

The Serde website says, "Serde is a framework for serializing and deserializing Rust data structures efficiently and generically." This crate is among the most widely used in the Rust community and provides a great structure for the work we need to do. For simple use cases, Serde provides the macros Serialize and Deserialize that make the process of converting even complex structures into strings simple and easy. From there, writing them to files or network streams or wherever you need to send them is a simple task. More complex cases can be handled with custom serialization and deserialization code.

BUT... all as the saying goes 'with great power comes great responsibility.' Serde is written for an audience that fully knows Rust. Serde uses all of the more complex features of Rust to offer its solution. Serde macros hide most of the complexity, but if you step off the easy path, be prepared to understand traits, macros, generic functions, and lifetimes before you can even follow their examples, much less roll your own solutions. As a result, things escalate pretty quickly in this post and the next and I'll freely admit that writing this code was right at the edge of what I can do in Rust today. I'm still digesting some of what I'm presenting here as this solution.

Meet RON

We've shared that we're going to use Serde to read a World struct instance from a file. But we've not explored the actual content the file should contain. Serde provides the ability to serialize (write) and deserialize (read) a variety of formats including JSON and a variant called RON (a Rusty Object Notation). RON is similar in syntax to Rust code and offers the capability to represent all of the Rust data model including enums, structs, and vectors. We'll use RON to represent the World object and all of its data in the game_file.

As a bonus, since Serde can serialize RON, we'll write code to write a file at the same time as we do code to read the file. We can have our game write out a first version of our game file based on the structure definition we already have!

Here is a sample of what RON looks like (taken directly from the RON github repo):

GameConfig( // optional struct name
    window_size: (800, 600),
    window_title: "PAC-MAN",
    fullscreen: false,
    
    mouse_sensitivity: 1.4,
    key_bindings: {
        "up": Up,
        "down": Down,
        "left": Left,
        "right": Right,
        
        // Uncomment to enable WASD controls
        /*
        "W": Up,
        "A": Down,
        "S": Left,
        "D": Right,
        */
    },
    
    difficulty_options: (
        start_difficulty: Easy,
        adaptive: false,
    ),
)

First steps with Serde

To begin with Serde, we need to add some dependencies into to the project Cargo.toml file like so:

[package]
name = "reentry"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
serde = { version = "1.0", features = ["derive"]}
ron = "0.8.0"

Explanation
9,10 - The Serde and RON dependencies needed for the game.

With that done we can decorate the World and Object structures using the Serde Serialize and Deserialize macros which automatically derive implementations that allow serialization and deserialization of our structs. The changes we need to make will be in rlib.rs where the World and Object structs are defined.

This snippet begins to show the amazingness of Serde. Just two lines is all it takes to enable serialization and deserialization of these structs and all they contain. We'll still need to add code to do the actual saving and loading, but as we'll show in the next post, the lines here are doing all the hard work.

//
// Reentry Library
//
// A library to support the creation of a text adventure game
// by Riskpeep
use serde::{Deserialize, Serialize};

// --snip--

#[derive(Serialize, Deserialize, Debug)]
pub struct Object {
    pub labels: Vec<String>,
    pub description: String,
    pub location: Option<usize>,
    pub destination: Option<usize>,
}

// --snip--

#[derive(Serialize, Deserialize, Debug)]
pub struct World {
    pub objects: Vec<Object>,
}

Explanation
6 - use statement to bring in the Serde macros we need.
10,20 - Derive blocks that add Serialize and Deserialize implementations for the World and Object structs. We've also added Debug so we can print these structs.

Next up is to actually do the reading and deserialization. We're going to modify the read_from_file() function in a series of steps:

  1. Write (serialize) our World struct to a string.
  2. Use that string to create a file game_file.ron.
  3. Read (deserialize) the game_file.ron file and use that for the game.

For step 1, we already have code to create a World struct instance that we've been using until now. We'll use Serde to transform it into a string and then print the string to the console.

impl World {
    // --snip--
    pub fn read_from_file(game_file: &str) -> Result<World, std::io::Error> {
        let game_file_path = Path::new(game_file);
        let game_file_data_res = read_to_string(game_file_path);

        match game_file_data_res {
            Ok(_) => {
                // Create a new World struct
                let new_world = World::new();

                // Write (serialize) the struct to a string using Serde
                let serialized_ron = ron::to_string(&new_world).unwrap();

                // Write the serialized string to the console
                println!("serialized = {}", serialized_ron);

                // TODO - For now just make a new world and return
                Ok(new_world)
            }
            Err(file_err) => Err(file_err),
        }
    }
    // -- snip --
}

Explanation
3 - The read_from_file() function. This is step 1 of our 3 step process.
10 - Use the existing World::new() function to create a new World struct.
13 - Write the World instance to a string. This is pure Serde and RON magic. We're using unwrap() here because this is throwaway code that we just need to help us get our game file created.
16 - Print the serialized World struct to the console.
19 - Return the new_world instance. We still need to return a World instance so the game can start up. We'll replace this later with the contents of our file.

With these changes we can run the game and get the following out.

$ cargo run
   Compiling reentry v0.1.0 (/home/rskerr/dev/reentry)
    Finished dev [unoptimized + debuginfo] target(s) in 0.98s
     Running `/home/rskerr/dev/reentry/target/debug/reentry`
serialized = (objects:[(labels:["Bridge"],description:"the bridge",location:None,destination:None),...)
Welcome to Reentry. A space adventure.

You awake in darkness with a pounding headache.
An alarm is flashing and beeping loudly. This doesn't help your headache.

> quit

Quitting.
Thank you for playing!
Bye!
$

Explanation
5 - This is what we're after. This output is the serialized version of the World struct from rlib.rs. We can copy this and paste it into the game_file.ron file.

That was Step 1. Now for Step 2! Copy the content on line 5 and paste it into the game_file.ron file. With a little bit of formatting we get this:

//
// Reentry
//
// A game by Riskpeep
World (
  objects: [
    (labels      : ["Bridge"],
     description : "the bridge",
     location    : None,
     destination : None
    ),
    (labels      : ["Galley"],
     description : "the galley",
     location    : None,
     destination : None
    ),
    (labels      : ["Cryochamber"],
     description : "the cryochamber",
     location    : None,
     destination : None
    ),
    (labels      : ["Yourself"],
     description : "yourself",
     location    : Some(0),
     destination : None
    ),
    (labels      : ["Glossy Photo","Photo"],
     description : "a glossy photo of a family. They look familiar",
     location    : Some(0),
     destination : None
    ),
    (labels      : ["Cryosuit"],
     description : "a silver suit that will protect you in cryosleep",
     location    : Some(2),
     destination : None
    ),
    (labels      : ["Wrinkled Photo","Photo"],
     description : "a wrinkled photo of a woman. They woman is crying",
     location    : Some(7),
     destination : None
    ),
    (labels      : ["Copilot"],
     description : "your copilot sleeping in his cryochamber",
     location    : Some(2),
     destination : None
    ),
    (labels      : ["Pen"],
     description : "a pen",
     location    : Some(7),
     destination : None
    ),
    (labels      : ["Aft"],
     description : "a passage aft to the galley",
     location    : Some(0),
     destination : Some(1)
    ),
    (labels      : ["Forward"],
     description : "a passage forward to the bridge",
     location    : Some(1),
     destination : Some(0)
    ),
    (labels      : ["Aft"],
     description : "a passage aft to the cryochamber",
     location    : Some(1),
     destination : Some(2)
    ),
    (labels      : ["Forward"],
     description : "a passage forward to the galley",
     location    : Some(2),
     destination : Some(1)
    ),
    (labels      : ["Forward","Port","Starboard"],
     description : "a bulkhead covered in switchpanels and gauges",
     location    : Some(0),
     destination : None
    ),
    (labels      : ["Port","Starboard"],
     description : "a smooth bulkhead with an endless void on the other side",
     location    : Some(1),
     destination : None
    ),
    (labels      : ["Aft","Port","Starboard"],
     description : "cryochambers backed by a dense tangle of pipes, tubes, and conduits",
     location    : Some(2),
     destination : None
    )
  ]
)

This is exactly what we need to move on to step 3! Save this file as game_file.ron. For step 3 we're back in the code again.

The next step is to modify read_from_file() once again. The previous version serialized the World struct. We'll replace that code with new code to deserializes the game file and then pass it back to the game for use. The new implementation looks like this:

impl World {
	// --snip--
    pub fn read_from_file(game_file: &str) -> Result<World, std::io::Error> {
        let game_file_path = Path::new(game_file);
        let game_file_data_res = read_to_string(game_file_path);

        match game_file_data_res {
            Ok(game_file_data) => {
                /* // Create a new World struct
                   let new_world = World::new();

                   // Write (serialize) the struct to a string using Serde
                   let serialized_ron = ron::to_string(&new_world).unwrap();
                   
                   // Write the serialized string to the console
                   println!("serialized = {}", serialized_ron);
                */

                // Read (deserialize) a World struct from the game_file string
                let deserialized_ron_result: Result<World, ron::error::SpannedError> =
                    ron::from_str(&game_file_data);
                match deserialized_ron_result {
                    Ok(deserialized_ron) => Ok(deserialized_ron),
                    Err(de_err_str) => Err(std::io::Error::new(
                        std::io::ErrorKind::Other,
                        de_err_str.to_string(),
                    )),
                }
            }
            Err(file_err) => Err(file_err),
        }
    }
    // -- snip --
}

Explanation
3 - The updated read_from_file() function.
8 - Matches on successful read of the game file. Extracts the content of the file into game_file_data.
9-17 - The previous implementation. Commented out, but left for clarity.
20-21 - Attempt to extract a World struct from the game_file_data string.
22-28 - Error check the deserialization attempt.
23 - Handle Ok() results. Return the deserialized struct wrapped in an Ok() result.
24-27 - Handle Err() results. Converts the error from the deseralization into a std::io::Error and then bubbles it up as the return result.

With these changes we've now externalized the definition of the game. All of the items in the game are now read at run-time from the game_file.ron file.

So, mission accomplished, right? Maybe not.

Lets look back at the goals we set out at the beginning and ask ourselves if we've accompliched them.

  • Is the file separate from the game's source code? Yes,
  • Is the file written in a format of our choosing? Yes,
  • Does the solution allow us to easily add and edit game objects? Not really! (see below)
  • Does the solution allow us to have many attributes on objects? Yes

Not bad. 3 out of 4 in our first go. But why not all four? Lets look more closely at the game_file.ron file. Specifically lets look at the location and destination fields.

In our source code location and destination looked like this:

// --snip--
const LOC_COPILOT: usize = 7;
//--snip--
Object {
  labels: vec!["Pen".into()],
  description: "a pen".into(),
  location: Some(LOC_COPILOT),
  destination: None,
},
// --snip--

See how the location field has an actual name for the location (LOC_COPILOT).

In contrast, our game file has numbers instead:

// --snip--
(labels      : ["Pen"],
  description : "a pen",
  location    : Some(7),
  destination : None
),
// --snip--

That is far less useful. If we only have a few objects then sure, we could use numbers, but with more than about 10, keeping track of which object links to which number will be not only tedious, but error prone. The code version is better because it uses an actual name. But event THAT doesn't meet our goal because we have to edit multiple places to keep the numbers lined up. On top of that what might happen if we mistakenly declared two of the const definitions with the same number?

It turns out that we can do much better. Serde provides all the tools we need. BUT... the path to get there goes deeper than we have space for here, so we're going to cover that in the next post.

Progress

We've done a lot in this post already. With just a few changes, we now have the game described in data outside of our source code. Serde provided the core functionality we needed. In the next post, we'll dig deeper into Serde and build custom serializer and deserializer functions that allow us to use actual names in our game file.

⇂  View Source

Enjoyed this post? Help me create more with a coffee. Never miss out on future posts by following me.

Comments

Popular posts from this blog

How to make a Text Adventure game in Rust - Introduction