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.
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.
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.