Skip to content

Recipes ​

Each recipe is a small, complete world about one idea, in packages/engine/src/worlds/examples/. Every transcript on this page is what a test gets by typing its commands into that world.

How a recipe fits into a world. A recipe is a whole world file, built like the two-room game: one World object with rooms, items, characters and events. The code blocks under each heading show the part of the file the recipe is about; the rest (the rooms around it, the events it names, its seed) is in The whole world, folded under the notes. To put an idea in your own game, copy the parts it shows into your world’s object, along with anything they name.

To play a recipe, you don’t need to copy anything: every one ships with the engine under the name its file exports. Set up a project as in Play it and type each > line of a transcript, without the > :

ts
import { createGame } from '@brass-lantern/engine';
import { containers } from '@brass-lantern/engine/worlds';

const game = createGame(containers);
for (const command of ['take key', 'open jar', 'take key from jar']) {
  console.log(`> ${command}`);
  console.log(game.send(command).lines.join('\n'));
}

Each transcript starts from a new game (a fresh createGame), just after its opening lines (game.opening). Where randomness matters, the world’s seed fixes it, so the same commands give the same replies every time.

Rules appear in every recipe. They hang off a verb (instead: { examine: [ … ] }), as a list:

  • instead rules replace what the verb does; after rules run once it has worked.
  • A rule’s keys: if is a condition that has to hold; with names the second object it applies to (unlock tin with key); then names an event to run; say prints lines instead.
  • The first rule that fits wins. The list is tried in order, so put the specific rules before a catch-all { say: [ … ] }. The thing’s own rules are asked before the room’s.

Rules has the whole shape, and Events and effects lists the steps an event can take.

Containers and keys ​

A shelf with a glass jar on it, a key in the jar, and a locked tin the key opens.

ts
items: {
  // A surface: what's on it is always in view and in reach.
  shelf: { name: 'shelf', description: 'A sagging pine shelf.', portable: false, tags: [], scenery: true, surface: true, contains: ['jar'] },
  // Transparent: you can see the key through the glass, but not take it until it's open.
  jar: {
    name: 'glass jar',
    aliases: ['jar'],
    description: 'A jar with a screw-top lid.',
    portable: true,
    tags: [],
    container: { openable: true, transparent: true },
    contains: ['key'],
  },
  key: { name: 'tin key', aliases: ['key'], description: 'A tiny key.', portable: true, tags: [] },
  // Locked, opened by the key, with room for two things.
  tin: {
    name: 'cookie tin',
    aliases: ['tin'],
    description: 'A dented tin with a keyhole.',
    portable: false,
    refusal: 'It’s wedged in tight.',
    tags: [],
    container: { openable: true, locked: true, key: 'key', capacity: 2, opened: 'The lid pops off with a sigh of cinnamon.' },
    contains: ['cookie'],
  },
  cookie: { name: 'cookie', description: 'Snickerdoodle.', portable: true, tags: [] },
},
  • A surface (the shelf) shows what’s on it, and you can reach it.
  • A container holds things. openable gives it a lid; locked and key lock it; capacity limits how many things fit directly inside; opened replaces the default “Opened.”.
  • transparent lets the player see inside while it’s closed, but not reach in: TAKE KEY says which container is in the way.
  • contains says what starts inside or on it.
The whole world: containers.ts
ts
import type { World } from '@brass-lantern/engine';

/** Recipe: containers, surfaces and keys. See docs/guide/building-worlds/recipes.md. */
export const containers: World = {
  startRoom: 'pantry',
  rooms: {
    pantry: {
      name: 'Pantry',
      description: 'Narrow shelves, a smell of cinnamon.',
      exits: {},
      items: ['shelf', 'tin'],
      npcs: [],
      onEnter: [],
    },
  },
  items: {
    // A surface: what's on it is always in view and in reach.
    shelf: { name: 'shelf', description: 'A sagging pine shelf.', portable: false, tags: [], scenery: true, surface: true, contains: ['jar'] },
    // Transparent: you can see the key through the glass, but not take it until it's open.
    jar: {
      name: 'glass jar',
      aliases: ['jar'],
      description: 'A jar with a screw-top lid.',
      portable: true,
      tags: [],
      container: { openable: true, transparent: true },
      contains: ['key'],
    },
    key: { name: 'tin key', aliases: ['key'], description: 'A tiny key.', portable: true, tags: [] },
    // Locked, opened by the key, with room for two things.
    tin: {
      name: 'cookie tin',
      aliases: ['tin'],
      description: 'A dented tin with a keyhole.',
      portable: false,
      refusal: 'It’s wedged in tight.',
      tags: [],
      container: { openable: true, locked: true, key: 'key', capacity: 2, opened: 'The lid pops off with a sigh of cinnamon.' },
      contains: ['cookie'],
    },
    cookie: { name: 'cookie', description: 'Snickerdoodle.', portable: true, tags: [] },
  },
  npcs: {},
  dialogue: {},
  events: {},
  flagLabels: {},
};
> take key
The glass jar is closed.
> open jar
Opened.
> take key from jar
Taken: tin key.
> unlock tin with key
Unlocked.
> open tin
The lid pops off with a sigh of cinnamon.
> take cookie
Taken: cookie.
> put key in tin
Done.

Doors between rooms are the same block on an item with door: true; the two-room game has one.

Darkness and death ​

A shed with a lamp, and a dark cellar below it.

ts
cellar: {
  name: 'Cellar',
  description: 'Damp stone walls. Steps lead up.',
  dark: true,
  exits: { up: 'shed' },
  items: ['jam'],
  npcs: [],
  onEnter: [],
},
ts
lamp: { name: 'oil lamp', aliases: ['lamp'], description: 'A brass oil lamp.', portable: true, tags: [], switchable: true, light: true },
ts
darkness: {
  look: 'It is pitch black. Something breathes nearby.',
  // A direction with no exit, in the dark: Zork's grue, minus the 20% you live.
  blunder: [{ die: 'You stumble into something with far too many teeth.' }],
},
death: {
  message: ['    ****  You have died  ****'],
  lives: 1,
  respawn: 'shed',
  resurrection: ['You wake on the shed floor with a headache and a new respect for the dark.'],
  final: ['That was your last chance. The cellar keeps you.'],
},
  • dark: true makes a room need light. A light item gives it while it’s on (it has to be switchable to turn on), whether it’s carried, on the floor, or inside something open.
  • In the dark the player can only find what they’re carrying. Anything else gets tooDark (“It’s too dark to see.”), and no time passes.
  • blunder runs when the player tries a direction with no exit in the dark. Zork’s grue is a chance of death; this one always bites.
  • death says what dying does. With lives: 1 the player gets one resurrection in respawn; the next death plays final and ends the game. Without a death block, dying ends the game at once.
The whole world: darkness.ts
ts
import type { World } from '@brass-lantern/engine';

/** Recipe: dark rooms, a lamp, and a death that isn't the end. See docs/guide/building-worlds/recipes.md. */
export const darkness: World = {
  startRoom: 'shed',
  rooms: {
    shed: {
      name: 'Shed',
      description: 'A garden shed. A hatch in the floor leads down.',
      exits: { down: 'cellar' },
      items: ['lamp'],
      npcs: [],
      onEnter: [],
    },
    cellar: {
      name: 'Cellar',
      description: 'Damp stone walls. Steps lead up.',
      dark: true,
      exits: { up: 'shed' },
      items: ['jam'],
      npcs: [],
      onEnter: [],
    },
  },
  items: {
    lamp: { name: 'oil lamp', aliases: ['lamp'], description: 'A brass oil lamp.', portable: true, tags: [], switchable: true, light: true },
    jam: { name: 'jar of jam', aliases: ['jam', 'jar'], description: 'Damson, 1987.', portable: true, tags: [] },
  },
  darkness: {
    look: 'It is pitch black. Something breathes nearby.',
    // A direction with no exit, in the dark: Zork's grue, minus the 20% you live.
    blunder: [{ die: 'You stumble into something with far too many teeth.' }],
  },
  death: {
    message: ['    ****  You have died  ****'],
    lives: 1,
    respawn: 'shed',
    resurrection: ['You wake on the shed floor with a headache and a new respect for the dark.'],
    final: ['That was your last chance. The cellar keeps you.'],
  },
  npcs: {},
  dialogue: {},
  events: {},
  flagLabels: {},
};
> down
📍 Darkness
It is pitch black. Something breathes nearby.
> take jam
It’s too dark to see.
> east
You stumble into something with far too many teeth.
    ****  You have died  ****
You wake on the shed floor with a headache and a new respect for the dark.
📍 Shed
A garden shed. A hatch in the floor leads down.
You can see: oil lamp.
Exits: down.
> take lamp
Taken: oil lamp.
> turn on lamp
The oil lamp is now on.
> down
📍 Cellar
Damp stone walls. Steps lead up.
You can see: jar of jam.
Exits: up.

A lamp that runs out is a variable and a few daemons: see Time.

Timers ​

A kettle that boils three turns after you turn it on, a clock, and a dripping tap.

ts
items: {
  kettle: {
    name: 'kettle',
    description: 'A whistling kettle.',
    portable: false,
    tags: [],
    switchable: true,
    // Turning it on lights a three-turn fuse; turning it off puts it out.
    after: { turn_on: [{ then: 'heat' }], turn_off: [{ then: 'cool' }] },
  },
},
events: {
  heat: [{ schedule: 'boil', in: 3 }],
  cool: [{ cancel: 'boil' }],
  boil: ['The kettle shrieks. Tea time.', { switch: 'kettle', on: false }, { set: 'boiled' }],
  chime: ['The clock chimes the hour.'],
},
ts
vars: { minutes: 0 },
daemons: [
  // Every turn is a minute; on the fifth, the clock chimes.
  { if: 'var:minutes>=0', then: [{ add: 'minutes', by: 1 }] },
  { if: 'var:minutes=5', then: 'chime' },
],
ambient: [{ if: '!flag:boiled', every: 2, lines: ['The tap drips.', 'The tap drips again.'] }],
  • A fuse is the schedule effect: run an event after so many turns. cancel stops it. Here after rules start and stop it: they run once TURN ON or TURN OFF has worked.
  • A daemon runs after every turn while its if holds. Two daemons make a clock: one counts minutes in a variable, the other chimes when it reaches 5.
  • ambient lines interrupt every few turns while their condition holds.
  • A turn is a command the engine acted on. Misunderstood commands, questions, and VERBOSE or UNDO take no time.
The whole world: timers.ts
ts
import type { World } from '@brass-lantern/engine';

/** Recipe: fuses, daemons and ambient lines. See docs/guide/building-worlds/recipes.md. */
export const timers: World = {
  startRoom: 'kitchen',
  rooms: {
    kitchen: {
      name: 'Kitchen',
      description: 'A small kitchen with a kettle on the stove.',
      exits: {},
      items: ['kettle'],
      npcs: [],
      onEnter: [],
    },
  },
  items: {
    kettle: {
      name: 'kettle',
      description: 'A whistling kettle.',
      portable: false,
      tags: [],
      switchable: true,
      // Turning it on lights a three-turn fuse; turning it off puts it out.
      after: { turn_on: [{ then: 'heat' }], turn_off: [{ then: 'cool' }] },
    },
  },
  events: {
    heat: [{ schedule: 'boil', in: 3 }],
    cool: [{ cancel: 'boil' }],
    boil: ['The kettle shrieks. Tea time.', { switch: 'kettle', on: false }, { set: 'boiled' }],
    chime: ['The clock chimes the hour.'],
  },
  vars: { minutes: 0 },
  daemons: [
    // Every turn is a minute; on the fifth, the clock chimes.
    { if: 'var:minutes>=0', then: [{ add: 'minutes', by: 1 }] },
    { if: 'var:minutes=5', then: 'chime' },
  ],
  ambient: [{ if: '!flag:boiled', every: 2, lines: ['The tap drips.', 'The tap drips again.'] }],
  npcs: {},
  dialogue: {},
  flagLabels: {},
};
> turn on kettle
The kettle is now on.
> wait
Time passes.
The tap drips.
> wait
Time passes.
> wait
Time passes.
The kettle shrieks. Tea time.
> wait
Time passes.
The clock chimes the hour.

Endings and verbs of your own ​

A garden with something buried in it, and a chapel. Digging and praying aren’t built in, so the world declares them.

ts
verbs: {
  dig: { words: ['dig', 'dig in'], target: 'optional', indirect: ['with'], reply: 'There’s nowhere to dig here.' },
  pray: { words: ['pray'], target: 'none', reply: 'Your prayer goes unanswered here.' },
},
ts
events: {
  treasure: ['You dig. Your spade rings on a strongbox full of coins.', { score: 50 }, { end: 'rich' }],
  blessing: ['A warm calm settles over you.', { score: 10 }, { end: 'peace' }],
},
scoring: [],
maxScore: 50,
endings: {
  rich: { lines: ['✨ You are rich, and the garden is a mess.'], score: true, footer: ['Type RESTART to try another ending.'] },
  peace: { lines: ['✨ You leave lighter than you came.'], score: true, footer: ['Type RESTART to try another ending.'] },
},
  • A world verb does nothing by itself. Rules give it meaning: the flowerbed has an instead.dig rule, and so does the garden, for DIG with no object. The chapel has instead.pray. Anywhere else, the verb’s reply answers.
  • end plays an ending: its lines, the score (with score: true), then the footer.
  • The score here comes from the score effect, out of maxScore. Worlds can also award points for flags with scoring.
The whole world: endings.ts
ts
import type { World } from '@brass-lantern/engine';

/** Recipe: verbs of your own, and more than one way to end. See docs/guide/building-worlds/recipes.md. */
export const endings: World = {
  startRoom: 'garden',
  rooms: {
    garden: {
      name: 'Garden',
      description: 'An overgrown garden. A chapel stands to the east. Something glints in a flowerbed.',
      exits: { east: 'chapel' },
      items: ['spade', 'flowerbed'],
      npcs: [],
      onEnter: [],
      // DIG with no target asks the room.
      instead: { dig: [{ if: 'has:spade', then: 'treasure' }, { say: ['The soil is packed hard. You need a spade.'] }] },
    },
    chapel: {
      name: 'Chapel',
      description: 'Cool and quiet. The garden is west.',
      exits: { west: 'garden' },
      items: [],
      npcs: [],
      onEnter: [],
      instead: { pray: [{ then: 'blessing' }] },
    },
  },
  verbs: {
    dig: { words: ['dig', 'dig in'], target: 'optional', indirect: ['with'], reply: 'There’s nowhere to dig here.' },
    pray: { words: ['pray'], target: 'none', reply: 'Your prayer goes unanswered here.' },
  },
  items: {
    spade: { name: 'spade', description: 'A rusty spade.', portable: true, tags: [] },
    flowerbed: {
      name: 'flowerbed',
      aliases: ['bed', 'flowers', 'soil'],
      description: 'Something glints in the soil.',
      portable: false,
      tags: [],
      scenery: true,
      instead: { dig: [{ if: 'has:spade', then: 'treasure' }] },
    },
  },
  events: {
    treasure: ['You dig. Your spade rings on a strongbox full of coins.', { score: 50 }, { end: 'rich' }],
    blessing: ['A warm calm settles over you.', { score: 10 }, { end: 'peace' }],
  },
  scoring: [],
  maxScore: 50,
  endings: {
    rich: { lines: ['✨ You are rich, and the garden is a mess.'], score: true, footer: ['Type RESTART to try another ending.'] },
    peace: { lines: ['✨ You leave lighter than you came.'], score: true, footer: ['Type RESTART to try another ending.'] },
  },
  npcs: {},
  dialogue: {},
  flagLabels: {},
};
> dig
The soil is packed hard. You need a spade.
> take spade
Taken: spade.
> dig in flowerbed
You dig. Your spade rings on a strongbox full of coins.
✨ You are rich, and the garden is a mess.
[Score: 50 of 50, in 2 moves.]
Type RESTART to try another ending.

The other ending, from a new game:

> east
📍 Chapel
Cool and quiet. The garden is west.
Exits: west.
> pray
A warm calm settles over you.
✨ You leave lighter than you came.
[Score: 10 of 50, in 1 move.]
Type RESTART to try another ending.

A guard to fight ​

An armory with a sword and an anvil too heavy to lift, and a guard who won’t let you through.

ts
items: {
  sword: { name: 'sword', description: 'Notched, but sharp.', portable: true, tags: [], weapon: true, size: 10 },
  anvil: { name: 'anvil', description: 'Black iron, very heavy.', portable: true, tags: [], size: 60 },
  club: { name: 'club', description: 'A knotted club.', portable: true, tags: [], weapon: true, size: 10 },
},
// Nothing heavier than 50 in all.
carry: { limit: 50 },
ts
npcs: {
  guard: {
    name: 'guard',
    description: 'A guard with a club blocks the arch.',
    descriptions: [{ if: '!awake:guard', text: 'The guard lies senseless on the flagstones.' }],
    holds: ['club'],
    combat: {
      strength: 2,
      weapon: 'club',
      firstStrike: 50,
      // Without his club, he spends his turns looking for it.
      onBusy: 'guard_gropes',
      onDeath: 'guard_drops_club',
      onUnconscious: 'guard_drops_club',
      messages: {
        missed: ['The guard’s club whistles past your head.'],
        lightWound: ['The club catches your shoulder.'],
        seriousWound: ['The club cracks against your ribs.'],
        stagger: ['The guard shoves you back.'],
        unconscious: ['The club comes down on your head.'],
        killed: ['The guard’s club finds your temple.'],
        loseWeapon: ['The guard knocks the {weapon} from your hand.'],
      },
    },
  },
},
combat: {
  strength: { min: 2, max: 2 },
  // The engine's own lines say “their” and “themselves”; these make the guard “he”.
  messages: { loseWeapon: ['The club flies out of the guard’s hand.'] },
  texts: { defenseless: 'The guard can’t defend himself.' },
},
ts
gate: {
  name: 'Gatehouse',
  description: 'A stone arch. Beyond it, daylight. The armory is south.',
  exits: {
    south: 'armory',
    // The guard bars the way until he's dead or out cold.
    north: { to: 'courtyard', denials: [{ if: 'awake:guard', text: 'The guard steps in front of you.' }] },
  },
  items: [],
  npcs: ['guard'],
  onEnter: [],
},
  • carry turns on weight. Each item weighs its size (5 if unset) plus what’s inside it, and TAKE refuses what would go over limit.
  • combat on a character makes it someone you can fight with a weapon. The engine runs the fight: your strength against theirs, a seeded roll, and Zork’s tables of results. The guard’s messages are its blows at you; world.combat holds yours (short defaults here).
  • The engine’s own fight lines say “they” (“The guard drops their weapon.”), since it doesn’t know who a character is. The world’s own combat block (not the guard’s) replaces the two that would say it here, messages.loseWeapon and texts.defenseless, so the guard is “he” throughout.
  • holds gives the guard his club. While he has it he fights back; knock it away and onBusy runs instead of a swing.
  • awake:guard in the exit’s denials keeps the way shut until he’s dead or out cold.
  • seed (here seed: 1, in the whole world below) fixes the world’s random generator, so this transcript plays the same way every time. Leave it out and every game differs.
  • DIAGNOSE says how hurt you are; wounds heal with time.
The whole world: guard.ts
ts
import type { World } from '@brass-lantern/engine';

/** Recipe: a guard to fight, and something too heavy to carry. See docs/guide/building-worlds/recipes.md. */
export const guard: World = {
  startRoom: 'armory',
  // A fixed seed, so the fight in the recipe's transcript plays the same way every time.
  seed: 1,
  rooms: {
    armory: {
      name: 'Armory',
      description: 'Racks of rusted weapons. An anvil squats in the corner. A gate is north.',
      exits: { north: 'gate' },
      items: ['sword', 'anvil'],
      npcs: [],
      onEnter: [],
    },
    gate: {
      name: 'Gatehouse',
      description: 'A stone arch. Beyond it, daylight. The armory is south.',
      exits: {
        south: 'armory',
        // The guard bars the way until he's dead or out cold.
        north: { to: 'courtyard', denials: [{ if: 'awake:guard', text: 'The guard steps in front of you.' }] },
      },
      items: [],
      npcs: ['guard'],
      onEnter: [],
    },
    courtyard: {
      name: 'Courtyard',
      description: 'Sunlight, at last.',
      exits: {},
      items: [],
      npcs: [],
      onEnter: [{ if: 'in:courtyard', then: 'escape' }],
    },
  },
  items: {
    sword: { name: 'sword', description: 'Notched, but sharp.', portable: true, tags: [], weapon: true, size: 10 },
    anvil: { name: 'anvil', description: 'Black iron, very heavy.', portable: true, tags: [], size: 60 },
    club: { name: 'club', description: 'A knotted club.', portable: true, tags: [], weapon: true, size: 10 },
  },
  // Nothing heavier than 50 in all.
  carry: { limit: 50 },
  npcs: {
    guard: {
      name: 'guard',
      description: 'A guard with a club blocks the arch.',
      descriptions: [{ if: '!awake:guard', text: 'The guard lies senseless on the flagstones.' }],
      holds: ['club'],
      combat: {
        strength: 2,
        weapon: 'club',
        firstStrike: 50,
        // Without his club, he spends his turns looking for it.
        onBusy: 'guard_gropes',
        onDeath: 'guard_drops_club',
        onUnconscious: 'guard_drops_club',
        messages: {
          missed: ['The guard’s club whistles past your head.'],
          lightWound: ['The club catches your shoulder.'],
          seriousWound: ['The club cracks against your ribs.'],
          stagger: ['The guard shoves you back.'],
          unconscious: ['The club comes down on your head.'],
          killed: ['The guard’s club finds your temple.'],
          loseWeapon: ['The guard knocks the {weapon} from your hand.'],
        },
      },
    },
  },
  combat: {
    strength: { min: 2, max: 2 },
    // The engine's own lines say “their” and “themselves”; these make the guard “he”.
    messages: { loseWeapon: ['The club flies out of the guard’s hand.'] },
    texts: { defenseless: 'The guard can’t defend himself.' },
  },
  dialogue: { guard: { default: '“Move along.”' } },
  flagLabels: {},
  events: {
    guard_drops_club: [{ move: 'club', to: 'here' }],
    guard_gropes: ['The guard gropes for his club.'],
    escape: ['✨ You walk out into the sun.', { end: 'free' }],
  },
  endings: { free: { lines: ['You’re free.'] } },
};
> take anvil
[That’s too heavy to carry with everything else.]
> take sword
Taken: sword.
> north
📍 Gatehouse
A stone arch. Beyond it, daylight. The armory is south.
Present: guard.
Exits: south, north.
> north
The guard steps in front of you.
The guard shoves you back.
> attack guard with sword
You’re still reeling from that last blow.
The guard’s club whistles past your head.
> attack guard with sword
You wound the guard.
The club catches your shoulder.
> attack guard with sword
The club flies out of the guard’s hand.
The guard gropes for his club.
> attack guard with sword
The guard can’t defend himself.
The guard is dead.
> diagnose
[You have a light wound, which will be cured after 28 moves.]
[You can be killed by one more light wound.]
> north
📍 Courtyard
Sunlight, at last.
✨ You walk out into the sun.
You’re free.

In Infocom style (style: 'infocom' on the world) the same fight uses Zork’s words, and kill guard picks the one weapon you’re holding. The native Zork I that ships with the engine (import { zork1 } from '@brass-lantern/engine/worlds') has a troll that is this recipe with Zork’s own numbers and messages.

Scripts ​

A fortune teller who tells you one of three fortunes, then sticks to it.

ts
verbs: { consult: { words: ['consult'], target: 'required' } },
events: { reading: [{ script: 'fortune' }] },
vars: { fortune: 0 },
scripts: {
  // Picks one of three fortunes the first time, then sticks to it.
  fortune: (ctx) => {
    const told = ctx.state.vars?.fortune ?? 0;
    const pick = told || ctx.roll(3);
    const text = ['“A stranger will bring you soup.”', '“Beware of geese.”', '“You will find what you lost under the sofa.”'][pick - 1];
    return told ? ['“I have told you already,” she sighs.', text] : [{ setVar: 'fortune', to: pick }, text];
  },
},
  • A script is a function in scripts that returns ordinary steps: lines and effects. Events call it with { script: 'name' }. It gets one argument, ctx, the game as the script sees it; Scripts lists what’s on it.
  • It reads the game, it doesn’t change it. ctx.state is read-only; to change things, return effects (setVar here). That keeps the engine’s guarantees: a command it didn’t understand changes nothing, and saves replay exactly.
  • ctx.roll(3) is 1 to 3 from the game’s seeded generator, never Math.random(). With this world’s seed: 3 the fortune is the sofa one; another seed picks another.
  • A rule on a character (instead.consult on Madame Zora) answers a world verb aimed at her.
The whole world: fortune.ts
ts
import type { World } from '@brass-lantern/engine';

/** Recipe: a script, for what data alone can't say. See docs/guide/building-worlds/recipes.md. */
export const fortune: World = {
  startRoom: 'tent',
  seed: 3,
  rooms: {
    tent: {
      name: 'Fortune Teller’s Tent',
      description: 'Velvet, incense, and a crystal ball.',
      exits: {},
      items: [],
      npcs: ['madame'],
      onEnter: [],
    },
  },
  items: {},
  npcs: {
    madame: {
      name: 'Madame Zora',
      article: '',
      description: 'Madame Zora peers at you over the crystal ball.',
      instead: { consult: [{ then: 'reading' }] },
    },
  },
  verbs: { consult: { words: ['consult'], target: 'required' } },
  events: { reading: [{ script: 'fortune' }] },
  vars: { fortune: 0 },
  scripts: {
    // Picks one of three fortunes the first time, then sticks to it.
    fortune: (ctx) => {
      const told = ctx.state.vars?.fortune ?? 0;
      const pick = told || ctx.roll(3);
      const text = ['“A stranger will bring you soup.”', '“Beware of geese.”', '“You will find what you lost under the sofa.”'][pick - 1];
      return told ? ['“I have told you already,” she sighs.', text] : [{ setVar: 'fortune', to: pick }, text];
    },
  },
  dialogue: {},
  flagLabels: {},
};
> consult madame
“You will find what you lost under the sofa.”
> consult madame
“I have told you already,” she sighs.
“You will find what you lost under the sofa.”

Reach for a script only when data can’t say it. Most behavior is rules, conditions and effects.

Topics and orders ​

A librarian who answers questions, and won’t be bossed about.

ts
npcs: {
  librarian: {
    name: 'librarian',
    description: 'The librarian peers at you over her glasses.',
    topics: {
      books: '“Shelved by color. Don’t ask.”',
      archive: [
        { if: 'has:brass_key', text: '“You have the key. Go on, then.”' },
        { text: 'lend_key' },
      ],
      dragons: '“Second floor, between the cookbooks and the tax law.”',
      // ASK LIBRARIAN ABOUT ME (or MYSELF).
      me: '“You look like someone with an overdue book.”',
    },
    topicAliases: { archive: ['the archive', 'archives', 'old letters', 'key'], books: ['book', 'shelves'] },
    noTopic: '“I couldn’t say, dear.”',
    refuseOrder: '“Shh.”',
  },
},
events: {
  // A topic entry can name an event: asking about the archive lends you the key.
  lend_key: ['“Oh, the archive.” She slides a brass key across the desk.', { move: 'brass_key', to: 'player' }],
  found_it: ['✨ The letters are all here.', { end: 'found' }],
},
  • topics answer ASK (or TELL) librarian ABOUT something. Each topic is a line, or a list of entries tried in order; the first whose if holds wins.
  • An entry can name an event, which runs instead of printing. Asking about the archive lends you the key, once; after that the has:brass_key entry answers first.
  • topicAliases are other words for a topic, matched the same way item names are, so “the archive”, “archives” and “key” all find archive.
  • A topic keyed me answers ASK librarian ABOUT ME, and ABOUT MYSELF too.
  • noTopic answers anything else. Without it, ASK falls back to what TALK TO would say.
  • refuseOrder answers an order: “librarian, open the door” or “tell librarian to open the door”. Without it the reply is “librarian ignores you.” A rule on the character (instead.order) can answer a particular order instead; in a script it runs, ctx.command.words.indirect is what was ordered (“open the door”).
The whole world: topics.ts
ts
import type { World } from '@brass-lantern/engine';

/** Recipe: topics and orders. See docs/guide/building-worlds/recipes.md. */
export const topics: World = {
  startRoom: 'library',
  rooms: {
    library: {
      name: 'Library',
      description: 'Tall shelves, a reading lamp, and a locked door marked ARCHIVE.',
      exits: { north: { to: 'archive', door: 'archive_door' } },
      items: [],
      npcs: ['librarian'],
      onEnter: [],
      scenery: ['archive_door'],
    },
    archive: {
      name: 'Archive',
      description: 'Boxes of old letters. You found it.',
      exits: { south: { to: 'library', door: 'archive_door' } },
      items: [],
      npcs: [],
      onEnter: [{ if: 'in:archive', then: 'found_it' }],
      scenery: ['archive_door'],
    },
  },
  items: {
    archive_door: {
      name: 'archive door',
      aliases: ['door'],
      description: 'A heavy door marked ARCHIVE.',
      portable: false,
      tags: [],
      door: true,
      container: { openable: true, locked: true, key: 'brass_key' },
    },
    brass_key: { name: 'brass key', aliases: ['key'], description: 'Small and brass.', portable: true, tags: [] },
  },
  npcs: {
    librarian: {
      name: 'librarian',
      description: 'The librarian peers at you over her glasses.',
      topics: {
        books: '“Shelved by color. Don’t ask.”',
        archive: [
          { if: 'has:brass_key', text: '“You have the key. Go on, then.”' },
          { text: 'lend_key' },
        ],
        dragons: '“Second floor, between the cookbooks and the tax law.”',
        // ASK LIBRARIAN ABOUT ME (or MYSELF).
        me: '“You look like someone with an overdue book.”',
      },
      topicAliases: { archive: ['the archive', 'archives', 'old letters', 'key'], books: ['book', 'shelves'] },
      noTopic: '“I couldn’t say, dear.”',
      refuseOrder: '“Shh.”',
    },
  },
  events: {
    // A topic entry can name an event: asking about the archive lends you the key.
    lend_key: ['“Oh, the archive.” She slides a brass key across the desk.', { move: 'brass_key', to: 'player' }],
    found_it: ['✨ The letters are all here.', { end: 'found' }],
  },
  endings: { found: { lines: ['You found what you came for.'] } },
  dialogue: { librarian: { default: '“Can I help you find something?”' } },
  flagLabels: {},
};
> ask librarian about books
“Shelved by color. Don’t ask.”
> ask librarian about me
“You look like someone with an overdue book.”
> ask librarian about the weather
“I couldn’t say, dear.”
> librarian, open the door
“Shh.”
> ask librarian about the archive
“Oh, the archive.” She slides a brass key across the desk.
> ask librarian about the archive
“You have the key. Go on, then.”

A character with no orders or obeys only answers; see Orders, numbers and a buggy for one that carries them out.

A wandering character ​

A cat who wanders three rooms and walks off with your sock.

ts
// The cat's turn: a daemon that runs a script after every turn the engine acts on.
daemons: [{ if: 'alive:cat', then: [{ script: 'cat_turn' }] }],
scripts: {
  cat_turn: (ctx) => {
    const rooms = ['hall', 'kitchen', 'garden'];
    const at = rooms.find((r) => ctx.npcIn('cat', r))!;
    // Half the time it stays put.
    if (ctx.roll(2) === 1) return [];
    const next = rooms[(rooms.indexOf(at) + 1) % rooms.length];
    const here = ctx.room();
    const steps: EventStep[] = [{ moveNpc: 'cat', to: next }];
    if (at === here) steps.push('The cat stalks off.');
    if (next === here) steps.push('The cat pads in.');
    // A sock on the floor where it arrives goes with it.
    if (ctx.children(next).includes('sock')) {
      steps.push({ move: 'sock', to: 'cat' });
      if (next === here) steps.push('The cat bats the sock away somewhere. It’s gone.');
    }
    return steps;
  },
},
  • A daemon runs after every turn the engine acts on. This one’s if (alive:cat) keeps the cat moving for as long as it lives, and its step is a script.
  • The script decides; its steps act. ctx.npcIn finds the cat, ctx.roll decides whether it moves, and { moveNpc } moves it. Lines are printed only when the player can see the cat leave or arrive (ctx.room() is where the player is).
  • ctx.children(room) lists what’s on the floor there. { move: 'sock', to: 'cat' } puts the sock in the cat’s keeping; a character’s holdings aren’t listed in a room.
  • The world’s seed (seed: 4) fixes the cat’s path, so the transcript below plays the same way every time: the cat stays in the kitchen for six turns, then comes to the hall and takes the sock, which starts on the hall floor. It keeps wandering after that (hall, kitchen, garden and round again), which is why it turns up in the kitchen behind you. ctx is described under Scripts.
The whole world: wanderer.ts
ts
import type { EventStep, World } from '@brass-lantern/engine';

/** Recipe: a character who wanders and steals. See docs/guide/building-worlds/recipes.md. */
export const wanderer: World = {
  startRoom: 'hall',
  seed: 4,
  rooms: {
    hall: { name: 'Hall', description: 'A narrow hall. The kitchen is east, the garden south.', exits: { east: 'kitchen', south: 'garden' }, items: ['sock'], npcs: [], onEnter: [] },
    kitchen: { name: 'Kitchen', description: 'A warm kitchen. The hall is west.', exits: { west: 'hall' }, items: [], npcs: ['cat'], onEnter: [] },
    garden: { name: 'Garden', description: 'A small garden. The hall is north.', exits: { north: 'hall' }, items: [], npcs: [], onEnter: [] },
  },
  items: {
    sock: { name: 'sock', description: 'A single woolly sock.', portable: true, tags: [] },
  },
  npcs: {
    cat: { name: 'cat', description: 'A gray cat with opinions.', holds: [] },
  },
  // The cat's turn: a daemon that runs a script after every turn the engine acts on.
  daemons: [{ if: 'alive:cat', then: [{ script: 'cat_turn' }] }],
  scripts: {
    cat_turn: (ctx) => {
      const rooms = ['hall', 'kitchen', 'garden'];
      const at = rooms.find((r) => ctx.npcIn('cat', r))!;
      // Half the time it stays put.
      if (ctx.roll(2) === 1) return [];
      const next = rooms[(rooms.indexOf(at) + 1) % rooms.length];
      const here = ctx.room();
      const steps: EventStep[] = [{ moveNpc: 'cat', to: next }];
      if (at === here) steps.push('The cat stalks off.');
      if (next === here) steps.push('The cat pads in.');
      // A sock on the floor where it arrives goes with it.
      if (ctx.children(next).includes('sock')) {
        steps.push({ move: 'sock', to: 'cat' });
        if (next === here) steps.push('The cat bats the sock away somewhere. It’s gone.');
      }
      return steps;
    },
  },
  events: {},
  dialogue: {},
  flagLabels: {},
};
> wait
Time passes.
> wait
Time passes.
> wait
Time passes.
> wait
Time passes.
> wait
Time passes.
> wait
Time passes.
The cat pads in.
The cat bats the sock away somewhere. It’s gone.
> look
📍 Hall
A narrow hall. The kitchen is east, the garden south.
Present: cat.
Exits: east, south.
> east
📍 Kitchen
A warm kitchen. The hall is west.
Exits: west.
The cat pads in.

Native Zork I’s thief is this recipe grown up: he moves through the rooms, steals what’s worth stealing, and stays out of sight (hidden) until he chooses to show himself.

A room that listens ​

A cave that hears everything you say as an echo, until you say the magic word.

ts
cave: {
  name: 'Echoing Cave',
  description: 'A vast cave. Every sound comes back to you, louder. The way out is out.',
  descriptions: [{ if: 'flag:quiet', text: 'A vast cave, quiet now. The way out is out.' }],
  exits: { out: 'ledge' },
  items: [],
  npcs: [],
  onEnter: [],
  // Until it's quiet, whatever you say is heard as noise.
  capture: { if: '!flag:quiet', script: 'echo' },
},
ts
scripts: {
  echo: (ctx) => {
    // Captures also see commands that arrive already parsed (AGAIN); this one only hears raw words.
    if (ctx.line === undefined) return;
    const words = ctx.line.toLowerCase().trim().split(/\s+/);
    // Declining (returning nothing) lets the line be parsed as usual: OUT still works.
    if (words[0] === 'out') return;
    if (words[0] === 'echo') return [{ set: 'quiet' }, 'The cave falls silent.', { free: true }];
    const last = words[words.length - 1];
    return [`${last} ${last} ...`, { free: true }];
  },
},
  • This world is in Infocom style (style: 'infocom'), like Zork: rooms show no exit line, and a room you’ve been in before shows just its name.
  • capture sees each command before the parser does, while its if holds. The script reads the raw words from ctx.line.
  • Returning steps takes the command, and drops the rest of the line. Returning nothing declines: the command is parsed as usual, so OUT still leaves.
  • A capture also sees commands that arrive already parsed (AGAIN, the intent server’s reading), with ctx.line unset; this one only hears raw words, so it returns nothing then.
  • { free: true } makes the reply take no time. The transcript below counts three moves: IN, LOOK and OUT.
  • Captured words never reach the intent server, and SAVE, UNDO and RESTART are handled before any capture, so a player can’t get stuck.
The whole world: echo.ts
ts
import type { World } from '@brass-lantern/engine';

/** Recipe: a room that listens. See docs/guide/building-worlds/recipes.md. */
export const echo: World = {
  startRoom: 'ledge',
  style: 'infocom',
  rooms: {
    ledge: {
      name: 'Ledge',
      description: 'A narrow ledge outside a cave. The cave mouth is in.',
      exits: { in: 'cave' },
      items: [],
      npcs: [],
      onEnter: [],
    },
    cave: {
      name: 'Echoing Cave',
      description: 'A vast cave. Every sound comes back to you, louder. The way out is out.',
      descriptions: [{ if: 'flag:quiet', text: 'A vast cave, quiet now. The way out is out.' }],
      exits: { out: 'ledge' },
      items: [],
      npcs: [],
      onEnter: [],
      // Until it's quiet, whatever you say is heard as noise.
      capture: { if: '!flag:quiet', script: 'echo' },
    },
  },
  items: {},
  npcs: {},
  scripts: {
    echo: (ctx) => {
      // Captures also see commands that arrive already parsed (AGAIN); this one only hears raw words.
      if (ctx.line === undefined) return;
      const words = ctx.line.toLowerCase().trim().split(/\s+/);
      // Declining (returning nothing) lets the line be parsed as usual: OUT still works.
      if (words[0] === 'out') return;
      if (words[0] === 'echo') return [{ set: 'quiet' }, 'The cave falls silent.', { free: true }];
      const last = words[words.length - 1];
      return [`${last} ${last} ...`, { free: true }];
    },
  },
  events: {},
  dialogue: {},
  flagLabels: {},
};
> in
📍 Echoing Cave
A vast cave. Every sound comes back to you, louder. The way out is out.
> hello there
there there ...
> echo
The cave falls silent.
> look
📍 Echoing Cave
A vast cave, quiet now. The way out is out.
> out
📍 Ledge

Native Zork I’s Loud Room is this recipe with Zork’s rules: ECHO quiets it, and when the dam’s gates are open at high tide its end routine (onEnd) throws you out.

A raft on a pond ​

A raft, a pond you can’t wade, and an island beyond it.

ts
raft: {
  name: 'raft',
  description: 'A raft of lashed logs.',
  portable: false,
  tags: [],
  vehicle: { travels: 'water' },
  container: { open: true },
},
ts
pond: {
  name: 'Pond',
  description: 'The middle of the pond. Lily pads drift by. An island lies to the east, the bank to the west.',
  // Only something that floats goes here.
  water: true,
  exits: { west: 'bank', east: 'island' },
  items: [],
  npcs: [],
  onEnter: [],
},
  • This world is in Infocom style too, so the vehicle lines are Zork’s: “Pond, in the raft”, “(raft)” when the parser fills in what you meant, and “(outside the raft)” on the room’s things.
  • vehicle makes an item something to get into: BOARD it while it’s on the ground, DISEMBARK to get out.
  • water marks a room only a water vehicle reaches. On foot it’s “You can’t go there without a vehicle.”; aboard, getting out there is refused.
  • The raft goes where you go, and comes to rest on the island’s shore with you still aboard. Things you drop land in the raft, and the room’s things are “(outside the raft)”.
The whole world: raft.ts
ts
import type { World } from '@brass-lantern/engine';

/** Recipe: a raft on a pond. See docs/guide/building-worlds/recipes.md. */
export const raft: World = {
  startRoom: 'bank',
  style: 'infocom',
  rooms: {
    bank: {
      name: 'Pond Bank',
      description: 'A muddy bank. A pond stretches east.',
      exits: { east: 'pond' },
      items: ['raft'],
      npcs: [],
      onEnter: [],
    },
    pond: {
      name: 'Pond',
      description: 'The middle of the pond. Lily pads drift by. An island lies to the east, the bank to the west.',
      // Only something that floats goes here.
      water: true,
      exits: { west: 'bank', east: 'island' },
      items: [],
      npcs: [],
      onEnter: [],
    },
    island: {
      name: 'Island',
      description: 'A tiny island with one tree. The pond is west.',
      exits: { west: 'pond' },
      items: ['pinecone'],
      npcs: [],
      onEnter: [],
    },
  },
  items: {
    raft: {
      name: 'raft',
      description: 'A raft of lashed logs.',
      portable: false,
      tags: [],
      vehicle: { travels: 'water' },
      container: { open: true },
    },
    pinecone: { name: 'pine cone', aliases: ['cone', 'pinecone'], description: 'A fine pine cone.', portable: true, tags: [] },
  },
  npcs: {},
  events: {},
  dialogue: {},
  flagLabels: {},
};
> east
You can’t go there without a vehicle.
> board raft
You are now in the raft.
> east
📍 Pond, in the raft
The middle of the pond. Lily pads drift by. An island lies to the east, the bank to the west.
> disembark
(raft)
You realize that getting out here would be fatal.
> east
The raft comes to a rest on the shore.

📍 Island, in the raft
A tiny island with one tree. The pond is west.
There is a pine cone here. (outside the raft)

Native Zork I’s magic boat is this recipe with Zork’s rules on top: inflating it, the label, LAUNCH, punctures and the river’s current.

Orders, numbers and a buggy ​

A workshop with a robot that does as it’s told, a dial you turn to a number, and a dune buggy for the sand beyond the yard.

ts
npcs: {
  robot: {
    name: 'robot',
    description: 'A squat robot on treads, with one clamp for an arm.',
    // It does these built-in orders itself: “robot, go east”, “robot, take wrench”.
    obeys: ['go', 'take'],
    obeyReplies: { go: 'Whirr, click!', take: 'Click!' },
    refuseOrder: 'The robot beeps, puzzled.',
    // And these through rules, keyed by the verb: “robot, push the button”.
    orders: {
      push: [{ if: 'target:button', then: 'robot_pushes' }, { say: ['The robot has no use for that.'] }],
      // “robot, go to the dunes”: in a go rule, `direction:` is where it was told to go, as typed.
      go: [{ if: 'direction:dunes', say: ['The robot looks at its treads, then at the sand, and stays put.'] }],
    },
  },
},
  • obeys lists the built-in orders the robot carries out itself: GO, TAKE, DROP and GIVE (“robot, give me the wrench”). obeyReplies is what it says; the default is “Okay.”
  • orders is a table of rules per verb, for anything else (“robot, push the button”). Its rules read the order as the player wrote it: target:button is what it was told to push. “Push” is also the word the parser files under USE; the word typed is tried first, so orders.push answers it even when orders.use exists.
  • direction: in an orders.go rule is the place the character was told to go, as typed: “robot, go to the dunes” is direction:dunes. A rule that answers stops the robot obeying; anything else (“robot, go east”) falls through to obeys.
  • An order ends the rest of the line, and the objects of an order are looked up in its room, not the player’s.
ts
dial: {
  name: 'dial',
  aliases: ['brass dial'],
  // `{var:dial}` fills in from the game; it's 0 until the dial is set.
  description: 'The brass dial points at {var:dial}.',
  portable: false,
  tags: [],
  instead: {
    turn: [
      { with: 'number', if: 'number:4', then: 'hatch_opens' },
      { with: 'number', then: 'dial_set' },
    ],
  },
},
  • A number is a second object: turn dial to 4. A rule says with: 'number' and tests number:4; { setVar: 'dial', from: 'number' } keeps it and {number} prints it. {var:dial} fills in a description from the game, so EXAMINE shows where the dial points now.
ts
dune: {
  name: 'Dune',
  description: 'Soft sand rolls away in every direction. The yard is west; a bigger dune lies east.',
  // A terrain of the world's own: only something that travels on sand goes here.
  terrain: 'sand',
  exits: { west: 'yard', east: 'crest' },
  items: [],
  npcs: [],
  onEnter: [],
},
crest: {
  name: 'Crest',
  description: 'The top of the biggest dune. You can see the whole desert, and it is all sand.',
  terrain: 'sand',
  exits: { west: 'dune' },
  items: [],
  npcs: [],
  onEnter: [],
},
ts
buggy: {
  name: 'dune buggy',
  aliases: ['buggy'],
  description: 'A rattling dune buggy with fat tires.',
  portable: false,
  tags: [],
  // It travels on land and on sand; its `leave` lines are said as it drives off with you.
  vehicle: { travels: ['land', 'sand'], leave: ['The engine roars.', 'Sand sprays behind you.'] },
  container: { open: true },
},
  • A terrain is any name a room gives itself (Vehicles has the fields). On foot you can walk only where onFoot allows (default ['land']), so the dunes need a vehicle that travels on 'sand'. leave (and arrive, landing) are lines the vehicle says as it goes; with more than one, the seeded generator picks (this world’s seed: 4 picks “Sand sprays behind you.” first).
The whole world: workshop.ts
ts
import type { World } from '@brass-lantern/engine';

/** Recipe: orders, numbers and a buggy. See docs/guide/building-worlds/recipes.md. */
export const workshop: World = {
  startRoom: 'workshop',
  // Fixes the buggy's choice of line, so the recipe's transcript plays the same every time.
  seed: 4,
  rooms: {
    workshop: {
      name: 'Workshop',
      description: 'Benches, a big brass dial on the wall and a red button. The yard is east.',
      exits: { east: 'yard' },
      items: ['dial', 'button', 'wrench'],
      npcs: ['robot'],
      onEnter: [],
    },
    yard: {
      name: 'Yard',
      description: 'A gravel yard. The workshop is west, and dunes begin to the east.',
      exits: { west: 'workshop', east: 'dune' },
      items: ['buggy'],
      npcs: [],
      onEnter: [],
    },
    dune: {
      name: 'Dune',
      description: 'Soft sand rolls away in every direction. The yard is west; a bigger dune lies east.',
      // A terrain of the world's own: only something that travels on sand goes here.
      terrain: 'sand',
      exits: { west: 'yard', east: 'crest' },
      items: [],
      npcs: [],
      onEnter: [],
    },
    crest: {
      name: 'Crest',
      description: 'The top of the biggest dune. You can see the whole desert, and it is all sand.',
      terrain: 'sand',
      exits: { west: 'dune' },
      items: [],
      npcs: [],
      onEnter: [],
    },
  },
  items: {
    dial: {
      name: 'dial',
      aliases: ['brass dial'],
      // `{var:dial}` fills in from the game; it's 0 until the dial is set.
      description: 'The brass dial points at {var:dial}.',
      portable: false,
      tags: [],
      instead: {
        turn: [
          { with: 'number', if: 'number:4', then: 'hatch_opens' },
          { with: 'number', then: 'dial_set' },
        ],
      },
    },
    button: { name: 'red button', aliases: ['button'], description: 'A red button. It begs to be pushed.', portable: false, tags: [] },
    wrench: { name: 'wrench', description: 'A big, greasy wrench.', portable: true, tags: [] },
    buggy: {
      name: 'dune buggy',
      aliases: ['buggy'],
      description: 'A rattling dune buggy with fat tires.',
      portable: false,
      tags: [],
      // It travels on land and on sand; its `leave` lines are said as it drives off with you.
      vehicle: { travels: ['land', 'sand'], leave: ['The engine roars.', 'Sand sprays behind you.'] },
      container: { open: true },
    },
  },
  npcs: {
    robot: {
      name: 'robot',
      description: 'A squat robot on treads, with one clamp for an arm.',
      // It does these built-in orders itself: “robot, go east”, “robot, take wrench”.
      obeys: ['go', 'take'],
      obeyReplies: { go: 'Whirr, click!', take: 'Click!' },
      refuseOrder: 'The robot beeps, puzzled.',
      // And these through rules, keyed by the verb: “robot, push the button”.
      orders: {
        push: [{ if: 'target:button', then: 'robot_pushes' }, { say: ['The robot has no use for that.'] }],
        // “robot, go to the dunes”: in a go rule, `direction:` is where it was told to go, as typed.
        go: [{ if: 'direction:dunes', say: ['The robot looks at its treads, then at the sand, and stays put.'] }],
      },
    },
  },
  events: {
    hatch_opens: ['The dial clicks to 4, and a hatch in the floor swings open. Inside is a tin of biscuits.', { setVar: 'dial', from: 'number' }, { end: 'biscuits' }],
    dial_set: [{ setVar: 'dial', from: 'number' }, 'The dial clicks round to {number}. Nothing else happens.'],
    robot_pushes: ['The robot extends its clamp and presses the button. Somewhere, a bell rings.'],
  },
  endings: { biscuits: { lines: ['A good day in the workshop.'] } },
  dialogue: { robot: { default: 'The robot beeps.' } },
  flagLabels: {},
};
> robot, go to the dunes
The robot looks at its treads, then at the sand, and stays put.
> robot, push the button
The robot extends its clamp and presses the button. Somewhere, a bell rings.
> robot, go east
Whirr, click!
> turn dial to 2
The dial clicks round to 2. Nothing else happens.
> examine dial
The brass dial points at 2.
> east
📍 Yard
A gravel yard. The workshop is west, and dunes begin to the east.
You can see: dune buggy.
Present: robot.
Exits: west, east.
> east
You can’t go there without a vehicle.
> board buggy
You are now in the dune buggy.
> east
Sand sprays behind you.
📍 Dune (in the dune buggy)
Soft sand rolls away in every direction. The yard is west; a bigger dune lies east.
Exits: west, east.

Turn the dial to 4 instead and the hatch opens, which ends the game.

Brass Lantern v2.1.1 · Released under the MIT License. · Why a brass lantern?