I’m attempting to write a (very simple, at least initially) text adventure in Rust, so I’m going to share progress on my blog starting today. This first post is about the sections, that to day is the rooms of a house for example.

Disclaimer: I’m no expert of adventure games (except maybe as a player), and I’m learning Rust. These writings are not meant as a tutorial, they rather are my thoughts, ideas and work.

The repository with the full code is available on GitHub. The name of the game is The Undergarden. The story is still to write, at present time there are only two sections (or rooms) just to show the player can walk from one to another.

The engine is into the unend module, which is bundled with the game but may become a separate crate if it ever gets mature enough and/or if I’ll want to use it with other games. The engine defines a trait for sections:

pub trait Visitable {
    fn get_tag(&self) -> String;
    fn get_name(&self) -> String;
    fn get_dsc(&self) -> String;
    fn exit(&self, _dir: &ExitDir) -> Exit;
}

So, a section can be any struct the programmer wants, as long as it correctly implements the Visitable trait, so that it becomes a place the player can… well… visit.

Besides the obvious getters, there is an exit() method which must define what happens when the player exits from the room in a certain direction. At present time (but it’s going to be expanded) direction can be one of the four cardinal points (defined in a separate enun). The result (method return value) when the player tries to exit can be one of the following:

pub enum Exit {
    Visitable(String),  // The tag of any other struct implmenting the `Visitable` trait
    Closed(String),     // A string to be shown to the player to explain why the exit is closed
    None,               // No string needed, we display a default here
}

Since an exit can lead to any section, we provide out-of-the-box support for things such as rooms in which you can enter but then you can’t exit; or exits which lead to the same room being left; or more than one exit leading to the same room; or even portals (connections between distant rooms, easy because there is no notion of proximity in the data).

The engine also provides a BasicSection struct, which implements Visitable and is okay to boostrap with section creation:

impl BasicSection {
    pub fn new(
        i_tag: String,
        i_name: String,
        i_dsc: String,
        i_exits: HashMap<String, Exit>,
    ) -> Self {
        BasicSection {
            tag: i_tag,
            name: i_name,
            dsc: i_dsc,
            exits: i_exits,
        }
    }
}

impl Visitable for BasicSection {
    fn get_tag(&self) -> String {
        self.tag.clone()
    }
    fn get_name(&self) -> String {
        self.name.clone()
    }
    fn get_dsc(&self) -> String {
        self.dsc.clone()
    }

    fn exit(&self, dir: &ExitDir) -> Exit {
        match self.exits.get(dir) {
            Some(ex) => ex.clone(),
            None => Exit::None,
        }
    }
}

new() takes tag, name and description of the section. The tag will be used to look the section up when needed and will be stored in other sections which have exits to this one. We won’t directly store a borrow to a section struct in another one, as this would likely create crossed borrows which would be a nightmare to manage. The last parameter is a HashMap of Exits. exit() implementation checks that an exit exists in the direction passed as parameter and either returns it or no exit.

So, let’s use this code to create a room. Why not begin with the kitchen:

let kitchen = UnenedSection::Basic(BasicSection::new(
    s!("kitchen"),
    s!("The grand kitchen"),
    s!("You are at the center of the kitchen and dining room. The only exit is south."),
    hashmap!{
        ExitDir::South => Exit::Visitable(s!("hallway")),
        ExitDir::North => Exit::Closed(s!("You can't exit through the window.")),
    },
));

The maplit crate is being used here, in order to provide less verbose HashMap construction. A custom s!() macro is also used, because there are really a lot of strings to create when creating the data of a text adventure, and I wanted the code to still be readable: the macro resolves to String::from().

Only the exits which actually exist or are closed need to be defined: non existing ones will be handled by the exit() implementation we provided in BasicSection for Visitable.

It’s worth noting that the BasicSection construction is wrapped inside an enum: UnendSection. This is need in order to allow different types of Visitables to be values of the same HashMap (even though there is only one at the moment).

We are now ready to initialize a nice game with two sections:

let sections = hashmap!{
    s!("hallway") => hallway,
    s!("kitchen") => kitchen,
}

let game = Game::new(sections, "hallway"); // We begin the game in the hallway

game.run();

Et voilà, it works!



It’s actually a bit more complicated than this, as Game doesn’t provide I/O, so that must be implemented in a trait: the ConsoleIO trait defined inside unend provides I/O from the text console and is in this case implemented: if we are not in the console but - say - in a web browser (compiling the game to a wasm target is one of my secret goals), we should write a specific trait.

Since this first post is about sections, we won’t dig into the implementation of Game to much. At the moment it’s pretty straightforward anyway, since there are no objects or people to interact with, and it’s basically a loop which waits for user input. I’m just showing the code which processes the user commands (only directions + quit):

match command.as_str() {
    dir if (dir == "n" || dir == "s" || dir == "w" || dir == "e") => {
        match current_section.exit(dir) {
            Exit::Visitable(s) => {
                current_section_tag = s;
            }
            Exit::Closed(s) => {
                self.write_line(&s);
            }
            Exit::None => {
                self.write_line("No exit this way.");
            }
        };
    }
    "q" => {
        self.write_line("See you!");
        process::exit(0);
    }
    _ => {
        self.write_line("Invalid command.");
    }
};

If command is a cardinal point, it calls Visitables exit() method implemented for BasicSection: if it returns the tag of another Visitable, it updates the current section tag (so we can load the section at the next loop iteration); if the exit is closed, it display the explanatory string; if there is no exit, it displays a default message. The other branches handle quitting the game and invalid commands.

That’s it for now. Hopefully, next time we’ll try to interact with objects.