How to make a Text Adventure game in Rust - VII - Distance

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

7 - Distance

We've been adding features to our text adventure to enable the creation of puzzles in the game. Of course one aspect of a puzzle is the unknown. Working out the solution involves figuring out how to achieve some goal. But just as important as leaving the solution to be worked out by the player, providing appropriate feedback during the process is essential to a satisfying puzzle.

Consider the difference between the games Guess the Number between 1 and 100 and 20 questions. Guess the Number is a game of nearly pure luck, while 20 Questions is an interactive dialogue that allows your brain to work out the solution. Both of these games have an unknown element. The difference is in the feedback that the game provides. At best with Guess the Number you get 'too low' or 'too high,' which is very little to go on. In contrast, 20 Questions (often) starts with, 'Is it bigger than a breadbox?' and allows players to explore size, shape, color, weight, alive or dead, or any other aspect of the object to zero in on an answer. Feedback makes all the difference.

The difference between a 'just okay' text adventure game and a 'great' text adventure game is the ability of the game to not only convey a rich story, but to provide challenging puzzles with sufficient feedback through in game responses that players feel a sense of accomplishment when solving them. When a game becomes a guessing game of trying all <verb> <object> combinations it becomes more of a chore than fun.

The problem

The game already includes feedback to explain why certain actions are possible or not. Look, for example, at all of the various responses included in do_go() and describe_move() for example. These messages provide critical feedback to the player to allow them to know why their actions were or were not successful.

But as the game becomes more complex, adding these messages is becoming increasingly complex. This results in lots of complex branching and messages throughout the game code. We need a more foundational concept that will allow us to detect and respond to various situations with appropriate responses. Lets begin by looking at commands a bit more deeply.

Commands in the game operate on one or more objects. For example:
  • The player picks up an item.
  • The player gives an item to another player.
  • The player follows a passage to another location.
The first thing to check (after the obvious typos which are caught by the parser) is for the presence of the objects. Failure should result in something like "There is no <object> here." or "You don't see any <object>." In this post, we'll develop a generic function that can be used with every command to figure out if an object is within reach of the player.

However, 'within reach,' is a more complex topic that it might seem at first. The obvious cases are the object is here, or not here. But closer examination reveals that many commands will benefit from considering more range than just 'here' and 'not here'. Consider some examples:
  • To use a weapon or tool, the player must be holding it; its mere presence in the location is not enough.
  • To drop an item, the player must first be holding it; to pick up an item, the player must not be holding it.
  • Another actor holding an item may keep the player from getting that item. get item is not the same as ask item and the game should respond appropriately in both cases.

The solution - Distance

We can address these cases with various notions of 'here' as shown in the figure below.


A figure showing the meanings of distance from closer to farther away. Values shown include me, held, held contained, location, here, here contained, over there, and unknown object.


As shown in the figure, we can think of various notions of here as a kind of distance away from the player. Notice that the values seen in the image above and table below traverse from closest (Me) to farther away with unknown objects (UnknownObject) being represented as the farthest away.


DistanceDescriptionExample
MeThe object is the playerobject_idx == LOC_PLAYER;
HeldThe player is holding the objectobjects[object_idx].location == LOC_PLAYER;
HeldContainedThe player is holding another object (e.g. a bag) containing the objectobjects[object_idx].location.is_some() && objects[objects[object_idx].location].location == LOC_PLAYER;
LocationThe object is the player's locationobject_idx == objects[LOC_PLAYER].location;
HereThe object is present at the player's locationobjects[object_idx].location == objects[LOC_PLAYER].location;
HereContainedThe object is contained in another object (e.g. a bag) at the player's locationobjects[object_idx].location.is_some() && objects[objects[object_idx].location].location == objects[LOC_PLAYER].location;
OverThereThe object is a nearby locationget_passage_index(objects[LOC_PLAYER].location, object_index).is_some();
NotHereThe object is not (or appears to not be) here
UnknownObjectThe object is unknown to the parserobject_index.is_none();

While variations such as 'Me' (object is player) may seem trivial, they are important. For example the command 'examine self' should return a meaningful response. Indeed 'examine self' and its unique response may be a key part of some game puzzle. As an additional note, observe that while there are 7 variations of 'here', there is only one for 'not here.' This is because the game is concerned with the objects a player can see and interact with. If the item is not here, then there is little to say.

Lets build it! - The Distance enum

We can represent distance as an enum. Not only will this give us concrete type support for each of the distances, but Rust provides language support to ensure that the game code addresses all of the possible values where needed.

#[derive(PartialOrd, Ord, PartialEq, Eq, Debug)]
pub enum Distance {
    Me,
    Held,
    HeldContained,
    Location,
    Here,
    HereContained,
    OverThere,
    NotHere,
    UnknownObject,
}

Explanation
1 - Derive statements to create trait implementations for PartialOrd, Ord, PartialEq, and Debug. All but the last trait are included to enable ordinal (i.e. Me < Held etc.) comparisons, which we'll use in function implementations and branching test below.
3-11 - Definitions of the various elements of the Distance enum.

Checking Distance with is_holding() and get_distance()

In the table above, we showed in the leftmost column the various distance values of interest, and in the rightmost column a test we can use to check that distance. We can turn those tests into a function that returns the calculated 'distance' from an object (as seen from the player's point of view).

For the implementation below, we've also added a helper function is_holding() that checks to see if the location of one given object (i.e. object) equals the index of another (i.e. container), which indicates that the object is being held by the container. This function makes it easy to see if, for example, the location of an object is the same as the player object, which indicates that the object is Held. get_distance() uses is_holding() to simplify its implementation and reduce redundant code lines. Note also that get_distance() needs access to other methods so it is implemented as a function on World.

impl World {
    // --snip--

    pub fn is_holding(&self, container: Option<usize>, object: Option<usize>) -> bool {
        object.is_some() && (object.and_then(|a| self.objects[a].location) == container)
    }

    pub fn get_distance(&self, from: Option<usize>, to: Option<usize>) -> Distance {
        let from_loc = from.and_then(|a| self.objects[a].location);
        let to_loc = to.and_then(|a| self.objects[a].location);

        if to.is_none() {
            Distance::UnknownObject
        } else if to == from {
            Distance::Me
        } else if self.is_holding(from, to) {
            Distance::Held
        } else if self.is_holding(to, from) {
            Distance::Location
        } else if from_loc.is_some() && self.is_holding(from_loc, to) {
            Distance::Here
        } else if self.is_holding(from, to_loc) {
            Distance::HeldContained
        } else if self.is_holding(from_loc, to_loc) {
            Distance::HereContained
        } else if self.get_passage_index(from_loc, to).is_some() {
            Distance::OverThere
        } else {
            Distance::NotHere
        }
    }
    
    // --snip--
}

Explanation
4-6 - is_holding() function. Checks to see if one object ('container') directly contains another object ('object'). Takes indexes to a container object and another object. If the second object's location is the container then return True.
8-31 - get_distance() function. Given a 'from' object and a 'to' object, returns the distance relationship between them.

Updating the helper functions actor_here() and list_objects_at_location()

Now that we have is_holding() and get_distance() we can begin updating the various functions in the game code to take advantage of it. The first two functions we'll look at are places where the game needs to check to see if the player is holding the object of interest. In the case of actor_here() we want to check if the Copilot actor is at the player's current location. More specifically we want to check if the current location is holding the Copilot? Similarly, with list_objects_at_location(), we iterate throught the list of objects and check to see if the location is holding the object.

Note that the actual changes to these functions are minimal. In both cases the actual call to is_holding() might even be more verbose than the test that existed previously. Then why make the change? Well, as developers, our goal not only to make functional code, but also (and perhaps more importantly for ourselves) is to make our intention clear to others (including our future selves) who may come behind us and read the code. is_holding() is more clear than obj_loc == self.objects[LOC_PLAYER].location. In the end they accomplish the same goal, but is_holding() is exactly clear about what we're checking for.

impl World {
    // --snip--

    pub fn actor_here(&self) -> Option<usize> {
        let mut actor_loc: Option<usize> = None;

        for (pos, _) in self.objects.iter().enumerate() {
            if self.is_holding(self.objects[LOC_PLAYER].location, Some(pos)) && pos == LOC_COPILOT {
                actor_loc = Some(pos);
            }
        }
        actor_loc
    }

    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() {
            if pos != LOC_PLAYER && self.is_holding(Some(location), Some(pos)) {
                if count == 0 {
                    output += "You see:\n";
                }
                count += 1;
                output = output + &format!("{}\n", object.description);
            }
        }
        (output, count)
    }

    // --snip--
}

Explanation
4-13 - Updated actor_here() function. Note that this implementation is substantially refactored from the implementation in step 6. The refactoring eliminates a match statement and highlights that the function is a simple test on each object.
8 - Updated check in actor_here() that confirms that the location is holding the object of interest.
15-28 - Similar to actor_here() the implementation of list_objects_at_location() is substantially refactored to support ease of reading and comprehension.
19 - Updated check in list_objects_at_location() that confirms that the location is holding the object of interest.

Going the distance with do_go()

Next up is do_go(). We'll modify do_go() to use the new distance enum. The updated version is (in my view) much easier to follow because the match cases now clearly show our intent. Previously we had match cases like this (Some(obj_idx), None, _, _, Some(_)) => and it was left as an exercise to ther reader to figure out that the collection of terms meant to a new location. With the update, the match arm is Distance::OverThere =>. This is much simpler and it is much more clear what the intent of the match arm is.

This change is but one step among many that we'll take to improve readability of the game code. We'll continue to make more adjustments as we proceed (and as I, dear reader, learn more Rust).

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);

        match self.get_distance(Some(LOC_PLAYER), obj_opt) {
            Distance::OverThere => {
                self.objects[LOC_PLAYER].location = obj_opt;
                "OK.\n\n".to_string() + &self.do_look("around")
            }
            Distance::NotHere => {
                format!("You don't see any {} here.\n", noun)
            }
            Distance::UnknownObject => output_vis,
            _ => {
                let obj_dst = obj_opt.and_then(|a| self.objects[a].destination);
                if obj_dst.is_some() {
                    self.objects[LOC_PLAYER].location = obj_dst;
                    "OK.\n\n".to_string() + &self.do_look("around")
                } else {
                    "You can't get much closer than this.\n".to_string()
                }
            }
        }
    }

    // --snip--
}

Explanation
4-26 - Updated do_go() function. Note again that this implementation is refactored from the implementation in step 6. The refactoring primarily simplifies the match arms, but in the process allows us to eliminate some of the internal variables as well.
8,12,&15 - Updated arms that use the Distance enum for matching.

"Now I get it!" with do_get()

After do_go() is do_get(). Just as with do_go(), we've modified do_get() to use the new Distance enum. As before, we've refactored the entire function to use the Distance enum, with, hopefully, an easier to follow flow.

impl World {
    // --snip--

    pub fn do_get(&mut self, noun: &str) -> String {
        let (output_vis, obj_opt) = self.get_visible("what you want to get", noun);

        let player_to_obj = self.get_distance(Some(LOC_PLAYER), obj_opt);

        match (player_to_obj, obj_opt) {
            (Distance::Me, _) => output_vis + "You should not be doing that to yourself.\n",
            (Distance::Held, Some(object_idx)) => {
                output_vis
                    + &format!(
                        "You already have {}.\n",
                        self.objects[object_idx].description
                    )
            }
            (Distance::OverThere, _) => output_vis + "Too far away, move closer please.\n",
            (Distance::UnknownObject, _) => output_vis,
            _ => {
                let obj_loc = obj_opt.and_then(|a| self.objects[a].location);

                if obj_loc == Some(LOC_COPILOT) {
                    output_vis
                        + &format!(
                            "You should ask {} nicely.\n",
                            self.objects[LOC_COPILOT].name
                        )
                } else {
                    self.move_object(obj_opt, Some(LOC_PLAYER))
                }
            }
        }
    }

    // --snip--
}

Explanation
4-34 - Updated do_get() function. Note again that this implementation is refactored from the implementation in step 6. As with do_go(), the refactoring primarily simplifies the match arms.
10,11,17&19 - Updated arms that use the Distance enum for matching.

Going above and beyond - Adding some sugar to get_object_index(), get_visible() and get_possession()

The last thing we'll do in this post is modify get_object_index() so that it can find specific objects (i.e. nouns), but only if those objects are within a defined distance. Previously, get_object_index() would return an object wherever it was found. With this new addition, get_object_index() is a much more versitile, allowing the game to filter down to just search the set of objects near at hand or those anywhere in the game. This enhancement won't do a lot for us here, but in the next post we'll be modifying the game to allow objects to have the same label. Being able to select just the objects the player can see is an essential component of that solution. We'll show the change to get_object_index() first and then the changes to the calls to get_object_index() in the rest of the game.

impl World {
    // --snip--

    fn get_object_index(
        &self,
        noun: &str,
        from: Option<usize>,
        max_distance: Distance,
    ) -> Option<usize> {
        let mut result: Option<usize> = None;
        for (pos, object) in self.objects.iter().enumerate() {
            if self.object_has_name(object, noun)
                && self.get_distance(from, Some(pos)) <= max_distance
            {
                result = Some(pos);
                break;
            }
        }
        result
    }

    fn get_visible(&self, message: &tr, noun: &str) -> (String, Option<usize>) {
        let obj_over_there = self.get_object_index(noun, Some(LOC_PLAYER), Distance::OverThere);
        let obj_not_here = self.get_object_index(noun, Some(LOC_PLAYER), Distance::NotHere);

        match (obj_over_there, obj_not_here) {
            (None, None) => (format!("I don't understand {}.\n", message), None),
            (None, Some(_)) => (format!("You don't see any '{}' here.\n", noun), None),
            _ => (String::new(), obj_over_there),
        }
    }

    pub fn get_possession(
        &mut self,
        from: Option<usize>,
        command: Command,
        noun: &str,
    ) -> (String, Option<usize>) {
        let object_held = self.get_object_index(noun, from, Distance::HeldContained);
        let object_not_here = self.get_object_index(noun, from, Distance::NotHere);

        match (from, object_held, object_not_here) {
            (None, _, _) => (
                format!("I don't understand what you want to {}.\n", command),
                None,
            ),
            (Some(_), None, None) => (
                format!("I don't understand what you want to {}.\n", command),
                None,
            ),
            (Some(from_idx), None, Some(_)) if from_idx == LOC_PLAYER => {
                (format!("You are not holding any {}.\n", noun), None)
            }
            (Some(from_idx), None, Some(_)) => (
                format!(
                    "There appears to be no {} you can get from {}.\n",
                    noun, self.objects[from_idx].name
                ),
                None,
            ),
            (Some(from_idx), Some(object_held_idx), _) if object_held_idx == from_idx => (
                format!(
                    "You should not be doing that to {}.\n",
                    self.objects[object_held_idx].name
                ),
                None,
            ),
            _ => ("".to_string(), object_held),
        }
    }

    // --snip--
}

Explanation
4-20 - Updated implementation of get_object_index(). Includes as added parameters the source object (from) and a maximum distance from that object that the noun should be considered accessible.
12-13 - The actual change to get_object_index() is quite simple, the visibility check here is adjusted to include a call to get_distance() that limits the maximum acceptable distance the object can be from the source object.
22-31 - Updated implementation of get_visible(). get_visible() is dramatically simplified with the enhancements to get_object_index(). All of the previous special case arms simplify down to just one, leaving a total of 3 arms.
23 - Call to get_object_index() with a distance of OverThere. Means everything outside of the player's visible range is ignored.
24 - Call to get_object_index() with a distance of NotHere. Means all known objects will be checked.
33-70 - Updated implementatin of get_possession(). Largely similar to the prior implementation, however with modified calls to get_object_index() and a slight re-ordering of the match arms.
39 - Call to get_object_index() with a distance of HeldContained. Means only objects held by the player (and the player themselves).
40 - Call to get_object_index() with a distance of NotHere. Means all known objects will be checked.

Progress

All that work for the game to play just like it did before. Kind of makes you wonder why? Well, you as you can see there were a number of simplifications in the game code, which enhanced understandability. Additionally, the game is now much more able to understand differences in object locations, which sets us up for better output messages. The benefits here are largely internal, but are undenyable from the perspective of solidifying the foundation for further building. In the next post, we'll look at how distance can be used to assist on the input side as well. We'll also add generic directions to the game so the user can move more naturally.

⇂  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