How to make a Text Adventure game in Rust - IV - Objects

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

4 - Objects

A note to readers. This post contains a pretty big Aha! moment for me. The approach here is what made me want to implement a text adventure following this example.

Puzzles are what make a text adventure game interesting. Well... puzzles AND great writing. But writing without puzzles is a novel and not what your players are after.

Most puzzles in an text adventure game revolve around items in the world such as a pen or a sword, actors such as a thief or a copilot, and locations such as a field or a space capsule.
  • A pen must be used to write a note to the copilot,
  • A sword must be used to kill a thief or scare it off, or
  • A switch must be thrown in the bridge of the capsule.
We looked at locations in our last post. In this post, we're going to look at representing items and actors in our game.

What we could do...

Consider the situation shown in this picture.

An image showing three locations: The bridge, the galley, and the cryochamber. The locations contain various objects. The bridge holds the player and a photo. The cryochamber holds the copilot a cryosuit and and a pen.

We could represent an item or an actor with a structure like so:
  • name: the name of the object.
  • description: the description of the object.
  • location: where the item/actor is located. Stored as an index into the locations vec!().
pub struct Object {
    pub name: String,
    pub description: String,
    pub location: usize,
}

// ...
objects: vec![
    Object {
        name: "Photo".to_string(),
        description: "a photo of a family. They look familiar".to_string(),
        location: 0,
    },
    Object {
        name: "Copilot".to_string(),
        description: "your copilot. You've known him for years".to_string(),
        location: 1,
    },
]
// ...
Note how similar this structure is to the Location struct we created last time. They're so similar that we could merge them and treat everything as an object.
pub struct Object {
    pub name: String,
    pub description: String,
    pub location: Option<usize>,
}

// ...
objects: vec![
    Object {
        name: "Bridge".to_string(),
        description: "the bridge".to_string(),
        location: None,
    },
    Object {
        name: "Galley".to_string(),
        description: "the galley".to_string(),
        location: None,
    },
    Object {
        name: "Cryochamber".to_string(),
        description: "the cryochamber".to_string(),
        location: None,
    },
    Object {
        name: "Photo".to_string(),
        description: "a photo of a family. They look familiar".to_string(),
        location: Some(0),
    },
    Object {
        name: "Copilot".to_string(),
        description: "your copilot. You've known him for years".to_string(),
        location: Some(3),
    },
    Object {
        name: "Pen".to_string(),
        description: "a pen".to_string(),
        location: Some(5),
    },
]
// ...
With this modification, we would have items, actors, and locations all in the same vector. We might use a Rust Option for the location field because actual locations won't have a location value. With an Option we could the location value to None. (Note, we could also use a nonsense number for the same purpose, but the Rust Option type covers this very scenario and lets us avoid using a magic number. This decision will come back to create 'fun' later on, stay tuned.)

To make things easier we could define consts to represent locations:
const LOC_BRIDGE: usize = 0;
const LOC_GALLEY: usize = 1;
const LOC_PHOTO: usize = 2;
const LOC_CRYOCHAMBER: usize = 3;
const LOC_PHOTO: usize = 4;
const LOC_COPILOT: usize = 5;
const LOC_PEN: usize = 6;
The examples below show how we would use these new structures.
// Prints "You are in the galley."
println!("You are in {}.",objects[LOC_GALLEY].description);

// Prints all items and actors present in the 'bridge'
for obj in objects {
	match obj.location {
    	Some(obj_location) if obj_location == LOC_BRIDGE =>
        	println!("{}\n", obj.description);
        _ => ();
    }
}

But should we?

So we've seen that we could represent everything in the game as an object. Wouldn't it be easier to keep all of them separate? To keep locations in a locations list, items in an items list, and actors in an actors list? Especially since Rust is strongly typed, we could have separate functions that know how to deal with locations, items, etc. For shared functions, we could use generics and everything would be separate.

We could keep them separate, but doing so starts to fall apart when you think about how limiting it is. Lets consider some questions to help guide us.
  1. How might we represent an item that is also a location? (e.g. a space ship that moves from place to place.)
  2. How might we represent a location or an item that is also an actor? (e.g. an alien that you could both talk to and pick up.)
  3. How might we represent items that contain other items? (e.g. a treasure chest that contains gold)
  4. How might we represent items that contain other actors or actors that hold items? (e.g. a thief that holds a chest (which also holds gold))
Very quickly, we run into situations where we want something in more than one list at the same time. Some more questions really bring this home. Consider a magic lamp. Is it a location or an object?
  1. It probably has a location, can be picked up, moved, etc.
  2. But it might also be a location that a Genie lives in, or that you could go into.
  3. What if the lamp is animated? Could you talk to it as an actor? Can it DO things like an actor would?
Putting everything in one list solves the lamp problem and opens up all sorts of possibilities that we would struggle to capture if we treat objects as separate and different things. For all the same reasons, it also doesn't make sense to give objects a 'type' attribute that indicates if they are a location, item, or actor. We've just considered that objects could be more than one kind of thing, the last thing to do would be to squeeze them back into a single thing with 'type' value! When it really does matter, we can use attributes of the object itself to let us know if something is more location like or more item like, etc.
  • Locations will (eventually) be connected by 'passages'. Objects with passages that enter or leave are locations. A player can go to them by following a passage.
  • Items have a location and can (possibly) picked up and moved. We can use a 'weight' property to control what a player can pick up or not.
  • Actors can be interacted with, talked to, fought, etc. Actors may have their own behavior that causes them to move. Actors could even be other players in another process. We could use a 'health' property to determine if they are alive.
This approach is much more flexible and lets us 'do' more with the engine.

See me, touch me, feel me, heal me...

There is one last thing to consider. In our previous implementation with the Location struct we had a separate variable player_location that we used to hold the location of the player. We've said above that actors should be treated as objects. If we go all the way down that road, we should treat the player as an object! We can capture the player's location in the same way we know where everything else in the game is, by using the location field.
// Expression to move the player to the bridge
objects[LOC_PLAYER].location = Some(LOC_BRIDGE);

// Expression to return a description of the current location
objects[objects[LOC_PLAYER].location.unwrap()].description;
The upside here is that by putting the player into the objects list, all the other actors can interact with the player using the same shared code that we'll use when processing player actions. In this sense, we're treating the player as just another actor in the game world and proceeding from there. ALL of the apparatus we build out to interact with other actors and objects can also apply to the player themselves. They could for example 'talk' with themselves, or 'examine' themselves, or 'look' at themselves and we'll have all the structure we need to capture those interactions.

Lets do this

Having considered the options, lets make the switch from Locations to Objects. We'll start with the definition of the Object structure and the consts we'll need to represent object indices in the objects Vec().
pub struct Object {
    pub name: String,
    pub description: String,
    pub location: Option<usize>,
}

const LOC_BRIDGE: usize = 0;
const LOC_GALLEY: usize = 1;
const LOC_CRYOCHAMBER: usize = 2;
const LOC_PLAYER: usize = 3;
const LOC_PHOTO: usize = 4;
const LOC_CRYOSUIT: usize = 5;
const LOC_COPILOT: usize = 6;
const LOC_PEN: usize = 7;

pub struct World {
    pub objects: Vec<Object>,
}

Explanation
1 - Define the Object struct. Each in game item, actor and location is represented as an object.
2 - name: the short name of the object
3 - description: a description of the object. This is where we'll put narrative text that describes an object and brings it to life.
4 - location: the index into the objects Vec() of the object's current location.
7-13 - LOC_<object> - consts that represent indices of specific objects in the objects Vec().
16 - objects: contains the object structs that represent game elements. Note that the Vec() is specialized to contain Object structs. The objects Vec() replaces the previous locations Vec() in the World struct. 

Next we can define the objects vector in the new() function. The objects defined here match the image from above. Notice how using the consts for location makes setting locations much more clear than a simple index into the array would. Notice also how the 'pen' is contained within the 'cryosuit' by setting its location to that of the 'cryosuit'.
impl World {
    pub fn new() -> Self {
        World {
            objects: vec![
            	// Foo
                Object {
                    name: "Bridge".to_string(),
                    description: "the bridge".to_string(),
                    location: None,
                },
                Object {
                    name: "Galley".to_string(),
                    description: "the galley".to_string(),
                    location: None,
                },
                Object {
                    name: "Cryochamber".to_string(),
                    description: "the cryochamber".to_string(),
                    location: None,
                },
                Object {
                    name: "Yourself".to_string(),
                    description: "yourself".to_string(),
                    location: Some(LOC_BRIDGE),
                },
                Object {
                    name: "Photo".to_string(),
                    description: "a photo of a family. They look familiar".to_string(),
                    location: Some(LOC_BRIDGE),
                },
                Object {
                    name: "Cryosuit".to_string(),
                    description: "a silver suit that will protect you in cryosleep".to_string(),
                    location: Some(LOC_CRYOCHAMBER),
                },
                Object {
                    name: "Copilot".to_string(),
                    description: "your copilot sleeping in his cryochamber".to_string(),
                    location: Some(LOC_CRYOCHAMBER),
                },
                Object {
                    name: "Pen".to_string(),
                    description: "a pen".to_string(),
                    location: Some(LOC_CRYOSUIT),
                },
            ],
        }
    }

    // ...
}

Explanation
1 - Define the World struct implementation.
4 - Define the objects Vec(). Each in game item, actor and location is represented as an item in this Vec().
6-45 - Objects that define the game world. The list here mirrors the structure of the image above.

Can you see me?

The code segments above define the data structures for storing objects. Next we need some functions to interact with objects that we've defined with those data structures. We'll need to be able to view the objects at a location, find out if we can see a given object from where the player is, go to locations, etc. The first functions we'll implement are helper functions that let us check if an object has the same name a the noun that the player entered, and what the index of that object is. Their implementation is straight forward, so we won't comment on them beyond showing the code.
impl World {
    // ...
    fn object_has_name(&self, object: &Object, noun: &String) -> bool {
        *noun == object.name.to_lowercase()
    }

    fn get_object_index(&self, noun: &String) -> Option<usize> {
        let mut result: Option<usize> = None;
        for (pos, object) in self.objects.iter().enumerate() {
            if self.object_has_name(&object, noun) {
                result = Some(pos);
                break;
            }
        }
        result
    }
    
    // ...
}
The next method to implement will check to see if an object is visible and something the player can interact with.

Before looking at the implementation, it will be helpful to consider what it means to be visible. That will drive the implementation. We can say that an object is visible to the player if any of the following conditions are true:
  • Is the object the player himself? Players can always see themselves.
  • Is the object the location where the player is? (Remember locations are objects too)
  • Is the object being 'held' by the player?
  • Is the object at the same location as the player?
  • Is the object contained by an object the player is holding?
  • Is the object contained by an object in the same location as the player?
We also need to add a check for any other location. This is temporary until we add passages, but if we didn't have it the player wouldn't be able to move because the game wouldn't think the player could see the location and prevent the player from going there.
  • Is this object any location?
If any of the above are true, then the object is visible and the code should return its index. If none of the above are true, the player can't see the object and the player shouldn't be able to interact with it.

For the implementation, we need to know where the player is, where the object in question is, and then check all of the above conditions. Sounds simple enough, lets look at the implementation.

Sidebar - A descent into madness
Dear readers - It is at this point that the Rust implementation started to go a little sideways. Using Options to store locations in the objects Vec() meant the I couldn't just use the location directly as an index into the array, but instead needed to unwrap the Option before using it (and also deal with None, etc.). This created syntax challenges when trying to write the function. On top of that rustfmt insists on destroying any notion of visual structure used to enhance readability. What is left is a well aligned (in someone's view) mess that even code highlighting can't help. The code below is my third attempt at a correct implementation of get_visible(). I'm not at all happy with it. It works, but IMO is not at all easy to read and reason about. It is far too verbose and too convoluted. The C implementation of this same code is far more concise and easier to follow.

I've left comments inline to try to add clarity, but I can't help but think that there is a better way. At a later date I'm going to write a whole post on the 'fun' that this implementation created for me. Until then, look below, weep for me, and then let us both move along and put this behind us.
impl World {
    // ...

    fn get_visible(&self, message: &str, noun: &String) -> (String, Option<usize>) {
        let mut output = String::new();

        let obj_index = self.get_object_index(noun);
        let obj_loc = obj_index.and_then(|a| self.objects[a].location);
        let obj_container_loc = obj_index
            .and_then(|a| self.objects[a].location)
            .and_then(|b| self.objects[b].location);
        let player_loc = self.objects[LOC_PLAYER].location;

        match (obj_index, obj_loc, obj_container_loc, player_loc) {
            // Is this even an object?  If not, print a message
            (None, _, _, _) => {
                output = format!("I don't understand {}.\n", message);
                (output, None)
            }
            //
            // For all the below cases, we've found an object, but should the player know that?
            //
            // Is this object the player?
            (Some(obj_index), _, _, _) if obj_index == LOC_PLAYER => (output, Some(obj_index)),
            //
            // Is this object the location where the player is?
            (Some(obj_index), _, _, Some(player_loc)) if obj_index == player_loc => {
                (output, Some(obj_index))
            }
            //
            // Is this object being held by the player (i.e. 'in' the player)?
            (Some(obj_index), Some(obj_loc), _, _) if obj_loc == LOC_PLAYER => {
                (output, Some(obj_index))
            }
            //
            // Is this object at the same location as the player?
            (Some(obj_index), Some(obj_loc), _, Some(player_loc)) if obj_loc == player_loc => {
                (output, Some(obj_index))
            }
            //
            // Is this object any location?
            (Some(obj_index), obj_loc, _, _) if obj_loc == None => (output, Some(obj_index)),
            //
            // Is this object contained by any object held by the player
            (Some(obj_index), Some(_), Some(obj_container_loc), _)
                if obj_container_loc == LOC_PLAYER =>
            {
                (output, Some(obj_index))
            }
            //
            // Is this object contained by any object at the player's location?
            (Some(obj_index), Some(_), Some(obj_container_loc), Some(player_loc))
                if obj_container_loc == player_loc =>
            {
                (output, Some(obj_index))
            }
            //
            // If none of the above, then we don't know what the noun is.
            _ => {
                output = format!("You don't see any '{}' here.\n", noun);
                (output, None)
            }
        }
    }
}

Explanation
1 - get_visible() is defined as an associated function for the World struct.
4 - get_visible() takes a reference to self, a message (to be displayed if no matching object is found and a noun that the player entered in their command. get_visible() returns a tuple containing an output string and Option indicating the index of the visible object if any object is visible, or None if no object is visible.
5 - output - a String that holds any messages to be sent to the display.
7 - obj_index - an Option that holds the index of the object (i.e. the noun) in the objects Vec().
8 - obj_loc - an Option that holds the index of the location of the object (i.e. the noun) in the objects Vec(). Note the use of and_then(). and_then() evaluates an Option and if the Option is None, it returns None. If it is Some, it executes the closure and returns that result. We use and_then() to accommodate the fact that obj_index could be None.
9 - obj_container_loc - an Option that holds the index of the object that contains the object (i.e. the noun).
10 - obj_index - an Option that holds the index of the object in the objects Vec().
11 - player_loc - an Option that holds the index of the player's location.
14 - match on all four of the above Options.
15-19 - The 'not an object' case. If obj_index is None, then we didn't find an object, return a message string and None.
23-24 - If obj_index is the same as the index of the player object, we've found the player object. The object is visible, return a message string and Some(obj_index).
26-29 - If obj_index is the same as player_loc, our object is the location where the player is. The object is visible, return a message string and Some(obj_index).
31-34 - If obj_loc is the same as player's index, then the object is being held by our player. The object is visible, return a message string and Some(obj_index).
36-39 - If obj_loc is the same as player_loc, then the object is in the same location as the player. The object is visible, return a message string and Some(obj_index).
41-42 - If obj_loc is None, then the object isn't located anywhere. It must be a location and locations are always visible. Return a message string and Some(obj_index).
44-49 - If obj_container_loc is the same as player's index, then the player is holding an object that contains the object. The object is visible, return a message string and Some(obj_index).
51-56 - If obj_container_loc is the same as player_loc, then the object is in a container at the player's location. The object is visible, return a message string and Some(obj_index).
58-62 - Finally, if none of the above cases are true, then the object is NOT visible, return a message string and None.
60 - Note that the message here prints out the noun, and not the object description. This is to prevent the game from giving away the fact that a given object exists. Consider a scenario where the player makes a guess and types "get gold" without knowing that a gold coin exists in the game. If the game responds with "You don't see a gold coin here." it is giving away the fact that there is indeed a gold coin in the game. Replying with just the noun that the player used (i.e. "You don't see any gold here.") doesn't give away any more than the player's own guess.

What do we have here?

With the above code, the game can know if an object is visible when the player attempts to interact with it. But for the player to know what is available to interact with, the game needs a way to print out the list objects at a given location. Lets implement that next.
impl World {
    // ...

    pub fn list_objects_at_location(&self, location: usize) -> (String, i32) {
        let mut output = String::new();
        let mut count: i32 = 0;
        for (pos, object) in self.objects.iter().enumerate() {
            match (pos, object.location) {
                (pos, _) if pos == LOC_PLAYER => continue,
                (_, None) => continue,
                (_, Some(obj_location)) if obj_location == location => {
                    if count == 0 {
                        output = output + &format!("You see:\n");
                    }
                    count += 1;
                    output = output + &format!("{}\n", object.description);
                }
                _ => continue,
            }
        }
        (output, count)
    }
    
    // ...
} 

Explanation
7 - Loop through every object
8 - match on the location of each object so we can include in the list if we should.
9 - Exclude the player from the list. It is safe to assume they know that they're in the location.
10 - For objects with a location, include them if the location is this one.
12-14 - Include "You see:" with the first object so that the list is formatted nicely. 21 - Return the output message and the number of objects that are visible.

With the implementation of get_visible() and list_objects_at_location(), there is enough functionality to implement updated versions of do_look() and do_go() to accommodate the new data structure. Also, the Locations Vec() can be removed as well as the player_location variable that previously held the player's location. Now that information is all in the Objects Vec().
pub struct World {
    pub objects: Vec<Object>,
}

impl World {
    // ...
    
    pub fn do_look(&self, noun: &String) -> String {
        match noun.as_str() {
            "around" | "" => {
                let (list_string, _) =
                    self.list_objects_at_location(self.objects[LOC_PLAYER].location.unwrap());
                format!(
                    "{}\nYou are in {}.\n",
                    self.objects[self.objects[LOC_PLAYER].location.unwrap()].name,
                    self.objects[self.objects[LOC_PLAYER].location.unwrap()].description
                ) + list_string.as_str()
            }
            _ => format!("I don't understand what you want to see.\n"),
        }
    }

    pub fn do_go(&mut self, noun: &String) -> String {
        let (output_vis, obj_opt) = self.get_visible("where you want to go", noun);

        let player_loc = self.objects[LOC_PLAYER].location;
        match (obj_opt, player_loc) {
            (None, _) => output_vis,
            (Some(obj_loc), Some(player_loc)) if obj_loc == player_loc => {
                format!("Wherever you go, there you are.\n")
            }
            (Some(obj_loc), _) => {
                self.objects[LOC_PLAYER].location = Some(obj_loc);
                format!("OK.\n\n") + &self.do_look(&"around".to_string())
            }
        }
    }
}

Explanation
11-12 - Call list_objects_at_location() to show the list of objects at the location. Note the function call returns a tuple that contains the list for display and a count of objects. We don't care about the count yet.
13-17 - Call format!() macro to create an output string that includes the location and visible objects.
24 - Call get_visible() to check if the noun is visible.
26-36 - match on the returned obj_opt (an Option()) to determine the correct action
27 - The object isn't visible. Just return the string that get_visible() sent back.
28 - The noun is visible in some way the internal if statements decide what to do.
29-30 - Is the visible thing the player? Players can't enter themselves, just let them stay where they are.
31-33 - It isn't the player, so move the player there. Set the location of the player object to the current location.

Progress

With these changes, the player can interact with the game in a whole new way. Locations have objects that can be seen as the player moves around as shown below. BUT players can't really interact with the objects at all. They can't be picked up, moved, dropped, etc. For objects to have meaning in the game as more than just baubles, the player must be able to interact with them.

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 around
Bridge
You are in the bridge.
You see:
a photo of a family. They look familiar

> go galley
OK.

Galley
You are in the galley.

> go cryochamber
OK.

Cryochamber
You are in the cryochamber.
You see:
a silver suit that will protect you in cryosleep
your copilot sleeping in his cryochamber

> go cryosuit
OK.

Cryosuit
You are in a silver suit that will protect you in cryosleep.

> go bridge
OK.

Bridge
You are in the bridge.
You see:
a photo of a family. They look familiar

> quit
Quitting.
Thank you for playing!
Bye!

Once again, feel free to pull down the code and experiment. Add some objects. Do they add to your story?

⇂  View Source

Next post will be about allowing players to interact with objects, carry them around, and maybe even give things to other actor objects.

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