How to make a Text Adventure game in Rust - II - The Main Loop

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

2 - The Main Loop

The beating heart of a video game is the main loop. The loop is exactly what it sounds like - a simple loop that is started when the game program starts and then continues to run for as long as the game is running. The loop ends when the game exits and the program shuts down. The main loop drives the game and gives it life.

Internally, the main loop always performs the same 3 steps:
  1. Check for player input,
  2. Update the game state based on the input, and
  3. Refresh the screen based on the game state.
This loop repeats continuously. In most cases as frequently 60 times or more per second. The main loop is what allows the game to move characters, play sounds, and update animations even if the player isn't doing anything.

Text adventure games follow a modified version of this same pattern. The difference is that the game will pause while waiting for input. The makes a text adventure game more like a series of turns than most video games.

In this post, we'll write a simplified version of each of these steps for the game. When we're done, you'll have a playable game that you can interact with.

Lets look at the steps individually and then dive into some code.

Collect input

Players interact with a text adventure by entering a series of instructions to the game. For example, a player might say 'go north,' or 'eat bread' as input and the game will respond by implementing those actions. Each entry is given as a command or simple sentence.

Processing the input is a hard problem because the player can type anything as a command and the game must respond reasonably. This step is called parsing. Parsing can be a quite complex topic for more than the simplest entries. To keep things simple to start, we can begin with a simple two word parser. This parser will assume that all entries are in the form '<verb> <noun>.' Later, we will spend more time to add to the parser and handle more complex commands.

Update the game state

The game state is a representation of everything about the game. Game state will have obvious things like score and player location, but other values might also be the location of other entities in the game and the state of the game 'world.' All of this information is typical stored in a single or series of shared data structures that collectively represent the state. In a text adventure game, the game state will include a list of locations, connections between the locations, objects in the game, player inventory, and conditions.

As the game parses each new player input, the game responds by updating the game state based on the input. So the command 'go north' would check to see if north is a direction that the player can travel and if so update the player's location in the game state. Similarly, 'eat bread,' would check to see if the player has bread in inventory and if so, to remove the bread (since it has been eaten) and perhaps update player health.

After updating, the game will be in a new state and ready for the next stage.

Refresh the screen

The last stage is to update the display. In action games, refreshing the display might first clear the whole screen and then redraw every game element in their new locations. This process can get very complex and involve an entire graphics pipeline that draws using a specialized library like OpenGL or Vulkan and a graphics card. In a text adventure game we'll mostly be writing text back the screen to let the player know the results of their action.

Writing the Main Loop

Because we know that our parser and screen updates will become more complicated over time, we'll split the parsing and screen update functions out to a separate module and refer to them in the main function. That way as the module grows we won't clutter up the main project directory and we'll be able to build and expand the module without needing to recompile the whole project.

Here is the updated main.rs
pub mod rlib;

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

    let mut command = rlib::Command::new();
    let mut output: String;

    //
    // Main Loop
    //
    while command.verb != "quit" {
        command = rlib::get_input();
        output = rlib::update_state(&command);
        rlib::update_screen(output);
    }

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

Explanation
1 - Import the Rentry library module rlib.
3-11 - This is the first steps code.
13 - Create a Command struct to hold user entered commands. We'll describe this in more detail below.
14 - Create a String to hold output for the screen
19 - 23 - The main loop. Continue to loop until the user enters 'quit'
20 - Main loop step 1 - Check for input. This call blocks while waiting for input. Returns a Command struct.
21 - Main loop step 2 - Update the game state with the Command value. Returns a String with output lines.
22 - Main loop step 3 - Send the output to the screen for display.
28 - Print 'Bye!' and shutdown the game.

The rlib Module

In the main function, above, we called out to a different module 'rlib.' rlib (the Reentry library module) provides several functions and the Command struct. Lets look at the rlib module and those parts now to see what is happening with them. We'll examine them in sections. First up is the Command struct.

Command holds the action that is parsed from the user entered string. The code shows the struct and the two implemented methods on the struct.
pub struct Command {
    pub verb: String,
    pub noun: String,
}

impl Command {
    pub fn new() -> Command {
        Command {
            verb: String::new(),
            noun: String::new(),
        }
    }

    fn parse(&mut self, input_str: &str) {
        let mut split_input_iter = input_str.trim().split_whitespace();

        self.verb = split_input_iter.next().unwrap_or_default().to_string();
        self.noun = split_input_iter.next().unwrap_or_default().to_string();
    }
}
Explanation
1-4 - The Command struct itself. It contains verb, and noun, two variables of String type.
7-12 - The new() function for Command. The declaration is made public so we can create a Command in the main function. Implmentation is simple and just creates two Strings to populate the struct fields verb and nown.
14 - Declaration for the parse() function. It takes as input a reference to self and a reference to input_str as a &str type.
15 - Trim leading and trailing whitespace and then split the input_str on white space. Creates an iterator on the split values.
17 & 18 - Take values from the iterator and place them into verb and noun. Note that we have to call unwrap_or_default() to accommodate the Option returned by the iterator. These lines need to function if no value is returned from the iterator. Note also that we only take the first two words in the input string.

Command lays the foundation for much more advanced parsing that may come later in our game. Having input and parsing as part of the implementation means that we can expand parsing as we need to and we'll still have an easy way to move the parsed values around as a single unit. For example, we might want to switch verb from a simple string to an enum that holds the parsed commands. This would let us deal more easily with command shortcuts like 'n' when the player means to 'go north.' In another example, we might change Command so that it contains an array of actions that the game would take. This would allow the game to 'inject' actions that the player might trigger, but that they did not initiate directly.

With Command defined we can look at get_input() to see how we get new commands from the player.
use std::io::{self, Write};

pub fn get_input() -> Command {
    // Prompt
    println!("");
    print!("> ");
    io::stdout().flush().unwrap();

    let mut input_str = String::new();

    io::stdin()
        .read_line(&mut input_str)
        .expect("Failed to read move");
    println!("");
    
    // Parse
    let mut command = Command::new();
    command.parse(input_str.as_str());

    // Return
    command
}
Explanation
1 - Bring in std::io and std::io::Write to support IO operations and the call to flush().
3 - The get_input() function. Returns a Command struct with a parsed command.
4 - 15 - Prompt for input and wait for a command.
5 & 6 - Display the prompt.
7 - Flush the print buffer so that the prompt is displayed. Note the use of unwrap() here. We have to add this here or Rust won't compile. flush() returns a Result and if we do not unwrap() then Rust sees it as an unhandled Result case and throws an error. This is also a bit of a red flag since we're using unwrap(). unwrap() in Rust is often a sign that we're ignoring possible error cases, which can come back to bite us later. We're using unwrap() here to move quickly, but later we'll probably want to come back and add proper handling for this (and other) unwrap() calls that appear.
9 - A String variable to hold the user entered string.
11-13 - Read player input. Wait forever until the player provides an input. expect() is another case where we're glossing over the possibly unhandled error conditions.
14 - Insert a blank line to provide some standoff for any output that the game might display.
16-18 - Parse the input string
17 - create a Command object to hold the parsed command.
18 - Actually perform the parsing. This uses the parse() function defined on the Command struct that we saw earlier.
20 - Return the command.

And to complete the review, lets look at update_state() and update_screen():
pub fn update_state(command: &Command) -> String {
    let output: String;

    match command.verb.as_str() {
        "quit" => output = format!("Quitting.\nThank you for playing!"),
        "look" => output = format!("It is very dark, you can see nothing but the flashing light."),
        "go" => output = format!("It is too dark to move."),
        _ => output = format!("I don't know how to '{}'."),
    }

    // Return
    output
}

pub fn update_screen(output: String) {
    println!("{}", output);
}
Explanation
1 - Defines the update_state() function. update_state() takes one argument of type Command, and returns a String.
2 - Declares output will be a String when initialized
4 - 9 - Match on verb string to determine action. For each command, create a String and assign it to output.
12 - Return output String
15 - Define the update_screen() function, which takes as input a String.
16 - Print the input string to the screen.

Progress

With these changes, we've added the first interactivity into our game. Even these simple additions have created a full, albeit simple, game. Running the program with cargo run shows the kind of interactions the game supports already.
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

It is very dark, you can see nothing but the flashing light.

> go north

It is too dark to move.

> examine light

I don't know how to do that.

> quit

Quitting.
Thank you for playing!
Bye!

Finding Fun with Iteration

This post shows another important aspect of making games. Notice that we started in last post with just about the simplest 'game' we could possibly make. In this post, we've added to our game, but just a little bit. We were sure to have a functional game at the end though. This change, however small enhanced the experience and let us stop and look at the progress we've made. This style of development is progressive iteration. Iteration in game making is a nearly essential part of the process. It keeps changes small and provides lots of opportunities for review and feedback. 'Fun' is a subtle quality that is as much discovered in a game as it is deliberately created. Simple iterations is one of the ways that great games emerge from the development process. We'll continue to follow this iterative process as we add layers to the game. Each new layer will provide added depth to the game.

Closing

We've made a lot of progress, but there is much more to add. Currently the player is limited to just one location and prevents the player from actually moving by saying it is too dark. In the next post, we'll add locations. Future posts will add inventory, conditions, score and much more.

⇂  View Source

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

Comments

  1. You may have done this already, but I think the more idiomatic way to write the initialization of output in update_state would be to set output equal to the result of the match expression.

    ReplyDelete
    Replies
    1. This is a great point and one that gets fixed in the third post (https://www.riskpeep.com/2022/08/make-text-adventure-game-rust-3.html). I'm still very much learning Rust though, so I'm sure there are many similar mistakes. I appreciate the feedback.

      Delete
  2. Hi, Rob! Thank you so much! I found a typo: "All of this information is typical stored in a single or series of shared data structures". Typical = typically

    ReplyDelete

Post a Comment

Popular posts from this blog

How to make a Text Adventure game in Rust - Introduction