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

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 II

In the last post we began moving the definition of the game out of the source and into a file using the Serde crate. By the end of the post we had modified the game to first write and then read the game definition from a file.

But the file, while functional, did not satisfy all of the goals we had set out to accomplish. Instead of using names to refer to locations and destinations, the file used integer indexes into the list of game objects. Left as is, the indexes would be difficult to keep correct as the file got larger. Changes to the file such as adding new objects or moving around existing ones would mean updating numerous entries and almost surely create errors in the game. Using Serde we can create custom serialize and deserialize functions to eliminate the use of indexes and use object names instead to store object relationships. This approach would be much less prone to error and enable moving objects around easily.

In this post, we'll implement custom serialize and deseralize functions that extend the solution we implemented in the last post to use named locations and destinations.

Adding a Game File Step 3 - Named Location and Destinations with Custom Serialization and Deserialization

We showed in the last post that Serde macros can automatically create serialize and deseralize functions. But as is, the World struct uses integer indexes for the location and destination fields. The automatically generated serialization and deserialization functions reflect this and thus the game file has integer indexes in it. If the game instead used a struct that used names to refer to other objects we could use Serde generated functions to serialize and deserialize them and we'd have the file we want.

Lets explore this approach more fully. The current implementation works as shown in the figure below. World structs contain a Vec of Object structs, and the Object structs have a number of attributes. The problem is the location and destination attributes, which are of type Option<usize>. Because of this, the serialized representation is as an Option and we see values like Some(7), and None in the game file.


We can change things by adding intermediate types called SavedWorld and SavedObject respectively. These new types will use Strings to represent location and destination so the game file will contain strings as we want. To convert from our 'normal' objects, we'll implement the From and Into trait on the normal objects with their saved counterparts.

To make this work we'll use the following process

  1. Create alternate versions of the World and Object structs that have the named values we want,
  2. Write From and Into trait implementations to convert between the normal and save structs,
  3. Implement custom serialize and deserialize functions for the normal structs, and
  4. Update the game file and put it all together.

Task 1 - Create alternate World and Object structs

The first task is to create new structs that use named values. We'll call them SavedWorld and SavedObject to reflect that they are going to be saved. They look like this:

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

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

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

#[derive(Serialize, Deserialize, Debug)]
pub struct SavedWorld {
    pub objects: Vec<SavedObject>,
}
// --snip--

Explanation
2-8 - The 'normal' Object struct. No changes here.
10-13 - The 'normal' World struct. No changes here.
15-21 - The 'saved' Object struct. Note the use of String for location and destination.
23-26 - The 'saved' World struct. Note the Vec is now a Vec of SavedObject structs.

That is it for task 1! BUT, Notice that we've included derive for Serialize and Deserialize trait implementations on both of the new structs. That is so that we can use Serde to automatically serialize these structs for us. The derived implementations allow writing and reading SavedWorld and SavedObject structs to and from the game file respectively. If we can get our game data into SavedWorld and SavedObject structs we would be able to go all the way from our game code out to the file and back. That is what Task 2 is all about. Lets go there now.

Task 2 - Create From and Into trait implementations

Next up is to create implementations of the From and Into traits for our new SavedObject and SavedWorld structs that allow the game to convert between them and the normal Object and World structs. We'll use these as part of the serialization and deserialization of Object and World to convert to and from their normal form into the saved form for which can use derived serialization functions.

We will implement the From trait first. The From trait implementation needs to take in a World struct and return a SavedWorld struct as output. Mostly the implementation is walking the objects vector and for each one constructing a corresponding SavedObject instance that converts the object struct's location and destination Options into Strings.

impl From<&World> for SavedWorld {
    fn from(value: &World) -> Self {
        let mut new_vec_of_objects: Vec<SavedObject> = Vec::new();

        for item in &value.objects {
            new_vec_of_objects.push(SavedObject {
                labels: item.labels.clone(),
                description: item.description.to_string(),
                location: match item.location {
                    Some(location) => value.objects[location].labels[0].to_string(),
                    None => "".to_string(),
                },
                destination: match item.destination {
                    Some(destination) => value.objects[destination].labels[0].to_string(),
                    None => "".to_string(),
                },
            });
        }

        SavedWorld {
            objects: new_vec_of_objects,
        }
    }
}

Explanation
1 - The From trait implementation for SavedWorld. This implementation is generic for World structs.
2 - The from function for the trait. Accepts a reference to a World struct (called value) and returns a SavedObject struct.
3 - A mutable vector of SavedObjects. Used to accumulate the converted Object structs as we do the conversion.
5-18 - Loop through the objects vector of the passed World.
6-17 - For each object construct a new SavedObject.
9-12 - Set location using a lookup into the source World.objects list. Use the first label as the object name.
13-16 - Set destination using a lookup into the source World.objects list. Use the first label as the object name.
20-22 - Create a new SavedWorld struct. Set its objects field to the list of accumulated SavedObject structs.

The lookups on lines 10 and 14 have two important ramifications. First is that cannonical name for an object is the first value in the labels array. That value will be used to refer to the object and is the value that will appear in SavedObject and in the saved file to refer to the object. This leads to the second important point, which is that the first value in the labels vec must be unique across ALL objects. This means that when naming objects, the most specific name should be the first one in the list of labels. Other simplified names, such as 'photo' to refer to a 'glossy photo,' should appear later in the list.

Let us now look at implementing the Into trait. Into accomodates the case of reading from a saved game file. Implementing Into is complicated by the fact that Into can fail. What might happen, for example, if the user enters a location or destination that doesn't exist? This is not a case of bad syntax (Serde will catch that for us), but rather of well formed, but inconsistent content in the game file. It turns out that Rust has a variant of Into called TryInto that accommodates this very case. TryInto returns a Result which allows us to catch and then handle the errors that can arise from mappings that fail.

Implementing TryInto requires implementing a single function try_into(), but it also requires the use of a defined error type. For our implementation, we'll create a custom type ParseError that we can use to represent errors that arise when reading saved game files. Implementing ParseError is straight forward, but does warrant some explanation, so we'll review it separately here. When implementing an error type, we first implement the type, and then implement the std error::Error trait on the type. Additionally, types that implement std error::Error must also implement Debug and Display. Those implementations are also shown here.

use std::error
// --snip--

#[derive(Debug)]
pub enum ParseError {
    UnknownName(String),
}

impl error::Error for ParseError {}

impl fmt::Display for ParseError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            ParseError::UnknownName(message) => write!(f, "{}", message),
        }
    }
}

Explanation
1 - The Error trait is included from std::error
5-7 - The error type we need to implement. The implementation is a simple enum with a string annotation. Here we only show one error enum value, but we could easily implement others if needed.
9 - Implementing the Error trait for ParseError
4 - ParseError must implement Debug. We can use the derived Debug implementation.
11-17 - ParseError must also implement the Display trait. This implementation just passes through the string that is included in the error itself.

With the required error type implemented, we can now implement the TryInto trait. As stated previously, TryInto only requires the implementation of one function, try_into(). The implementation here operates on a SavedWorld struct, looping over its vector of SavedObjects to accumulate a new vector of Objects that it then returns inside a newly created World struct. For each found SavedObject, the implementation creates a new Object struct. The implementation copies the labels and description values from the source struct. For the location and destination values, the implementation loops over the objects vec in SavedWorld to find the appropriate index to use in the new object. The found_location and found_destination booleans are used to detect that both a location and destination are found. If both are found, then the loop short circuits to move onto the next Object. If either are not found, the implementation returns a ParseError with the name of the unmatched location or destination string.

impl Object {
    fn new(
        new_labels: Vec<String>,
        new_description: String,
        new_location: Option<usize>,
        new_destination: Option<usize>,
    ) -> Object {
        Object {
            labels: new_labels,
            description: new_description,
            location: new_location,
            destination: new_destination,
        }
    }
}

impl TryInto<World> for SavedWorld {
    type Error = ParseError;

    fn try_into(self) -> Result<World, Self::Error> {
        let mut new_vec_of_objects: Vec<Object> = Vec::new();

        'items: for item in &self.objects {
            let mut new_object = Object::new(
                item.labels.clone(),
                item.description.to_string(),
                None,
                None,
            );

            let mut found_location: bool = item.location.is_empty();
            let mut found_destination: bool = item.destination.is_empty();

            for (pos, internal_item) in self.objects.iter().enumerate() {
                if item.location == internal_item.labels[0] {
                    new_object.location = Some(pos);
                    found_location = true;
                }
                if item.destination == internal_item.labels[0] {
                    new_object.destination = Some(pos);
                    found_destination = true;
                }
                if found_location && found_destination {
                    new_vec_of_objects.push(new_object);
                    continue 'items;
                }
            }

            if !found_location {
                return Err(ParseError::UnknownName(format!(
                    "Unknown location '{}'",
                    item.location
                )));
            }

            if !found_destination {
                return Err(ParseError::UnknownName(format!(
                    "Unknown destination '{}'",
                    item.destination
                )));
            }

            new_vec_of_objects.push(new_object);
            return Err(ParseError::UnknownName("How are we here?".into()));
        }

        let result_world = World {
            objects: new_vec_of_objects,
        };

        Ok(result_world)
    }
}

Explanation
1-15 - Helper implementation of new() for Object Used by the try_into() implementation.
17 - Implementation of the TryInto trait for SavedWorld. The implementation is generic for World structs.
18 - Define the type association for Error to associate our ParseError type as the error type used in this trait implementation.
20 - The try_into() function. Returns a Result<World, Self::Error>. The Self::Error here will be a ParseError because of the type association on line 2.
23-65 - Loop through each SavedObject in the SavedWorld struct.
24-29 - For each SavedObject, create a new Object and copy the labels and description values. Make it mutable so we can update the location and destination values later.
31-47 - Try to find location and destination values by looping over our list of SavedObject structs. Short circuit the loop if we find both before the end of the vector of SavedObject structs.
49-61 - If we haven't found location or destination after checking each SavedObject then this must be an undefined name. Return an error.
63-64 - Catch all error for any cases we haven't considered. This is most likely dead code that can never be executed.
67-69 - If we're here we've matched all the locations and destinations in the vector of SavedObjects and have accumulated a vector of Object structs. The lines here construct a new World struct for return.
71 - Success. Return the constructed World struct.

With the From and TryInto traits we now have implementations that can move data from our in game World and Object structs into and out of the SavedWorld and SavedObject structs. Recall that in Task 1, we have Serde derived methods that will write those structs out to a file and back. That is the entire path we need. We could stop here and manually convert the game World struct into a SavedWorld struct before writing (or reading) to the game file, but that would complicate our read_from_file function. Instead, we can create custom serializer and deserializer functions that wrap these conversions and abstarct the SavedWorld and SavedObject structs away from the other game code. We'll do that in our last task, Task 3.

Task 3 - Implement custom serialize and deserialize functions for the normal structs

In this section we'll implement the Serialize and Deserialize traits for the World struct. These implementations will encapsulate all the work we've done in this post and make it look like we're serializing the World and Object structs directly.

We'll start with the implementation of the Serialize trait. Its implementation is just as you might expect - when called on a World struct, the implementation first pushes the World struct into a SavedWorld struct, and then uses Serde to serialize that struct.

use serde::ser::{SerializeStruct, Serializer};
// --snip--

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

// --snip--

impl Serialize for World {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        let serializeable_struct: SavedWorld = SavedWorld::from(self);

        // 1 is the number of fields in the struct.
        let mut state = serializer.serialize_struct("SavedWorld", 1)?;
        state.serialize_field("objects", &serializeable_struct.objects)?;
        state.end()
    }
}

Explanation
1 - Added dependency for the Serialize trait.
4-7 - Here we're adjusting the implementation of World to remove the derived implementation of Serialize and Deserialize. Removing the derived versions here is required before implementing custom versions. (Note that the Deserialize trait is implemented in a separate snippet, below).
11 - Implementation of the Serialize trait for the World struct.
12-14 - The serialize() function declaration. The where keyword is a type constraint that requires that the generic for the function implements the Serializer trait.
16 - Use the from() implementation from the From trait implemented on SavedWorld to convert the World struct into a SavedWorld struct.
18-21 - These lines are the normal Serde serialization pattern to serialize a struct. First call serialize_struct() with the type and number of fields, then serialize each field, then end to complete the serialization.

The implementation of the Serialize trait is easy to digest. Serde deserialization, however, is quite a bit more complex to follow. The complexity arises from Serde's heavy use of generics, Serde's pattern of declaring embedded structs and functions, the fact that in Serde deserializers must deal with the different ways that a value can be serialized (as either a sequence or a map in the case of a struct) and the use of the visitor pattern in Serde. For brevity, we won't describe here all or even most of the deserialization code. There are better and more comprehensive descriptions of deserialization on the Serde site. We will provide (hopefully) enough high level commentary to emphasize what makes the implementation here work as needed.

use serde::de::{self, Deserializer, Error, MapAccess, SeqAccess, Visitor};
// --snip--

impl SavedWorld {
    fn new(new_objects: Vec<SavedObject>) -> SavedWorld {
        SavedWorld {
            objects: new_objects,
        }
    }
}

// --snip--

impl<'de> Deserialize<'de> for World {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        enum Field {
            Objects,
        }

        impl<'de> Deserialize<'de> for Field {
            fn deserialize<D>(deserializer: D) -> Result<Field, D::Error>
            where
                D: Deserializer<'de>,
            {
                struct FieldVisitor;

                impl<'de> Visitor<'de> for FieldVisitor {
                    type Value = Field;

                    fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
                        formatter.write_str("`objects`")
                    }

                    fn visit_str<E>(self, value: &str) -> Result<Field, E>
                    where
                        E: de::Error,
                    {
                        match value {
                            "objects" => Ok(Field::Objects),
                            _ => Err(de::Error::unknown_field(value, FIELDS)),
                        }
                    }
                }

                deserializer.deserialize_identifier(FieldVisitor)
            }
        }

        struct SavedWorldVisitor;

        impl<'de> Visitor<'de> for SavedWorldVisitor {
            type Value = SavedWorld;

            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
                formatter.write_str("struct SavedWorld")
            }

            fn visit_seq<V>(self, mut seq: V) -> Result<SavedWorld, V::Error>
            where
                V: SeqAccess<'de>,
            {
                let objects = seq
                    .next_element()?
                    .ok_or_else(|| de::Error::invalid_length(1, &self))?;
                Ok(SavedWorld::new(objects))
            }
            fn visit_map<V>(self, mut map: V) -> Result<SavedWorld, V::Error>
            where
                V: MapAccess<'de>,
            {
                let mut objects = None;
                while let Some(key) = map.next_key()? {
                    match key {
                        Field::Objects => {
                            if objects.is_some() {
                                return Err(de::Error::duplicate_field("objects"));
                            }
                            objects = Some(map.next_value()?);
                        }
                    }
                }
                let objects = objects.ok_or_else(|| de::Error::missing_field("objects"))?;
                Ok(SavedWorld::new(objects))
            }
        }

        const FIELDS: &[&str] = &["objects"];
        let internal_extract = deserializer.deserialize_struct("World", FIELDS, SavedWorldVisitor);
        match internal_extract {
            Ok(extracted_val) => {
                let external_val = extracted_val.try_into();
                match external_val {
                    Ok(result_val) => Ok(result_val),
                    // From here: https://serde.rs/convert-error.html
                    // But that lacks context, this one is better:
                    // https://stackoverflow.com/questions/66230715/make-my-own-error-for-serde-json-deserialize
                    Err(_) => external_val.map_err(D::Error::custom),
                }
            }
            Err(err_val) => Err(err_val),
        }
    }
}

Explanation
1 - Added dependencies for the Deserialize and other traits used in the implementation here.
4-10 - Helper implementation of new() for SavedWorld Used in the Deserialize implementation.
14 - Implementation of the Deserialize trait for the World struct.
15-17 - The deserialize() function declaration. The where keyword is a type constraint that requires that the generic for the function implements the Deserializer trait.
18-105 - Implementation of the deserialize() function. This implementation follows the patterns in typical use for Serde as described in the Serde documentation. This pattern makes use of embedded structures and functions as shown here.
19-50 - deserialize() implementation part 1, containing the definition of Field and an embedded Deserialize implementation for that enum. This code is called during deserialization on lines 66 and 75.
52-88 - deserialize() implementation part 2, containing the visitor implementation for the SavedWorld struct. This code is called during deserialization on line 91.
90-104 - deserialize() implementation part 3. This section is most analagous to the previous serialize() implementation.
91 - Attempts to deserialize a SavedWorld struct using the defined visitor
94 - If successful attempt to convert the extracted SavedWorld into a World struct using try_into().
96 - If successully converted to a World result, return the result.
100 - If not successful, return an error. Of note here is that the returned error from try_into() is of type ParseError and the type that deserialize() must return is of type de::Deserializer::Error. The description provided by Serde for error conversion and a Stack Overflow post proved to be particularly instructive in helping me to find the map_err() function.

With this implementation, we now have fully encapsulated the round trip from the game's internal state representation (in the World struct) out through SavedWorld and to a game file persisted on disk in an easy to read and update format. In the next section, we'll put it all together to have the game read information from a file.

Task 4 - Update the game file and put it all together

In the last post, we used a modified version of the read_from_file() function to write a version of the file game_file.ron based on the statically defined World definition in the game code. With the serialization and deserialization code implemented here we need a new game file that contains string based names for location and destination. We can use the same modified version of read_from_file() we used previously to create an updated game file. If we revert to that code and re-run the game, we see the following out.

$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.02s
     Running `/home/rskerr/dev/reentry/target/debug/reentry`
serialized = (objects:[(labels:["Bridge"],description:"the bridge",location:"",destination:""),...])
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!

As previously, we can reformat the output and arrive at a file like so. (We've snipped out most of the file for brevity, but the full file is included in the code repository.) Notice that the location and destination fields are now strings that refer to other objects by label.

//
// Reentry
//
// A game by Riskpeep
World (
    objects : [
        (labels     : ["Bridge"],
        description : "the bridge",
        location    : "",
        destination : ""
        ),

// --snip--

        (labels     : ["Glossy Photo", "Photo"],
        description : "a glossy photo of a family. They look familiar",
        location    : "Bridge",
        destination : ""
        ),

// --snip--

        (labels     : ["Forward"],
        description : "a passage forward to the galley",
        location    : "Cryochamber",
        destination : "Galley"
        ),

// --snip--

    ]
)

We now can save this new file as game_file.ron (replacing the previous one), and reset read_from_file() back to the unmodified version. Now, when we run the game, the game definition will come from our newly modified game_file.ron file. To be certain the game is pulling content from the file, we can modify the game file to provide an updated description for the bridge.

// --snip--
        (labels     : ["Bridge"],
        description : "the bridge. Switches, dials, and blinking lights cover the walls.",
        location    : "",
        destination : ""
        ),
// --snip--

The above change exists only in the game file and not in the game's source code. When we run the game we can confirm that the game content comes from the file.

$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.01s
     Running `/home/rskerr/dev/reentry/target/debug/reentry`
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.

> look

Bridge
You are in the bridge. Switches, dials, and blinking lights cover the walls..
You see:
a glossy photo of a family. They look familiar
a passage aft to the galley
a bulkhead covered in switchpanels and gauges

> quit

Quitting.
Thank you for playing!
Bye!

The description shows the edited string. Success!

Progress

With these changes, we now have a much more easily editable game file. While using strings to indicate locations and destinations is more verbose than using numbers, using location names is much more readable and thus far less likely to result in confusion when editing the game file. In addition, we can now move objects around, add new objects in the middle of the file, and delete objects without having to update indexes across the entire file. Changes are much more likely to be contained in the changed objects and those they connect to.

So... Can we declare mission accomplished now?

Lets once again review our goals 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? Yes!
  • Does the solution allow us to have many attributes on objects? Yes

This is an overall win. We accomplished all of our goals and now have a way to edit the game without rebuilding the program every time. There are still a few enhancements we might make to, for example, set the game introduction text, adjust verb names, error responses, or any other of the hard coded strings in the game. Such changes would be an extension to the implementation we've accomplished here and would move the game to a more fully implemented game engine.

In the next post, we'll look at adding additional attributes to game objects that will further enhance the player's experience and interactivity in the game.

⇂  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