Tier 2 — Intermediate

Text Adventure

Build a room-based text adventure engine — graph of connected rooms, inventory, commands, game state.

🐍 Python 🟨 JavaScript

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

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

  1. Add a use <item> on <target> command — unlock a door with the key
  2. Add NPC characters with simple dialogue trees
  3. Add save/load game state to JSON
  4. 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

Try It Yourself