1. The Problem
Build a text adventure engine with rooms connected by exits (north/south/east/west), items you can pick up and use, and a simple command parser. The world is a graph — rooms are nodes, exits are edges.
Why this project? A directed graph implemented as a Map, command parsing with a dispatch table, game state as a plain object, and the classic "world data separate from engine logic" pattern used in every game.
2. How to Think About This
Store rooms in a Map: roomId → { name, description, exits: {north, south...}, items: [] }. Game state: { currentRoom, inventory }. Commands: an object mapping command words to handler functions — a dispatch table. Parse input by splitting on spaces, look up the verb, call the handler.
3. The Build
import * as readline from "readline/promises";
import { stdin, stdout } from "process";
// ── World definition ────────────────────────────────────────────────────────
export const WORLD = new Map([
["entrance", {
name: "Cave Entrance",
desc: "You stand at the mouth of a dark cave. Sunlight streams in from behind you.",
exits: { north: "tunnel", east: "forest" },
items: ["torch"],
}],
["tunnel", {
name: "Dark Tunnel",
desc: "A narrow tunnel stretches ahead. Without light, you can barely see.",
exits: { south: "entrance", north: "chamber" },
items: [],
darkRoom: true,
}],
["chamber", {
name: "Crystal Chamber",
desc: "A vast chamber filled with glittering crystals. A chest sits in the corner.",
exits: { south: "tunnel" },
items: ["gem", "key"],
}],
["forest", {
name: "Ancient Forest",
desc: "Tall trees surround you. Birds sing overhead. A path leads west.",
exits: { west: "entrance" },
items: ["map"],
}],
]);
// ── Engine ──────────────────────────────────────────────────────────────────
export function createGame(startRoom = "entrance") {
return { room: startRoom, inventory: [], moves: 0, flags: {} };
}
export function look(state) {
const room = WORLD.get(state.room);
if (!room) return "You are nowhere.";
const dark = room.darkRoom && !state.inventory.includes("torch");
if (dark) return "It's pitch dark. You can't see anything.
(Hint: you need a light source)";
const exits = Object.keys(room.exits).join(", ") || "none";
const items = room.items.length ? `
Items here: ${room.items.join(", ")}` : "";
return `
${room.name}
${room.desc}${items}
Exits: ${exits}`;
}
export function go(state, direction) {
const room = WORLD.get(state.room);
const dest = room?.exits[direction];
if (!dest) return `You can't go ${direction} from here.`;
if (!WORLD.has(dest)) return "That way seems blocked.";
state.room = dest;
state.moves++;
return look(state);
}
export function take(state, item) {
const room = WORLD.get(state.room);
const idx = room?.items.indexOf(item);
if (idx === undefined || idx === -1) return `There's no ${item} here.`;
room.items.splice(idx, 1);
state.inventory.push(item);
return `You pick up the ${item}.`;
}
export function drop(state, item) {
const idx = state.inventory.indexOf(item);
if (idx === -1) return `You don't have a ${item}.`;
state.inventory.splice(idx, 1);
WORLD.get(state.room)?.items.push(item);
return `You drop the ${item}.`;
}
// ── Command parser ──────────────────────────────────────────────────────────
export function parse(input, state) {
const [verb, ...args] = input.trim().toLowerCase().split(/\s+/);
const noun = args.join(" ");
const commands = {
look: () => look(state),
l: () => look(state),
go: () => go(state, noun),
north: () => go(state, "north"),
south: () => go(state, "south"),
east: () => go(state, "east"),
west: () => go(state, "west"),
n: () => go(state, "north"),
s: () => go(state, "south"),
e: () => go(state, "east"),
w: () => go(state, "west"),
take: () => take(state, noun),
get: () => take(state, noun),
drop: () => drop(state, noun),
inv: () => state.inventory.length ? `Carrying: ${state.inventory.join(", ")}` : "You're not carrying anything.",
i: () => parse("inv", state),
help: () => "Commands: look, go <dir>, n/s/e/w, take <item>, drop <item>, inv, quit",
quit: () => { process.exit(0); },
};
return (commands[verb] ?? (() => `Unknown command: "${verb}". Type 'help' for commands.`))();
}
// ── CLI ─────────────────────────────────────────────────────────────────────
const rl = readline.createInterface({ input: stdin, output: stdout });
const state = createGame();
console.log("
🗺 TEXT ADVENTURE");
console.log("Type 'help' for commands, 'quit' to exit.
");
console.log(look(state));
while (true) {
const input = await rl.question("
> ");
if (!input.trim()) continue;
console.log(parse(input, state));
}
4. Test & Prove
import { createGame, go, take, drop, look, parse } from "./adventure.mjs";
let p=0,f=0;
function test(d,fn){try{fn();console.log(" ✓",d);p++;}catch(e){console.log(" ✗",d,"—",e.message);f++;}}
const state = createGame("entrance");
test("look returns room name", () => { if(!look(state).includes("Cave Entrance")) throw new Error(); });
test("go north moves room", () => { go(state,"north"); if(state.room!=="tunnel") throw new Error(state.room); });
test("dark room without torch", () => { if(!look(state).includes("dark")) throw new Error(); });
test("go back south", () => { go(state,"south"); if(state.room!=="entrance") throw new Error(); });
test("take item", () => { take(state,"torch"); if(!state.inventory.includes("torch")) throw new Error(); });
test("dark room with torch", () => { go(state,"north"); if(look(state).includes("dark")) throw new Error(); });
test("drop item", () => { drop(state,"torch"); if(state.inventory.includes("torch")) throw new Error(); });
test("invalid direction", () => { if(!go(state,"up").includes("can't")) throw new Error(); });
test("parse shortcut 'n'", () => { const s2=createGame(); parse("n",s2); if(s2.room!=="tunnel") throw new Error(s2.room); });
test("parse unknown command", () => { if(!parse("fly",state).includes("Unknown")) throw new Error(); });
console.log(`
${p} passed, ${f} failed`);5. The Interface
🗺 TEXT ADVENTURE Cave Entrance You stand at the mouth of a dark cave. Exits: north, east > take torch You pick up the torch. > n Dark Tunnel A narrow tunnel stretches ahead. Exits: south, north > inv Carrying: torch
6. Run It
node adventure.mjs
node adventure.test.mjs🎯 Try this next
- Add a
use <item> on <target>command — unlock a door with the key - Add NPC characters with simple dialogue trees
- Add save/load game state to JSON
- Generate the room graph from a JSON file — separate world data from engine code entirely
What you practised
Graph data structure (rooms as Map nodes, exits as directed edges), dispatch table (commands object instead of if/else), game state as a plain mutable object, separating world data from engine logic, and the [verb, ...args] destructuring pattern for command parsing.
Reference: Set & Map · Destructuring & Spread · Objects