Text-Based Game Design
[ ]
What are some building blocks we can use for text-based games?[ ]
How does the adventure game example work?[ ]
How does the roguelike game example work?
Text Games
Before the dawn of affordable raster displays and sufficient memory to hold a screenful of pixels, people played computer games in the form of dialogues with a computer (potentially miles away, over the telephone, via a printer and typewriter); or on simple local teletypes or personal computers. Some of the first massively multiplayer online games were text-based MUDs (Multi-User Dungeons), with some variants enabling user-created content and entire worlds! Interactive fictions by Infocom were among the preeminent games of the 1980s, and simulation games live on in both clicker games and graphing calculator diversions. In the last quarter-century, the world wide web has also been used to render and navigate hypertext fictions (popularized recently in e.g. Twine and Choice of Games).
Most of our discussion of operational logics so far has focused on graphical games, but OLs are also at work in text-based games. While physics is less emphasized, collision remains in games like Rogue or Dwarf Fortress; linking, resource, and game-state logics take center stage.
Activity: Find Text-Based Games
Find and play a few text-based games and report back. Think about these questions while you play:
- What do you do in this game?
- How does the game present your available actions or input options?
- How does the game show the results of your actions or the change in the world over time?
- What are some rules of this game, and what logics are they built from?
Here are a few indexes of such games that you can browse through:
- textbased.com
- The text-based tag on Itch.io
- A GitHub tag search
- Some older, historical games
Each team should:
- Send me a Zulip message with their team members and answers to (1-4) above for a game
- Find a time that they can meet with each other before Thursday's class to plan their game idea.
Adventure Game
cargo new --bin --edition 2021 adventure-game
In a text-based adventure game, the player (usually addressed in second person) navigates a simulated space and interacts by typing messages which are parsed and then enacted. Sometimes an explicit menu of options is given, and sometimes options are presented as hyperlinks.
Oftentimes, adventure games are built around conventions of pen-and-paper role-playing games: characters have inventories of objects, unlock doors and disarm traps, and solve puzzles. Sometimes objects themselves can have objects inside of them (e.g. I'm holding a bag with three stones inside, and I put the bag onto a plinth; now the bag is "in" the plinth and the stones are in the bag). This double logic of linked spaces—rooms are connected to each other, things are inside of other things—is foundational to a certain tradition of text adventure games. Another important logic here is persistence (once I solve a puzzle it stays solved).
At an implementation level, we might decide to code up movement between rooms in one way and the tree of object containment in another. Persistence is simple here—there aren't some things that get reset and others that persist for example—so we won't need any explicit code structures to support it.
Let's start with moving between rooms. We need a data structure representing the room graph, so let's make some ontological commitments.
struct Room { name: String, // E.g. "Antechamber" desc: String, // E.g. "Dark wood paneling covers the walls. The gilded northern doorway lies open." doors: Vec<Door> } struct Door { target: RoomID, // More about this in a minute triggers:Vec<String>, // e.g. "go north", "north" message: Option<String> // What message, if any, to print when the doorway is traversed // Any other info about the door would go here }
So each room has its essential data—a name and description and its outgoing links—and the links themselves have some data, including their destinations. A trick we'll use often in this course is to allocate all of our rooms (or whatever) in a contiguous block of memory and use indices (usize
-s) to refer to them. For bonus points we'll wrap those usize
values into what are called newtypes to keep them distinct from other uses of usize
.
#[derive(PartialEq, Eq, Clone, Copy, Debug)] struct RoomID(usize);
Note that the data in Room
and Door
is durable—there's never a reason it should change during gameplay. We want to be in the habit of separating transient player state from the immutable definitions used to construct the game world.
We can build the network of rooms in an array constant, but in a real game we'd probably load it from a JSON or TOML file or something.
let rooms = [ Room { name: "Foyer".into(), // Turn a &'static string (string constant) into a String desc: "This beautifully decorated foyer beckons you further into the mansion. There is a door to the north.".into(), doors: vec![ Door{ target:RoomID(1), triggers:vec!["door".into(), "north".into(), "go north".into()], message:None }] }, Room { name: "Antechamber".into(), desc: "Dark wood paneling covers the walls. An intricate painting of a field mouse hangs slightly askew on the wall (it looks like you could fix it). The gilded northern doorway lies open to a shadowy parlour. You can return to the foyer to the southern door.".into(), doors: vec![ Door{target:RoomID(0), triggers:vec!["door".into(), "south".into(), "go south".into(), "foyer".into()], message:None}, Door{target:RoomID(2), triggers:vec!["north".into(), "doorway".into(), "go north".into()], message:None}, Door{target:RoomID(3), triggers:vec!["painting".into(), "mouse".into(), "fix painting".into()], message:Some("As you adjust the painting, a trap-door opens beneath your feet!".into())} ] }, Room { name: "A Room Full of Snakes!".into(), desc: "The shadows wriggle and shift as you enter the parlour. The floor is covered in snakes! The walls are covered in snakes! The ceiling is covered in snakes! You are also covered in snakes!\n\nBAD END".into(), doors:vec![] }, Room { name: "The Vault".into(), desc: "When you regain consciousness, you feel a stabbing sensation in your lower back. Reaching beneath you, you discover a massive diamond! This room is full of gold and jewels, and a convenient ladder leading back outdoors!\n\nYou win!".into(), doors:vec![] } ];
Of course in a real game, we'd want to load this from a file—and support more robust parsing in terms of verbs and nouns, rather than give each door a fixed set of trigger phrases.
Then our game loop will consist of printing room descriptions and asking the player what to do next:
fn main() { use std::io; // We need the Write trait so we can flush stdout use std::io::Write; <<rooms>> let end_rooms = [RoomID(2), RoomID(3)]; let mut input = String::new(); let mut at = RoomID(0); println!("The Spooky Mansion Adventure"); println!("============================"); println!(); println!("You've been walking for hours in the countryside, and have finally stumbled on the spooky mansion you read about in the tour guide."); loop { // We don't want to move out of rooms, so we take a reference let here:&Room = &rooms[at.0]; println!("{}\n{}", here.name, here.desc); if end_rooms.contains(&at) { break; } loop { print!("What will you do?\n> "); io::stdout().flush().unwrap(); input.clear(); io::stdin().read_line(&mut input).unwrap(); let input = input.trim(); if let Some(door) = here.doors. iter(). find(|d| d.triggers.iter().any(|t| *t == input)) { if let Some(msg) = &door.message { println!("{}", msg); } at = door.target; break; } else { println!("You can't do that!"); } } } }
Lab: Mini Text Game
At the moment, our game state is just the room we're in. For lab credit, add an explicit WorldState
or GameState
struct that tracks not only where we are, but possibly what items we've obtained or what our character's stats are; modify room, door, or other game structures to update and read from this state. You'll want to replace the at
variable with this more robust state.
One really important thing to remember about the example above is that it was totally made-up and arbitrary; I just decided that there is a vec of rooms, and that each door has a list of trigger words and a single target. You get to decide how the world is made. I did it this way so that the code was as simple as possible, without needing a real command parser.
Roguelike
cargo new --bin --edition 2021 roguelike
In a Rogue-like game, the player (usually drawn as an @
sign) navigates a simulated space and interacts by pressing keyboard keys. Sometimes menus of options are given (e.g., an inventory menu). Like an adventure game, there are typically several linked spaces and text messages are the main way of showing changes to the game world, but unlike an adventure game the interior of each individual space is navigable. Typically there is some type of combat mechanic (activated when the player and an enemy bump into each other); here we'll just have the player die when touched.
We'll end up somewhere like this:
We'll use the terminal kind of like a screen, but instead of pixels we have characters, each of which can have a foreground and a background color. The crossterm
crate handles this in a cross-plaform way (cargo add crossterm
or add a crossterm
dependency to your Cargo.toml
).
This code sample is much larger than any program we've seen so far. We'll take it piece by piece. First, let's think about our data structures. If you want to start from this code for your game, you'd need to modify at least Tile and ThingType (and probably Thing, Map, and World).
struct World { maps:Vec<Map>, current_map:usize, // An index into the maps vec. } struct Map { // There are other ways to do this, but this is fine for now. tiles:[[Tile; 80]; 23], // An grid of 23 rows of 80 Tiles entities:Vec<Thing>, // We'll assume the player is always the 0th thing in this list // There's no strong reason the player // has to be "just another entity"---we could just as well // have separate vecs of each type of entity, and a // special `player:Option<Player>` field, or make the Player a property // of the world and not the room. This is just one // arbitrary choice among many possible alternatives. } #[derive(Clone,Copy)] enum Tile { Empty, Wall, Stairs(usize,(u8,u8)), // The usize here is the map to which the stairs go, the tuple is where in that map } struct Thing { position:(u8,u8), thing_type:ThingType } enum ThingType { // These variants are empty now but they could just as well be given associated data Player/* (PlayerData) */, Enemy/* (EnemyType, EnemyData) */, // Treasure, // ... }
It's a good habit to start by defining your data structures, and think about how your game will process this data. In a sense, a game is a machine for transforming "old" game data into "new" game data.
Both tiles and things need to be drawn as single characters with optional foreground and background colors. We'll introduce a Style
trait and implement it for both enums. If you modify this starter code, you'll want to change these functions.
use crossterm::style::Colors; trait Style { fn colors(&self) -> Colors; fn look(&self) -> char; } impl Style for Tile { fn colors(&self) -> Colors { match self { Tile::Empty => Colors{foreground:Some(Color::Black), background:Some(Color::Black)}, Tile::Wall => Colors{foreground:Some(Color::White), background:Some(Color::Black)}, Tile::Stairs(_,_) => Colors{foreground:Some(Color::White), background:Some(Color::Black)}, } } fn look(&self) -> char { match self { Tile::Empty => '.', Tile::Wall => '#', Tile::Stairs(_,_) => '>', } } } impl Style for ThingType { fn colors(&self) -> Colors { match self { ThingType::Player => Colors{foreground:Some(Color::White), background:Some(Color::Black)}, ThingType::Enemy => Colors{foreground:Some(Color::Red), background:Some(Color::Black)}, } } fn look(&self) -> char { match self { ThingType::Player => '@', ThingType::Enemy => 'E', } } }
With those pieces, we can render a map onto stdout:
impl Map { fn draw(&self, out:&mut std::io::Stdout) -> std::io::Result<()> { // We can scope a use just to a single function, which is nice use std::io::Write; use crossterm::{terminal, QueueableCommand, cursor, style::{SetColors, Print}}; out.queue(terminal::BeginSynchronizedUpdate)?; for (y,row) in self.tiles.iter().enumerate() { out.queue(cursor::MoveTo(0,y as u16))?; for tile in row.iter() { out.queue(SetColors(tile.colors()))?; out.queue(Print(tile.look()))?; } } for ent in self.entities.iter() { let (x,y) = ent.position; out.queue(cursor::MoveTo(x as u16,y as u16))?; out.queue(SetColors(ent.thing_type.colors()))?; out.queue(Print(ent.thing_type.look()))?; } out.queue(crossterm::terminal::EndSynchronizedUpdate)?; out.flush()?; Ok(()) } }
In the other direction, it would be great to be able to load a map from an ASCII-art string like this:
################ #..............# #..............# #..............# #..............# #..............# ####....########
We'll implement a simple parser that assumes maps of (compile-time) fixed dimensions. If you modify this code example to make your game, you'll want to change this function. The only weird bit is that numbers 0-9 turn into exits to the corresponding floors—but of course, most real games will have more than 10 floors, so think about that. Also note that this won't load entities like enemies from the map description.
To handle a map of arbitrary dimensions, we'd need to take W
as a formal parameter (e.g., of type usize) and return a Vec<Vec<Tile>>
or similar.
fn parse_tilemap<const W:usize, const H:usize>(text:&'static str) -> [[Tile; W] ; H] { // H rows, each W wide let mut ret = [[Tile::Empty; W]; H]; let chars:Vec<_> = text.chars().collect(); for (y,row) in chars.chunks(W).enumerate() { for (x,ch) in row.iter().enumerate() { let tile = match ch { '#' => Tile::Wall, '.' => Tile::Empty, '0'..='9' => Tile::Stairs( ch.to_digit(10).unwrap() as usize, (x as u8,y as u8) ), _ => Tile::Empty }; ret[y][x] = tile; } } ret }
Now, let's look at our game loop. We'll tackle it in three pieces: terminal setup and cleanup, world initialization, and the update-render loop.
fn main() -> std::io::Result<()> { // terminal initialization // ... // world initialization // ... // update-render loop while let Ok(evt) = read() { if let Event::Key(KeyEvent{code,kind:KeyEventKind::Press,..}) = evt { if code == KeyCode::Esc { break; } if game_over { continue; } // game rule updates // rendering } } // terminal cleanup Ok(()) }
Terminal setup and cleanup look something like this:
fn main() -> std::io::Result<()> { use std::io::stdout; use crossterm::event::{read, Event, KeyEvent, KeyEventKind, KeyCode}; let mut stdout = stdout(); { // we can even scope a use to just a single block! use crossterm::{terminal, ExecutableCommand}; terminal::enable_raw_mode()?; stdout.execute(crossterm::terminal::SetSize(80,24))?; stdout.execute(crossterm::cursor::Hide)?; stdout.execute(terminal::Clear(terminal::ClearType::All))?; } // ... event loop and everything else goes here... // Then we finally clean up: { use crossterm::{terminal,ExecutableCommand}; terminal::disable_raw_mode()?; stdout.execute(crossterm::cursor::Show)?; stdout.execute(terminal::Clear(terminal::ClearType::All))?; } Ok(()) }
Next, before entering the loop for the first time we'll use a snippet like this to set up our game world. Two text files (map0.txt
and map1.txt
) define the map tiles, while the objects are positioned in the maps manually (it might be better to parse them along with the map).
let mut world = World { maps:vec![ Map { tiles:parse_tilemap(include_str!("map0.txt")), entities:vec![ Thing{thing_type:ThingType::Player, position:(2,2)}, Thing{thing_type:ThingType::Enemy, position:(39,17)}, ] }, Map { tiles:parse_tilemap(include_str!("map1.txt")), entities:vec![ Thing{thing_type:ThingType::Enemy, position:(52,21)}, ] } ], current_map:0 }; let mut game_over = false; // One initial draw so that we have something on screen before the first event arrives. world.maps[world.current_map].draw(&mut stdout)?;
Finally, we can show the two pieces of the game loop: update and rendering.
// Get the next event from crossterm, waiting until it's ready while let Ok(evt) = read() { if let Event::Key(KeyEvent{code,kind:KeyEventKind::Press,..}) = evt { if code == KeyCode::Esc { break; } if game_over { continue; } let mut status_message = ( " ", Colors{foreground:None, background:None} ); // Game rule updates: first, interpret key events. // If you have custom game rules you might want e.g. i to open the inventory. let (dx,dy) = match code { KeyCode::Left => (-1, 0), KeyCode::Right => (1, 0), KeyCode::Up => (0, -1), KeyCode::Down => (0, 1), _ => (0,0) }; // Get the current map from the world let map = &mut world.maps[world.current_map]; // Ask it to move our player. We'll read through this function's code later. map.move_entity(0, dx, dy); // Then loop through all the other entities and have them move randomly for ent in 1..map.entities.len() { let dx:i8 = rng.gen_range(-1..=1); let dy:i8 = rng.gen_range(-1..=1); map.move_entity(ent, dx, dy); } // Remember where the player is now... let (x,y) = map.entities[0].position; // if any enemy is touching the player, game over for ent in map.entities[1..].iter() { // Matching with `if let` is used here since we haven't // implemented or derived PartialEq or Eq on ThingType. // We'll talk about that another time. if let ThingType::Enemy = ent.thing_type { if ent.position == (x,y) { // Set a status message to render later status_message = ("You died!", Colors{foreground:Some(Color::Red), background:Some(Color::Black)}); game_over = true; } } } // Maybe move between floors if let Tile::Stairs(to_map, to_pos) = map.tiles[y as usize][x as usize] { world.current_map = to_map; // We'll also move the special player entity where it goes // in the new room. let mut player = map.entities.remove(0); player.position = to_pos; world.maps[to_map].entities.insert(0,player); } // Update's done; render the game state. world.maps[world.current_map].draw(&mut stdout)?; { use crossterm::ExecutableCommand; stdout.execute(crossterm::cursor::MoveTo(0, 23))?; stdout.execute(crossterm::style::SetColors(status_message.1))?; stdout.execute(crossterm::style::Print(status_message.0))?; } } }
Finally, the actual movement of objects in a map can look like this (it can go in the same impl Map
block or a new one). The key idea is to make sure the move is in-bounds and doesn't put the entity into a wall.
impl Map { fn move_entity(&mut self, which: usize, dx: i8, dy: i8) -> bool { let (x, y) = self.entities[which].position; let to_x = x as i16 + dx as i16; let to_y = y as i16 + dy as i16; if !(0_i16..80).contains(&to_x) || !(0_i16..23).contains(&to_y) { return false; } if let Tile::Wall = self.tiles[to_y as usize][to_x as usize] { return false; } self.entities[which].position = (to_x as u8, to_y as u8); true } }
To support something like menus, we may want to try to create a more complex game state system:
enum GameMode { Playing, InventoryMenu, ShopMenu }
And then decide what user interface elements to show (and what keyboard buttons do) based on the current game mode. We could achieve this in a simple way by wrapping our game update and rendering in a match
against the current game mode.
Feel free to build on top of this example for your text-based game!
The Game/Engine Divide
The above outlines are for single-file games, which is not how we'll be mainly working in this class. We want to go for a an engine/game split: everything above the game-engine line is the engine which is potentially shared across many games, and everything below is game-specific. For the text adventure example, the base datastructures, player inventory, and some logic for allowing moves between rooms could be part of the engine, while the specific game map is game-specific; the parser could live on either side of the line. The main idea is that we want to capture the part that varies between games and put that on the game side of the line, and the part that sets an ontology that could be used by many games would live on the engine side. Try to think about what parts feel very particular to the game you're making and what parts feel more general-purpose.
When we explore the GPU next week, we'll go into more detail on Rust's module system.