How to make a Text Adventure game in Rust - VI - Passages

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

6 - Passages

In the Objects post we started off by considering that great text adventure games are enabled by great puzzles. Some of those puzzles are related to the items and actors in the game, but another way to add puzzles to a game is with the locations and passages between them. It is no mistake that one of the most famous lines from one of the most famous text adventure games is 'you are in a maze of twisty passages all alike.' Colossal Cave Adventure used the very locations of the world to twist and turn the player around making a puzzle from the locations in the game. In this post we'll explore how to make our own twisty little passages for players to move around in.

Up to this point, the game has allowed players to move from any location to any other location without restriction. But just like in the real world, locations in a text adventure game are connected by passages. And just like in the real world, players should only move from location to location by using those connecting passages.

We can think of a passage as anything that connects two locations. Passages could be a hallway, a trail through a forest, or even a door. Passages are simply ways for the player to move from location to location. Passages have the following properties that we need to represent:
  • A starting point (location),
  • An ending point (location),
  • A narrative description, for example "a forest path," and
  • A name by which the player may refer to the passage.
Just as with items and actors, we can see that the Object struct looks very much like what the game would need to represent a passage. Indeed to allow the Object struct to store passages we would need to add only one field - the destination of the passage.

Once again, by taking the approach of treating passages as objects, we'll be able to re-use functions that process objects just as with locations. As one example, the game will treat passages as items that create a kind of 'visible' object that just happens to be an exit. So functions like list_objects_at_location() will display passages like other items, other functions such as do_go() will treat passages as a different kind of item that moves a player from one location to another.

Updating the Object struct

Consider the locations we showed in the Objects post. We can add passages between them as shown in the drawing here.

An image showing three locations: The bridge, the galley, and the cryochamber. The locations are connected with passages aft from the bridge to the galley; port from the galley to the cryochamber, starboard from the cryochamber to the galley, and forward from the galley to the bridge. Also depicted in the image is a photo in the bridge.

The figure shows added passages between the locations and names them. Notice also that we shifted the locations so that no two passages have the same name. The game doesn't know how to handle that yet. (We'll fix that in post 8 and move the locations back to where they should be.) Notice also that the passage names and directions are those that might be expected on an actual ship (i.e. Aft, Forward, Port and Starboard).

Like earlier, we'll begin with modifying the Object struct and then propagate the necessary changes through the command and helper functions in the game code.
pub struct Object {
    pub name: String,
    pub description: String,
    pub location: Option<usize>,
    pub destination: Option<usize>,
}

Explanation
5 - destination: the index into the objects vec() of the object's current location.

Note that passages only run in one direction, from the location to the destination. If we want passages to go in both directions, we'll need to add two passages, one for each direction.

Adding Passages

With the Object struct updated, we can add passages to the game. From the diagram above, we can see that we'll need to add 4 passages, forward and aft between the galley and the bridge, and port and starboard between the galley and the cryochamber. The code below shows those additions.
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;
const AFT_TO_GALLEY: usize = 8;
const FWD_TO_BRIDGE: usize = 9;
const PORT_TO_CRYOCHAMBER: usize = 10;
const STBD_TO_GALLEY: usize = 11;

impl World {
    pub fn new() -> Self {
        World {
            objects: vec![
    
                // --snip--
                
                Object {
                    name: "Pen".to_string(),
                    description: "a pen".to_string(),
                    location: Some(LOC_COPILOT),
                    destination: None,
                },
                Object {
                    name: "Aft".to_string(),
                    description: "a passage aft to the galley".to_string(),
                    location: Some(LOC_BRIDGE),
                    destination: Some(LOC_GALLEY),
                },
                Object {
                    name: "Forward".to_string(),
                    description: "a passage forward to the bridge".to_string(),
                    location: Some(LOC_GALLEY),
                    destination: Some(LOC_BRIDGE),
                },
                Object {
                    name: "Port".to_string(),
                    description: "a passage port to the cryochamber".to_string(),
                    location: Some(LOC_GALLEY),
                    destination: Some(LOC_CRYOCHAMBER),
                },
                Object {
                    name: "Starboard".to_string(),
                    description: "a passage starboard to the galley".to_string(),
                    location: Some(LOC_CRYOCHAMBER),
                    destination: Some(LOC_GALLEY),
                },
            ],
        }
    }
    
    // --snip--
}

Explanation
9-12 - Added consts that contain indexes into the objects vec(). These make referring to the passages easier when constructing Objects for the objects vec().
21-26 - An example of one of the existing Objects that shows the newly added destination field. For all non-passage objects, destination is set to None.
27-50 - New objects that represent the passages between locations in the game. Note that the location and the destination fields have values that indicate the starting and ending points of the passage.

Finding Passages with get_passage()

The next thing we need to add is a function to determine if there is a passage between two locations. We can create a helper function get_passage_index() that iterates over the objects vec() and checks each Object to see if a matching passage exists between the locations.
impl World {

    // --snip--
    
    fn get_passage_index(&self, from: Option<usize>, to: Option<usize>) -> Option<usize> {
        let mut result: Option<usize> = None;

        match (from, to) {
            (Some(from), Some(to)) => {
                for (pos, object) in self.objects.iter().enumerate() {
                    let obj_loc = object.location;
                    let obj_dest = object.destination;
                    match (obj_loc, obj_dest) {
                        (Some(location), Some(destination))
                            if location == from && destination == to =>
                        {
                            result = Some(pos);
                            break;
                        }
                        _ => continue,
                    }
                    result
                }
            }
            _ => result,
        }
    }

    // --snip--  
}

Explanation
8-26 - Outer match, checks to ensure that both from and to are not None.
10-23 - Iterate over the objects vec() and check to see if any Object is a passage matching the from and to.
17-18 - If a matching passage is found, return the passage index value as Some.
22 - Return the found passage as Some (from 17), or None (from 6).

Modify do_go()

We can use the newly created get_passage_index() in do_go() to move the player from location to location. Note that we've expanded do_go() quite a bit to only allow the cases where the command is go <location> or go <passage> where either the location or passage is visible.
impl World {
    
    // --snip--
    
    pub fn do_go(&mut self, noun: &str) -> String {
        let (output_vis, obj_opt) = self.get_visible("where you want to go", noun);

        let obj_loc = obj_opt.and_then(|a| self.objects[a].location);
        let obj_dst = obj_opt.and_then(|a| self.objects[a].destination);
        let player_loc = self.objects[LOC_PLAYER].location;
        let passage = self.get_passage_index(player_loc, obj_opt);

        match (obj_opt, obj_loc, obj_dst, player_loc, passage) {
            // Is noun an object at all?
            (None, _, _, _, _) => output_vis,
            // Is noun a location and is there a passage to it?
            (Some(obj_idx), None, _, _, Some(_)) => {
                self.objects[LOC_PLAYER].location = Some(obj_idx);
                "OK.\n\n".to_string() + &self.do_look("around")
            }
            // Noun isn't a location. Is noun at a different location than the player?
            // (i.e. Object has Some location)
            (Some(_), Some(obj_loc_idx), _, Some(player_loc), None)
                if obj_loc_idx != player_loc =>
            {
                format!("You don't see any {} here.\n", noun)
            }
            // Noun isn't a location. Is it a passage?
            // (i.e. Object has Some location and Some destination)
            (Some(_), Some(_), Some(obj_dst_idx), Some(_), None) => {
                self.objects[LOC_PLAYER].location = Some(obj_dst_idx);
                "OK.\n\n".to_string() + &self.do_look("around")
            }
            // Noun might be a location or an object at the location, but there isn't a destination so it isn't a path,
            // then Noun must be the player's current or something at the location
            (Some(_), _, None, Some(_), None) => {
                "You can't get much closer than this.\n".to_string()
            }
            // Else, just return the string from get_visible
            _ => output_vis,
        }
    }
}

Explanation
11 - The call to get_passage_index(). Will return None if no passage exists, or Some if one does.
13 - Match on a tuple that includes all of the elements we need to select on - the object, the location, the destination, the player location, and the passage.
15 - Is noun an Object at all? (If not, then obj_opt will be None). Just return the string passed back from get_visible().
17-20 - Is the noun a location (i.e. obj_opt is Some and obj_loc is None), and is there a passage to it (i.e. passage is Some)? If yes, then we can move the player there.
23-27 - If the noun is an item or passage (i.e. obj_opt and obj_loc are Some) but it is not in the same location as the player (i.e. obj_loc != player_loc), then the object is not here. Print a message letting the player know that and return.
30-33 - Is the noun a passage (i.e. obj_opt, obj_loc, and obj_dst are Some)? If yes, we know it is in the same location as the player or the previous arm would match. If yes, then move the player to the passage destination.
36-38 - By this arm, we know the noun is either a location or an object. It can't be a passage because obj_dst is None, but also because if it were a passage then the previous arm would match. The only option left is that the noun is the current location or an object at the current location. Just print a message and return.
40 - Rust forces matches to be comprehensive. There are combinations of match values that don't make sense with our values (i.e. an Object with no location but having a destination). These cases cannot exist in the game. Here we just print the message from get_visible() and return.

Modify get_visible()

The last modification we must make is to get_visible(). Previously get_visible() allowed all locations to be visible from all other locations. The updated version only allows locations to be visible if they are connected to the current location by a passage.
impl World {

    // --snip--
    
    fn get_visible(&self, message: &str, noun: &str) -> (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;
        let passage = self.get_passage_index(player_loc, obj_index);

        match (obj_index, obj_loc, obj_container_loc, player_loc, passage) {
            // Noun isn't an object
            (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 the player is in?
            (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))
            }
            //
            // If this object is a location (i.e. it has Some obj_loc,
            // we only care if there is a passage to it)
            (Some(obj_index), None, _, _, Some(_)) => (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)
            }
        }
    }
    
    // --snip--
}

Explanation
14 - The added call to get_passage_index(). Will return None if no passage exists, or Some if one does.
16 - match on a tuple that includes all of the elements we need to select on - the object, the location, the container (of the object), the player location, and the passage.
18-65 - For all match arms, we've added a fifth element that is the passage. Most arms do not care about this element and remain unchanged from the prior implementation.
18-21 - Is noun an Object at all? (If not, then obj_index will be None). Return an error indicating that the object is unknown.
45 - For locations (i.e. obj_index is Some, but obj_loc is None), then only return the object as visible if there is a passage to the location (i.e. passage is Some).

Progress

Adding passages changes the character of the game experience for the player. Just as in real life, the world is no longer a set of disconnected places, but rather a map of connected spaces. As a player, interacting with a world connected by passages provides a much more real experience because it more closely mirrors the world as you know it. The map shown here is still very simple and has only four passages connecting the three locations, but the technique here can be applied to much larger maps and spaces.

⇂  View Source

Next post we'll explore the concept of distance in a text adventure game. We'll consider distance as not just the distance between locations, but also the difference in the proximity of objects. This concept should help us to clean up the game code and simplify some of the complicated match statements that we've been writing.

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