Project · after Level 2

Text Adventure 🗺️

Rusty lost the golden claw somewhere along the shore, and you are going to build the world it is hiding in. Rooms, exits, items, an inventory, and a parser that understands you: a whole game, no graphics required.

📋 The spec

Build a game that plays like this:

🦀 THE LOST CLAW: a tiny adventure

The Beach
Warm sand, gentle waves. A trail of tiny claw prints leads north.

> go north
The Tide Pools
Rocky pools full of starfish. Paths lead north and east; the beach is south.
You see: seaweed snack

> take seaweed snack
You take the seaweed snack.

Requirements:

  • At least five rooms, each with a name, a description, exits, and possibly items lying around.
  • Commands: go <direction>, look, take <item>, inventory, quit.
  • Taking an item removes it from the room and adds it to your inventory.
  • A win condition: taking a specific item ends the game in glory.
  • Bad directions, missing items, and nonsense commands all get friendly replies, never crashes.
🛠️ Terminal required Keyboard input again, so play happens in your real terminal:
cd ~/rusty-lab
cargo new adventure
cd adventure
No dependencies this time. The whole world fits in the standard library.

The one new idea: rooms point at each other

Here is the design problem that makes this project interesting. Each room needs to know its neighbors, but Rust's ownership rules (Lesson 6) make "structs holding references to each other" genuinely hard. The classic solution is beautifully simple: keep every room in one Vec, and let exits store index numbers instead of references.

use std::collections::HashMap;

struct Room {
    name: &'static str,
    description: &'static str,
    exits: HashMap<&'static str, usize>,  // "north" -> index into the Vec
    items: Vec<&'static str>,
}

Your position in the world is just let mut here: usize = 0;. Moving is here = new_index. This indices-instead-of-references trick is everywhere in real Rust: game engines, graphs, compilers. You are learning it on a beach with a snack. 🌊

One convenience worth knowing, a quick way to build a small map:

let exits = HashMap::from([("south", 0), ("north", 2)]);

Hints, in order of desperation

Hint 1: What is the overall shape?

Three pieces of state and one loop:

let mut world: Vec<Room> = build_world();
let mut here: usize = 0;
let mut inventory: Vec<&str> = Vec::new();

loop {
    // print a prompt, read a line
    // parse it, match on the command
    // update state, print what happened
}

Write build_world() as its own function returning the Vec<Room>: world data in one place, game logic in another.

Hint 2: How do I parse commands?

This is Lesson 19's pattern-matching exercise, now with somewhere to live. Lowercase the input, split_once(' '), then match:

let cmd = input.trim().to_lowercase();

match cmd.split_once(' ') {
    None if cmd == "quit" => { break; }
    None if cmd == "look" => { /* describe the room */ }
    None if cmd == "inventory" => { /* list what you carry */ }
    Some(("go", direction)) => { /* try to move */ }
    Some(("take", item)) => { /* try to pick it up */ }
    _ => println!("I don't understand '{cmd}'."),
}

Note that take seaweed snack works for free: split_once only splits at the first space, so the item name keeps its spaces.

Hint 3: How does moving work?
Some(("go", direction)) => match world[here].exits.get(direction) {
    Some(&next) => {
        here = next;
        describe(&world[here]);
    }
    None => println!("You can't go {direction} from here."),
},

exits.get returns an Option<&usize>; the &next pattern reaches through the reference and copies the number out. A describe(room: &Room) helper keeps the printing in one place for both go and look.

Hint 4: How does take work without angering the borrow checker?

Find the item's position first, then remove by index:

Some(("take", item)) => {
    let room = &mut world[here];
    match room.items.iter().position(|i| *i == item) {
        Some(index) => {
            let taken = room.items.remove(index);
            println!("You take the {taken}.");
            inventory.push(taken);
            if taken == "golden claw" {
                println!("\n🎉 YOU WIN! 🦀");
                break;
            }
        }
        None => println!("There is no {item} here."),
    }
}

position then remove is the idiomatic "find and take out of a Vec" two-step. The removed value moves straight into your inventory: ownership transfer you can watch.

Hint 5: The full reference solution
use std::collections::HashMap;
use std::io::{self, Write};

struct Room {
    name: &'static str,
    description: &'static str,
    exits: HashMap<&'static str, usize>,
    items: Vec<&'static str>,
}

fn build_world() -> Vec<Room> {
    vec![
        Room {
            name: "The Beach",
            description: "Warm sand, gentle waves. A trail of tiny claw prints leads north.",
            exits: HashMap::from([("north", 1)]),
            items: vec![],
        },
        Room {
            name: "The Tide Pools",
            description: "Rocky pools full of starfish. Paths lead north and east; the beach is south.",
            exits: HashMap::from([("south", 0), ("north", 2), ("east", 3)]),
            items: vec!["seaweed snack"],
        },
        Room {
            name: "The Kelp Forest",
            description: "Towering kelp sways overhead. Something glints in a cave to the east.",
            exits: HashMap::from([("south", 1), ("east", 4)]),
            items: vec![],
        },
        Room {
            name: "The Shipwreck",
            description: "An old hull half-buried in sand. The tide pools are back west.",
            exits: HashMap::from([("west", 1)]),
            items: vec!["rusty key"],
        },
        Room {
            name: "The Glittering Cave",
            description: "Bioluminescent walls light up a small pedestal.",
            exits: HashMap::from([("west", 2)]),
            items: vec!["golden claw"],
        },
    ]
}

fn describe(room: &Room) {
    println!("{}\n{}", room.name, room.description);
    if !room.items.is_empty() {
        println!("You see: {}", room.items.join(", "));
    }
}

fn main() {
    let mut world = build_world();
    let mut here = 0;
    let mut inventory: Vec<&str> = Vec::new();

    println!("🦀 THE LOST CLAW: a tiny adventure");
    println!("Rusty lost the golden claw. Find it! (commands: go/look/take/inventory/quit)\n");
    describe(&world[here]);

    loop {
        print!("\n> ");
        io::stdout().flush().expect("could not flush");
        let mut input = String::new();
        if io::stdin().read_line(&mut input).expect("could not read") == 0 {
            break; // end of input
        }
        let cmd = input.trim().to_lowercase();

        match cmd.split_once(' ') {
            None if cmd == "quit" => {
                println!("Goodbye, adventurer.");
                break;
            }
            None if cmd == "look" => {
                describe(&world[here]);
                let mut exits: Vec<&str> = world[here].exits.keys().copied().collect();
                exits.sort();
                println!("Exits: {}", exits.join(", "));
            }
            None if cmd == "inventory" => {
                if inventory.is_empty() {
                    println!("You are carrying nothing.");
                } else {
                    println!("You are carrying: {}", inventory.join(", "));
                }
            }
            Some(("go", direction)) => match world[here].exits.get(direction) {
                Some(&next) => {
                    here = next;
                    describe(&world[here]);
                }
                None => println!("You can't go {direction} from here."),
            },
            Some(("take", item)) => {
                let room = &mut world[here];
                match room.items.iter().position(|i| *i == item) {
                    Some(index) => {
                        let taken = room.items.remove(index);
                        println!("You take the {taken}.");
                        inventory.push(taken);
                        if taken == "golden claw" {
                            println!("\n🎉 You found Rusty's golden claw! YOU WIN! 🦀");
                            break;
                        }
                    }
                    None => println!("There is no {item} here."),
                }
            }
            _ => println!("I don't understand '{cmd}'. Try: go <direction>, look, take <item>, inventory, quit."),
        }
    }
}

Why sort the exits before printing? HashMap makes no promises about order, so without the sort the exits list shuffles between runs. Small detail, real lesson.

Stretch goals

Make it yours

  • Locked doors. The Glittering Cave should really require the rusty key from the Shipwreck: check the inventory before allowing go east.
  • drop <item>. The reverse of take. Watch ownership flow back the other way.
  • A wandering NPC. A seagull that moves to a random adjacent room each turn and squawks if it sees you (bring back rand).
  • Load the world from a file. Design a text format for rooms (Project 3 skills) so anyone can write adventures without touching the code. That split, engine versus data, is how real games are built.
  • Turn counter. Report how many commands victory took, and taunt accordingly.