# Brass Lantern > An npm library for classic parser text adventures (go north, take lamp) in a CRT terminal. Worlds are data: rooms, items, characters, rules and events in one typed object. It also plays Z-machine story files such as Zork. --- Source: https://mrballistic.github.io/brass-lantern/ # BRASS LANTERN: A text adventure engine on npm Three library packages for classic parser games in a CRT terminal, plus an MCP server for coding agents. Write your own world as data, mount it on a page, or load a Z-machine story file. The demo plays the Zork trilogy. - **Worlds are data.** A game is one TypeScript object of rooms, items, people, events and the rules that join them. Write a story without touching engine code. - **A parser that meets players halfway.** Classic verbs run instantly, and so do chained commands like “take key and wallet”. Anything else can go to an LLM through the optional server package, which only maps it onto your verbs and IDs; it never writes the story. - **The terminal is the product.** Boot sequence, scanlines, phosphor bloom and decay, flicker, a typewriter that changes pace with the scene. All CSS, no canvas. - **The genre’s good parts, built in.** Gated rooms, use rules, gifts, timed interruptions, hints, a score with ranks, and endings that remember what you did. - **Plays Zork, too.** Z-machine story files run in the same terminal, starting with Zork I, II and III, which Microsoft released under the MIT License. Pick from a cartridge menu; saves and autosave included. - **Bring your own story files.** Type LOAD at the menu to play a Z-machine game from your own computer. It stays in your browser; nothing is uploaded. ## Install ```sh npm install @brass-lantern/engine @brass-lantern/vue vue pinia ``` ## Three library packages, plus an MCP server | Package | What it is | |---|---| | [`@brass-lantern/engine`](https://www.npmjs.com/package/@brass-lantern/engine) | The game itself: parser, rules, saves and the Z-machine bridge. Runs anywhere, no DOM. | | [`@brass-lantern/vue`](https://www.npmjs.com/package/@brass-lantern/vue) | The CRT terminal, themes and `mountGame`, as a Vue 3 component. | | [`@brass-lantern/server`](https://www.npmjs.com/package/@brass-lantern/server) | Optional. An Express route that lets players type like people, through Gemini. | | [`@brass-lantern/mcp`](https://www.npmjs.com/package/@brass-lantern/mcp) | Optional. A local MCP server that lets a coding agent audit and play the world it’s writing ([Coding agents](https://mrballistic.github.io/brass-lantern/guide/coding-agents)). | ## A game on a page ```ts import { mountGame } from '@brass-lantern/vue'; import '@brass-lantern/vue/style.css'; import type { World } from '@brass-lantern/engine'; const world: World = { startRoom: 'study', rooms: { study: { name: 'The Study', description: 'A desk, a chair, and the dark.', exits: {}, items: ['lantern'], npcs: [], onEnter: [], }, }, items: { lantern: { name: 'brass lantern', aliases: ['lamp'], description: 'Unlit, for now.', portable: true, tags: [] }, }, npcs: {}, events: {}, dialogue: {}, flagLabels: {}, }; mountGame('#app', { cartridges: [{ kind: 'world', id: 'study', title: 'THE STUDY', world }], storagePrefix: 'study', }); ``` Then [build a real one](https://mrballistic.github.io/brass-lantern/guide/building-worlds/two-rooms), or read [Using the library](https://mrballistic.github.io/brass-lantern/guide/using-the-library) for the component, themes and options. ## Built with Brass Lantern **[Office Space: The Text Adventure](https://initech.mrballistic.com)** is a full-length game on this engine: four chapters from a very bad Monday at Initech to a field, a baseball bat and a printer that has it coming. It runs with the intent server, so you can type like a person. --- Source: https://mrballistic.github.io/brass-lantern/guide/getting-started # Getting started Brass Lantern is a small engine for classic parser text adventures (*go north*, *take lamp*, *give the mug to Gary*) that runs in the browser inside a CRT terminal. You write a game as data; the engine runs it. It also plays **Z-machine story files**, the format Infocom’s games shipped in, in the same terminal. It comes with five cartridges: **Snack Attack**, a three-room world that exists to show the engine off, **Zork I, II and III**, and **Zork I rebuilt natively** on the engine. [Play them](https://mrballistic.github.io/brass-lantern/demo/), then come back. For a full-length game built on the engine, try [Office Space: The Text Adventure](https://initech.mrballistic.com). ## Use it in your app Brass Lantern is on npm as three library packages (the engine, a Vue terminal and an optional intent server) plus an MCP server for coding agents. [Using the library](https://mrballistic.github.io/brass-lantern/guide/using-the-library) covers installing them, mounting a game, themes, running headless in Node and wiring the intent server. ```bash npm i @brass-lantern/engine @brass-lantern/vue vue pinia ``` ## Run the demo from the repo You need Node 24 or later. ```bash git clone https://github.com/mrballistic/brass-lantern.git cd brass-lantern npm install npm run dev # http://localhost:5173 ``` The terminal boots to the cartridge menu; type `1` for Snack Attack, `2`–`4` for Zork I–III, or `5` for the native Zork I. All are fully playable offline: the regex parser handles every command the engine knows, and Zork has its own parser. Loose phrasing (“make that thing stop beeping”) needs the optional [intent server](https://mrballistic.github.io/brass-lantern/guide/intent-server). ## What’s in the box ``` packages/ engine/ @brass-lantern/engine: the engine, the regex parser, fuzzy matching, line styling, createGame, auditWorld, the story-file runtime (zmachine/), and the bundled worlds (worlds/) vue/ @brass-lantern/vue: the terminal, the boot sequence, the game stores, themes, mountGame, and the CRT stylesheet server/ @brass-lantern/server: the intent server (parseIntent, intentRoute), plus the small Express app the repo runs (src/index.ts) apps/ site/ the demo: app.config.ts lists the cartridges, main.ts mounts the game, apps/site/public/stories/ holds the Zork trilogy and its licenses docs/ this site (npm run docs:dev) scripts/ the consumer smoke test, the glkapi vendoring ``` Each package has its own tests; `packages/engine/src/types/world.ts` is the world schema, with a comment on every field. ## Make it yours In your own app, the cartridges are just the `cartridges` you pass to `mountGame` ([Using the library](https://mrballistic.github.io/brass-lantern/guide/using-the-library)). In the repo, everything that makes the demo *a particular game collection* is in `apps/site/src/app.config.ts`: ```ts export const cartridges: Cartridge[] = [ { kind: 'world', id: 'snack-attack', title: 'SNACK ATTACK', world: tutorial }, { kind: 'zcode', id: 'zork1', title: 'ZORK I', story: 'stories/zork1.z3', format: 'Z-machine v3' }, { kind: 'zcode', id: 'zork2', title: 'ZORK II', story: 'stories/zork2.z3', format: 'Z-machine v3' }, { kind: 'zcode', id: 'zork3', title: 'ZORK III', story: 'stories/zork3.z3', format: 'Z-machine v3' }, { kind: 'world', id: 'zork1-native', title: 'ZORK I · NATIVE', world: zork1 }, ]; export const appName = 'BRASS LANTERN'; export const storagePrefix = 'brass-lantern'; ``` Add your own world as a cartridge (and remove the others if you like: with one cartridge there’s no menu). [Building worlds](https://mrballistic.github.io/brass-lantern/guide/building-worlds/) walks through writing one, from two rooms up; [Playing story files](https://mrballistic.github.io/brass-lantern/guide/z-machine) covers Z-machine cartridges. ## For coding agents The docs also come as plain markdown for agents and LLM tools. [llms.txt](https://mrballistic.github.io/brass-lantern/llms.txt) indexes every page, each page has a `.md` copy beside it, and [llms-full.txt](https://mrballistic.github.io/brass-lantern/llms-full.txt) is all of it in one file, with the example worlds’ code inlined. Point an agent at llms-full.txt and it has the schema, the conditions, the effects and the recipes in one read. There is also a local MCP server that lets an agent audit and play the world it is writing; [Coding agents](https://mrballistic.github.io/brass-lantern/guide/coding-agents) covers both. Working on Brass Lantern itself? [CONTRIBUTING.md](https://github.com/mrballistic/brass-lantern/blob/main/CONTRIBUTING.md) has the checks to run before a pull request. --- Source: https://mrballistic.github.io/brass-lantern/guide/using-the-library # Using the library Brass Lantern is three library packages, plus an MCP server for coding agents ([Coding agents](https://mrballistic.github.io/brass-lantern/guide/coding-agents)). Use the libraries to put a game in your own app, run one headless in Node, or add the intent server to a backend you already have. | Package | What it is | Needs | |---|---|---| | `@brass-lantern/engine` | Worlds as data, the parser, the turn loop (`createGame`), `auditWorld`, and the Z-machine runtime for story files. No Vue, no DOM. | Node 24+ (browsers and bundlers too) | | `@brass-lantern/vue` | The CRT terminal, the game store, themes and `mountGame`. | `vue` 3.5+, `pinia` 4 | | `@brass-lantern/server` | The intent server as `parseIntent` and, for Express, `intentRoute`. | A Gemini key; `express` 5 for the route | All three libraries are ESM with TypeScript declarations. On Node 24 and later every entry also loads with `require()` except one: `@brass-lantern/engine/zmachine/session` uses top-level `await` (to check for `ifvms` first), so `require()` throws `ERR_REQUIRE_ASYNC_MODULE`; load it with `import()`, which is how a browser app wants it anyway. TypeScript 5.0 or later works with `moduleResolution` set to `bundler`, `node16` or `nodenext`. The engine’s public types mention `fetch` and `Storage`, so your project needs the DOM lib or `@types/node`. Vue 3.5’s own type declarations need TypeScript 5.4 or later unless you set `skipLibCheck`, so a project using `@brass-lantern/vue` wants 5.4 or later anyway. ## Install ```bash npm i @brass-lantern/engine @brass-lantern/vue vue pinia ``` Add `@brass-lantern/server` (and `express`) on the machine that will run the intent server. Playing Z-machine story files in a browser app needs nothing extra: `@brass-lantern/vue` brings the interpreter ([ifvms](https://github.com/curiousdannii/ifvms.js)) with it. ## Put a game on a page `mountGame` makes a Vue app and a Pinia of its own and mounts one game on an element: ```ts import { mountGame } from '@brass-lantern/vue'; import '@brass-lantern/vue/style.css'; import { tutorial } from '@brass-lantern/engine/worlds'; mountGame('#app', { cartridges: [{ kind: 'world', id: 'snack-attack', title: 'SNACK ATTACK', world: tutorial }], storagePrefix: 'my-game', }); ``` The stylesheet is a separate import, so a bundler can see it. TypeScript 6 checks side-effect imports (`noUncheckedSideEffectImports`), so it needs to know what a `.css` import is: a Vite app gets that from `vite/client` in its `types`, and anything else can declare it in a `.d.ts` file with `declare module '*.css';`. It styles the game and nothing else on your page: no `html`, `body` or `*` rules, nothing fixed to the viewport. The game fills its container, so give the container a size. For a full-screen game: ```css html, body, #app { height: 100%; margin: 0; overflow: hidden; } ``` For a game inside a page, size its box (`#game { width: 640px; height: 400px; }`) and the terminal, its overlays and the boot sequence stay inside it. Pass `autofocus: false` to an embedded game so it doesn’t take the page’s focus, and scroll to itself, when it boots. Already have a Vue app? Use the component, with a Pinia installed: ```vue ``` That is most of the package: `BrassLantern`, `mountGame`, `ConsentBanner`, the themes (`PRESETS`, `PALETTES`, `resolveTheme`, the `UnknownTheme` error and their types), `useTypewriter`, and the `GameOptions` and `GameEvent` types, plus the cartridge types (`Cartridge`, `WorldCartridge`, `ZCodeCartridge`) re-exported from the engine so a Vue app can type its options from this package alone. The terminal’s parts (the terminal itself, the boot sequence, the stores) are internal, so they can change without a major version. ### Every option `GameOptions` is the only thing a game needs to be told: | Option | Meaning | |---|---| | `cartridges` | One or more worlds or story files. With one the terminal boots straight in; with more it shows the menu. Required. | | `storagePrefix` | Namespaces everything kept in the browser: saves, transcripts, the player’s theme and the story shelf (for example `my-game:save:snack-attack`). Required. | | `terminalName` | Shown in the header. Default `BRASS LANTERN`. | | `version` | Shown after the name in the header. | | `intentEndpoint` | Where misses go for the LLM’s reading: a URL that accepts `POST`. `null`, `''` or unset means none, and a miss gets the engine’s reply. | | `theme` | The author’s default: a preset name, the name of one of `themes`, or a custom theme object. The player’s `THEME` command wins. A name that isn’t a theme falls back to `crt-amber`, with a warning in the console. Default `crt-amber`. | | `themes` | Extra named themes, offered by `THEME` beside the presets. One named like a preset is ignored, with a warning. | | `analytics` | `{ onEvent(name, params?), openConsent?() }`. `onEvent` hears `game_start`, `game_completed` and `session_resumed`; a callback that throws is logged and never breaks the game. With `openConsent`, the header shows a COOKIES link and the command calls it; without it, COOKIES says nothing is collected. | | `storyBaseUrl` | Where a story cartridge’s relative `story` path is fetched from. A Vite app under a subpath passes `import.meta.env.BASE_URL`. Default `/`. | | `devChecks` | Turns on the engine’s script freeze, so a world script that assigns to game state throws instead of passing silently. Global to the page, and only ever turned on. A Vite app passes `import.meta.env.DEV`. Default `false`. | | `autofocus` | Focus the game’s input when it boots (which scrolls the page to it). An embedded game passes `false`; a click on the game still focuses it. Default `true`. | `mountGame(el, options, { slot })` takes a third argument for a component rendered inside the game’s shell once it has booted. The demo site uses it for its consent banner. `ConsentBanner` is exported, and it is presentational: it takes an `open` prop and emits `choose` with `'granted'` or `'denied'`, and your app decides what to store and what to send. Its wording is yours too: `title`, `body`, `note` and `label` (the section’s accessible name) are props, and the defaults are neutral and name no analytics provider, so pass your own to say what you collect and where it goes. ### Two games on one page Each game needs its own `storagePrefix`. The same prefix twice is the same game, with shared saves and shared stores. Remounting `` under the same Pinia keeps the options of the first mount, because Pinia caches the store by its id. To change the options of a running game, unmount it (the object `mountGame` returns has `unmount()`) and mount again. ## Themes The terminal ships five presets: | Name | Look | |---|---| | `crt-amber` | The default: amber phosphor, with every effect on. | | `crt-green` | Green phosphor, every effect on. | | `simple` | Plain text on a plain page, no effects. Follows the system’s light or dark setting. | | `simple-light` | The plain look, light. | | `simple-dark` | The plain look, dark. | A theme is a palette (twelve color roles) plus seven effect switches (`bloom`, `scanlines`, `flicker`, `vignette`, `noise`, `glitch`, `decay`). Write your own and offer it by name: ```ts import { mountGame, type Theme } from '@brass-lantern/vue'; // `cartridges` as in “Put a game on a page” above. const paper: Theme = { palette: { fg: '#2b2118', fgBright: '#000000', fgDim: '#7a6a58', glow: 'transparent', glowStrong: 'transparent', bg: '#f4efe3', border: '#d6cbb5', input: '#1d5fa8', location: '#000000', event: '#b3410b', decorative: '#6a3d9a', system: '#7a6a58', }, effects: { bloom: false, scanlines: false, flicker: false, vignette: false, noise: false, glitch: false, decay: true }, }; mountGame('#app', { cartridges, storagePrefix: 'my-game', theme: paper, themes: { paper } }); ``` `palette` can also be a name (`amber`, `green`, `light` or `dark`) to borrow a built-in one. `PALETTES`, `PRESETS` and `resolveTheme` are exported for tools that want the data. **Player commands.** `THEME` lists the themes; `THEME ` switches (a custom theme’s name can have spaces or underscores; the player types it as listed). `BLOOM ON|OFF` and `EFFECTS ON|OFF` turn the glow and all the effects on or off. They work everywhere: at the cartridge menu, in a native world and in a story file. The choice is remembered in the browser at `:theme`. `theme`, `bloom` and `effects` are reserved player commands, like SAVE: they are read as the command, and they shadow a world item with one of those names. **Reduced motion.** When the player’s system asks for reduced motion, flicker, glitch and noise are off whatever the theme says. ## Run a game with no browser The engine has no UI of its own. `createGame` runs a world in plain Node, which is how the tests play games and how you can build a bot, a CLI or a server-side game: ```ts import { createGame } from '@brass-lantern/engine'; import { tutorial } from '@brass-lantern/engine/worlds'; import { createInterface } from 'node:readline/promises'; const game = createGame(tutorial, { seed: 1 }); console.log(game.opening.join('\n')); const rl = createInterface({ input: process.stdin, output: process.stdout }); while (!game.state.gameOver) { const reply = game.send(await rl.question('> ')); console.log(reply.lines.join('\n')); } rl.close(); ``` `send(line)` takes whatever a player would type, including chained commands, and answers `{ lines, gameOver, awaiting }`. `awaiting` is true while the game waits for an answer to a question, such as “Which door do you mean?”, and that question is the last of `lines`. `seed` makes the game’s randomness repeatable. A command whose script throws is rolled back, and the reply is “[Something went wrong with that command. Nothing changed.]”. A line over `MAX_INPUT_LENGTH` (1,000 characters once whitespace is collapsed) changes nothing and hears `TOO_LONG_REPLY`. `createGame` handles RESTART (a fresh game, with the same `seed`) and UNDO (one turn at a time, up to 50) itself; `game.state` is always the game now, so read it after `send` rather than keeping the object. VERSION names the engine (`ENGINE_VERSION`) and the world. There is nowhere to keep a save or a transcript in plain Node, so SAVE, RESTORE, LOAD, SCRIPT and UNSCRIPT say they aren’t available and change nothing; a host that can store them handles those words before calling `send`. HELP lists only the commands that work here. `createGame` has no LLM: input the parser can’t read gets the engine’s own reply. `currentScore(world, game.state)` is the score SCORE reports: the world’s `scoring` entries that hold, plus its `score` variable. ### Your own loop `createGame` and the Vue terminal do the splitting, parsing and running for you. If you drive the lower-level pieces yourself (`splitCommands`, `fallbackParse`, `execute`), pass `splitCommands` the world’s names that contain “and”, or it will cut “take flair from lost and found” in two. Only names and aliases count: “lost and found” stays whole if it’s an item’s or a character’s name or one of its aliases, not if it only appears in a description. `andNames(world)` builds that list; work it out once per world, not once per line: ```ts import { andNames, execute, fallbackParse, initialState, splitCommands } from '@brass-lantern/engine'; const names = andNames(world); // e.g. ['lost and found box', 'lost and found'], from item and character names and aliases const state = initialState(world); function run(line: string): string[] { const out: string[] = []; for (const command of splitCommands(line, world.verbs, names)) { const action = fallbackParse(command, world.verbs); out.push(...(action ? execute(action, { world, state }).lines : ['I didn’t understand that.'])); } return out; } ``` ```ts splitCommands('take flair from lost and found and go north', world.verbs, names); // ['take flair from lost and found', 'go north'] splitCommands('take flair from lost and found and go north', world.verbs); // ['take flair from lost', 'take found', 'go north'] ``` `names` is optional, and a world with no such names gives an empty list, so leaving it out changes nothing for those worlds. That loop leaves out what `createGame` adds on top (questions and their answers, AGAIN, OOPS, UNDO, captures), so prefer `createGame` unless you need the pieces. ## The intent server Loose phrasing (“make that thing stop beeping”) can go to an LLM that maps it onto your verbs and IDs. It only classifies. See [The intent server](https://mrballistic.github.io/brass-lantern/guide/intent-server) for the protocol; here is the wiring. On the server, with Express 5: ```ts import express from 'express'; import { intentRoute } from '@brass-lantern/server/express'; const app = express(); app.set('trust proxy', 'loopback'); // behind a reverse proxy on the same machine app.use(express.json({ limit: '32kb' })); app.use('/api', intentRoute({ apiKey: process.env.GEMINI_KEY! })); app.listen(3001); ``` That serves `POST /api/parse-intent`, with a per-client rate limit. Pass `models`, `timeoutMs` or `rateLimitPerMinute` to change the defaults. An empty `models` list means the defaults, here and in `parseIntent`; `rateLimitPerMinute` must be a positive number, or `intentRoute` throws when it is built, as it does for an empty key. You own `express.json()` and `trust proxy`. Behind Apache or nginx, set `trust proxy` as above, or the rate limit sees every request coming from the proxy and all your players share one bucket; with nothing in front, leave it unset. The route sets no CORS headers, on purpose: other sites should not be able to spend your quota from their visitors’ browsers. In the browser: ```ts mountGame('#app', { cartridges, storagePrefix: 'my-game', intentEndpoint: '/api/parse-intent' }); ``` Not on Express? `parseIntent` is the same thing as a function: ```ts import { parseIntent } from '@brass-lantern/server'; const action = await parseIntent('hand gary his mug', context, { apiKey, timeoutMs: 5000 }); // { action: 'give', target: 'mug', indirect: 'gary' }, or { action: 'unknown' } ``` `context` is an `IntentContext`: what the player can see, by ID and name (the room’s name, its exits, the items and people in view, the inventory, and the world’s own verbs). The model answers only with IDs from it. [The intent server](https://mrballistic.github.io/brass-lantern/guide/intent-server#the-api) shows its shape. The Vue terminal builds and sends it for you. A UI that runs its own loop on the lower-level engine (`execute`, which reports `understood: false` on a miss) can build it with `buildContext` from `@brass-lantern/engine` and post it with `parseIntentRemote`. Neither reads the environment: you pass the key in. Keep it on the server. Never give it a `VITE_` name, because Vite inlines those into the public bundle; the browser talks to your route and never to Google. ## Write a world and check it A world is a typed object, `World`, from `@brass-lantern/engine`. [Building worlds](https://mrballistic.github.io/brass-lantern/guide/building-worlds/) walks through writing one, and the [schema](https://mrballistic.github.io/brass-lantern/reference/world-schema) lists every field. Check it before you play it: ```ts import { auditWorld, createGame, type World } from '@brass-lantern/engine'; const world: World = { /* rooms, items, events … */ } as World; const problems = auditWorld(world); // string[]: broken references: exits to nowhere, missing events, unknown items if (problems.length) throw new Error(problems.join('\n')); const game = createGame(world, { seed: 1 }); game.send('look'); ``` `auditWorld` finds the mistakes that only show up when someone walks into them: exits that go nowhere, events nothing defines, references to items that do not exist. Put it in your test suite beside a test that plays the winning route. Worlds written for 1.13.0 or later run on 2.1.0. Five things behave differently: a typed word the world doesn’t know now stops a name from matching (“red ball” no longer finds a thing called only “ball”; add an alias, see [How names are matched](https://mrballistic.github.io/brass-lantern/guide/building-worlds/#how-names-are-matched)); EXAMINE of a character in the dark says it’s too dark; `ctx.exits` hides denied exits; `go: true` verbs meet `instead.go` rules; and LIGHT on something already burning says it’s already lit. The CHANGELOG’s 2.1.0 Changed section has the details. 2.1.0 adds an optional `article` on characters and the built-in FOLLOW; a world that declared its own `follow` verb should drop it (`auditWorld` reports the clash). ## Story files (Z-machine) A story file is a cartridge too: ```ts { kind: 'zcode', id: 'zork1', title: 'ZORK I', story: 'stories/zork1.z3', format: 'Z-machine v3' } ``` The file is fetched from `storyBaseUrl` plus `story`, so host it with your app. See [Playing story files](https://mrballistic.github.io/brass-lantern/guide/z-machine). The player can also LOAD one from their own computer, which stays in their browser. The interpreter is `ifvms`, an optional peer of the engine. `@brass-lantern/vue` depends on it, so a browser app needs nothing more, and it loads the interpreter only when a story starts: a bundler puts it in a chunk of its own, and a game with only native worlds never downloads it. Using the runtime yourself, the session is its own entry, so you can do the same: ```ts import { SaveStoreDialog, localStorageSaveStore } from '@brass-lantern/engine/zmachine'; const { ZMachineSession } = await import('@brass-lantern/engine/zmachine/session'); const session = new ZMachineSession(storyBytes, new SaveStoreDialog(localStorageSaveStore('my-game:')), { onLines: (lines) => print(lines), onStatus() {}, onWaiting() {}, onExit() {}, onError: (m) => print([m]), }); session.start(); ``` `@brass-lantern/engine/zmachine` holds everything but the interpreter (the story shelf, `readStoryFile`, the save stores and the types) and never needs `ifvms`. In Node, install `ifvms` yourself to use `./zmachine/session`; without it the import fails with an error that names the package. Saves go through a `SaveStore` (`list`, `read`, `write`, `remove`), wrapped in a `SaveStoreDialog`. In the browser that is `localStorageSaveStore(prefix)`; a Node host can pass a directory, a database or a `Map`. ## Examples The [demo](https://mrballistic.github.io/brass-lantern/demo/) is built from these packages: in the repo, `apps/site` lists the cartridges, mounts the game and wires its analytics to `ConsentBanner`. It isn’t published to npm. [Office Space: The Text Adventure](https://initech.mrballistic.com) is a full-length game built on the engine. --- Source: https://mrballistic.github.io/brass-lantern/guide/coding-agents # Coding agents Two ways to give a coding agent (Claude Code, Cursor, Codex and the like) what it needs to build a Brass Lantern world: the docs as one file, and a local MCP server that can also audit and play the world it writes. ## The docs as one file The site publishes its documentation as markdown, built from the same pages you are reading. - [llms.txt](https://mrballistic.github.io/brass-lantern/llms.txt) is the index: a one-line summary and a link to every page. Each page also has a `.md` copy beside it (add `.md` to its address). Use this when the agent should fetch only the pages it needs. - [llms-full.txt](https://mrballistic.github.io/brass-lantern/llms-full.txt) is every page in one file, with the example worlds’ code inlined. Use this when the agent can take it all in one read: it then has the schema, the conditions, the effects and the recipes together. ## The MCP server `@brass-lantern/mcp` is a local [Model Context Protocol](https://modelcontextprotocol.io) server. It lets an agent close the loop on a world it is writing: **write** the world file, **audit** it, **play** a few commands to see how it actually reads, **fix** what turned up, and repeat. It also serves this version’s docs, so the agent reads the schema that matches the engine. ### Install In Claude Code: ```bash claude mcp add brass-lantern -- npx -y @brass-lantern/mcp ``` For any client that takes JSON config: ```json { "mcpServers": { "brass-lantern": { "command": "npx", "args": ["-y", "@brass-lantern/mcp"] } } } ``` Node 24 or later. World paths in tool calls are relative to the directory the client starts the server in (normally your project), or absolute. Worlds must be inside that directory or one you allow with `--root `, which you can repeat and which is relative to the same directory: ```bash claude mcp add brass-lantern -- npx -y @brass-lantern/mcp --root ../shared-worlds ``` ### The tools **`audit_world`** checks a world file the way the engine’s `auditWorld` does: exits that go nowhere, conditions that don’t parse, effects naming things that don’t exist, unmapped flag labels and more. Inputs: `path` (a `.ts`, `.mts`, `.js` or `.mjs` module) and optionally `export` (which export is the world; by default the default export, else the only world-shaped one). ```json { "path": "src/worlds/office.ts" } ``` ```text World: default in /project/src/worlds/office.ts Engine: @brass-lantern/engine 2.3.0 (the project’s) Size: 1 room, 0 items, 0 characters, 0 events 1 problem: - room hall: exit north goes nowhere (“nowhere”) ``` **`play`** starts a fresh game and types each command as a player would, with no LLM. It is stateless: every call begins at the start, and the game is seeded (default seed 1, or pass `seed`) so a run repeats exactly. To continue a session, send the whole command list again. At most 500 commands run per call, and a transcript over 2,000 lines keeps its opening and its last 200 lines, with a note where the middle was cut. The game stops when it ends. Inputs: `path`, optionally `export`, `commands` (the lines to type, in order) and optionally `seed`. ```json { "path": "src/worlds/office.ts", "commands": ["open drawer", "take key", "north"] } ``` ````text World: default in /project/src/worlds/office.ts Engine: @brass-lantern/engine 2.3.0 (the project’s) Seed: 1 …the opening… > open drawer You open the drawer. Inside is a brass key. > take key Taken. > pull the lever with feeling [not understood by the parser: “pull the lever with feeling”. With the intent server running, an LLM would get a chance to read it.] … Final state: ```json { "room": "Lobby (lobby)", "inventory": ["brass key"], "flags": [], "score": 0, "moves": 2, "gameOver": false } ```` The “not understood by the parser” note marks input the regex parser can’t read. A real player with the [intent server](./intent-server) would have it sent to the LLM; here nothing is, so treat the note as “this phrasing needs a verb, an alias or a rule” rather than as a bug. The final state includes `score`, from the engine’s `currentScore`. Notes (such as “Game over after 3 of 5 commands.”) come in a blank-line-separated block just before “Final state:”. Two things depend on the engine in use: a project engine older than 2.3.0 has no `currentScore`, so `score` comes from the world’s `score` variable, or is `null` if the world keeps none; and an engine without the parser helpers skips the “not understood” note. Neither is a fault in the world. **`docs`** serves the documentation bundled with the server. With no arguments it returns the index of pages; with `page` (such as `reference/world-schema`) one page’s markdown; with `query` (such as `timers fuses`) the best-matching sections. If you give both, `page` wins. ```json { "query": "timers fuses" } ``` ```text …the best-matching sections, each with its page path… ``` ### Which engine The server audits and plays a world with the engine the world’s own project uses, so a project on 2.2 is checked by 2.2’s rules. It looks for `@brass-lantern/engine` from the world file. If the project has none, the server uses the engine bundled with it, and the reply says “bundled with the MCP server” without further comment. If the project’s engine is found but can’t be used (it can’t be resolved or fails to import, or lacks `createGame` or `auditWorld`), the server falls back to the bundled one and adds a note saying why. Every reply states the engine version and whose it is, so you always know what you were checked against. A world file is re-read on every call, so an edit shows on the next one: each `audit_world` and `play` call runs in a fresh worker thread, which loads the world, the project files it imports and the packages it uses from scratch. ### Safety and limits The server **runs the world module it is pointed at**, with your permissions, the same way your test runner runs your tests. Point it at code you trust. It speaks only over local stdio and makes no network calls of its own. A world file is only loaded when it is inside the directory the server started in or one you passed with `--root`, compared after resolving symlinks; anything else is refused with a message naming `--root`. That check confines which world file is loaded, not what the module then imports or does: any module inside an allowed directory can be run. It is not a sandbox. Each `audit_world` and `play` call runs in its own worker thread, with a deadline: - A world that takes longer than 10 seconds (a script that loops forever, say) is stopped, and the call returns an error saying so. Start the server with `--timeout ` to allow longer. - A world that calls `process.exit()` ends only its worker; the call returns an error, and the server keeps answering. So does a world that crashes. - Whatever a world writes to stdout (`console.log`, `process.stdout.write`, its own `Console`) goes to the server’s stderr, so it can’t break the protocol. The worker is not a sandbox either: it runs with the server’s permissions, and a world that writes straight to file descriptor 1 (`fs.writeSync(1, …)`) still reaches the protocol stream. In a worker `process.chdir()` and `process.abort()` throw, but `process.kill(process.pid)` still ends the whole server, and there is no memory limit per world. --- Source: https://mrballistic.github.io/brass-lantern/guide/z-machine # Playing story files Brass Lantern also runs **Z-machine story files**, the format Infocom’s games shipped in. The [demo](https://mrballistic.github.io/brass-lantern/demo/) includes the **Zork trilogy** (Zork I Release 119, Zork II Release 63, Zork III Release 25), which Microsoft released under the MIT License in 2025. It plays exactly as Infocom wrote it, in the same CRT terminal as native worlds. The npm packages bring the interpreter, not the games: see [Cartridges](#cartridges) for adding story files to your own app. ## Playing Zork Open the [demo](https://mrballistic.github.io/brass-lantern/demo/) and type `2` for Zork I. You start west of a white house, with a mailbox. The rest is up to you. Zork II (`3`) and Zork III (`4`) pick up where it leaves off, each opening with a brass lantern close by. Each game stands alone; nothing carries over between them. If you’ve never played it, Zork’s parser is older and stricter than Brass Lantern’s: | Command | Notes | |---|---| | `N` `S` `E` `W` `NE` `NW` `SE` `SW` `UP` `DOWN` | Directions. Diagonals matter: the house is circled by them. | | `LOOK` (`L`), `EXAMINE` *thing* | `X` isn’t a word in the Zork games; spell out EXAMINE. | | `TAKE` *thing*, `TAKE ALL`, `DROP` *thing* | Also `PUT` *thing* `IN` *thing*. | | `INVENTORY` (`I`) | | | `OPEN`, `READ`, `MOVE`, `TURN ON`, `ATTACK` *creature* `WITH` *weapon* | The verbs you’ll need most. | | `AGAIN` (`G`) | Repeats your last command. | | `SCORE`, `DIAGNOSE` | Your score, and how hurt you are. | | `VERBOSE`, `BRIEF` | Full room descriptions every time, or only on your first visit. | | `SAVE`, `RESTORE` | See [Saving](#saving). SAVE before anything risky. | | `EJECT` | Back to the cartridge menu. The game autosaves, so it’s there when you come back. | Brass Lantern’s own command splitting, the intent server and HINT don’t apply here. Zork’s parser chains commands itself (`take lamp and sword`, `north. open door`), but it has no hints, and it’s more fun that way. Map as you go. ## Cartridges A story file is a cartridge, listed in `GameOptions.cartridges` beside any native worlds. The demo’s list: ```ts import { mountGame } from '@brass-lantern/vue'; import { tutorial, zork1 } from '@brass-lantern/engine/worlds'; mountGame('#app', { cartridges: [ { kind: 'world', id: 'snack-attack', title: 'SNACK ATTACK', world: tutorial }, { kind: 'zcode', id: 'zork1', title: 'ZORK I', story: 'stories/zork1.z3', format: 'Z-machine v3' }, { kind: 'zcode', id: 'zork2', title: 'ZORK II', story: 'stories/zork2.z3', format: 'Z-machine v3' }, { kind: 'zcode', id: 'zork3', title: 'ZORK III', story: 'stories/zork3.z3', format: 'Z-machine v3' }, { kind: 'world', id: 'zork1-native', title: 'ZORK I · NATIVE', world: zork1 }, ], storagePrefix: 'my-game', }); ``` - **With more than one cartridge**, the terminal shows a menu after it boots. Type a number to insert one; EJECT brings you back. - **With one cartridge**, it boots straight in, with no menu. - **After a reload**, the last cartridge you played comes straight back if it has a game in progress. **Story files aren’t in the npm packages.** Host them with your app, as static files: `story` is fetched from `storyBaseUrl` (default `/`) plus that path, so a Vite app puts `zork1.z3` in `public/stories/`, and one served from a subpath passes `storyBaseUrl: import.meta.env.BASE_URL`. Story files are binary and fetched on demand, and the interpreter is a separate chunk that loads only when a story cartridge is inserted, so a build that offers only native worlds never downloads it. **Getting Zork I, II and III.** Microsoft released them under the MIT License in 2025, in the `zork1`, `zork2` and `zork3` repositories at [historicalsource](https://github.com/historicalsource). The three files the demo plays are also in the Brass Lantern repo, in [`apps/site/public/stories/`](https://github.com/mrballistic/brass-lantern/tree/main/apps/site/public/stories), each beside its license (`LICENSE-zork1.txt` and so on). The MIT License asks that its text travel with the files, so publish each license beside its story. ## Playing your own story files Got a story file? Type **LOAD** at the cartridge menu and pick it, or drag it onto the terminal. It plays straight away, and joins the menu, marked *yours*, for next time. - **It never leaves your browser.** The file goes on a shelf in IndexedDB; nothing is uploaded, and the site never hosts it. That’s what makes this the way to play games you own but nobody may redistribute, like Infocom’s other titles from the *Lost Treasures* and *Masterpieces* collections. - **Accepted:** Z-machine versions 3, 4, 5 and 8, raw (`.z3`, `.z4`, `.z5`, `.z8`) or in a Blorb (`.zblorb`, `.zlb`). Glulx games (`.ulx`, `.gblorb`) and version 6 (*Zork Zero*, *Arthur*, *Shogun*, *Journey*) aren’t supported, and LOAD says so. - **Saving works like any cartridge:** SAVE, RESTORE, autosave, and resume on reload. - **REMOVE and its number** takes a story off the shelf. Its saved games stay, so loading it again picks up where you were. - **Loading the same file twice** keeps one copy; the cartridge is identified by the story’s release, serial number and checksum, not its file name. The title in the menu comes from the file name. - **If the browser won’t store it** (private browsing, full storage), it still plays, until the next reload. - **Only in builds with a menu.** A single-cartridge build boots straight into its game and has no LOAD. ## Finding more story files - **The [IF Archive](https://www.ifarchive.org/indexes/if-archive/games/zcode/)** holds thousands of Z-machine games, from the 1980s to this year. Files end in `.z3`, `.z5` or `.z8`; LOAD plays them. - **Infocom’s other games** are still under copyright, except Zork I, II and III, which Microsoft released under the MIT License in 2025 ([historicalsource](https://github.com/historicalsource)). Those three ship with the demo (see [Cartridges](#cartridges) for where to get them); the rest can’t be redistributed. - **Check the license before you publish one.** Playing a story file locally is one thing; putting it on a public site is redistribution. Many IF Archive games allow it; some don’t. - **Writing your own:** [Inform 6](https://www.inform-fiction.org/) and [ZIL](https://foss.heptapod.net/zilf/zilf) (Infocom’s own language) both compile to the Z-machine. ## Saving - **SAVE** asks for a name, and **RESTORE** lists the saves you have; type CANCEL to back out. Saves live in your browser’s localStorage, per story. - **Autosave:** the game also saves itself every turn, so a reload picks up exactly where you were. - **When the story ends** (QUIT, or a final death), its autosave is cleared. Type PLAY to start again. ## How it works - **ifvms** ([MIT](https://github.com/curiousdannii/ifvms.js)), the Z-machine inside Parchment, runs the story. - It talks to the screen through **Glk**, a standard interface for interactive fiction. `@brass-lantern/engine` includes a modified copy of glkapi.js, wrapped so each game gets its own instance. - **`BrowserGlkOte`** turns the game’s screen updates into terminal lines, and its status line into the header. - **`SaveStoreDialog`** stores saves and autosaves in any `SaveStore`: browser storage in the Vue terminal, a `Map`, a directory or a database in a Node host. The game’s own echo of your command, and its `>` prompt, are dropped, since the terminal draws its own. ## Limits - **SCRIPT** (a transcript file) isn’t supported; it says so and the game carries on. - **Full browser storage** (or any store that refuses a write) makes SAVE say “There’s no room to save that.”, rather than claiming success. - **A story that won’t download** within 20 seconds shows an error; reload to try again, or EJECT. - **Formats:** Z-machine versions 3, 4, 5 and 8 (what ifvms supports). No Glulx. - **Windows:** only the status line from the upper window is shown, so games that draw menus or quote boxes there lose them. - **No graphics, sound or timed input.** - **Loose phrasing:** the intent server doesn’t help with story files, which have their own parsers. --- Source: https://mrballistic.github.io/brass-lantern/guide/porting-zork # Porting Zork Brass Lantern ships Zork I twice: the original story file, run by the Z-machine interpreter, and **ZORK I · NATIVE**, a rebuild as an ordinary Brass Lantern world (`packages/engine/src/worlds/zork1.ts`). The native version is how the engine proves it can carry an Infocom-class game. A test plays both side by side and fails if they disagree. ## How ZIL maps to a world Zork was written in ZIL, Infocom’s language. Its source is MIT licensed ([historicalsource/zork1](https://github.com/historicalsource/zork1)), and most of it translates directly: | ZIL | Brass Lantern | |---|---| | `` with `LDESC` | a room with `description` | | `` with an `M-LOOK` routine | `descriptions: [{ if, text }]`, the first that holds | | `(NORTH TO X)` | `exits: { north: 'x' }` | | `(EAST "The door is boarded…")` | `exits: { east: { denial: '…' } }` | | `(WEST TO KITCHEN IF KITCHEN-WINDOW IS OPEN)` | `exits: { west: { to: 'kitchen', door: 'kitchen_window' } }` | | `` | an item | | `CONTBIT`, `OPENBIT`, `TRANSBIT` | `container: { openable, open, transparent }` | | `SURFACEBIT` | `surface: true` | | `NDESCBIT` | `scenery: true` | | `LOCAL-GLOBALS` and a room’s `GLOBAL` list | `room.scenery: [...]` | | `FDESC` / `LDESC` on an object | `initialDescription` / `roomDescription` | | `TEXT` | `text` (READ) | | `LIGHTBIT` | `switchable: true, light: true` | | an object’s `ACTION` routine | `instead` and `after` rules | | a room’s `VALUE` | an `onEnter` event that sets a scored flag | | a verb only some objects understand (MOVE, COUNT) | a [world verb](https://mrballistic.github.io/brass-lantern/reference/world-schema#world-verbs) | | a room without `ONBIT` | `dark: true` | | `LIT?` | the engine’s light check (a `light` item switched on, in sight) | | an interrupt (`QUEUE`, `ENABLE`) | a fuse (`schedule`) or a daemon. Zork’s clock counts a `QUEUE X n` from a command down that same turn and the engine’s from the next, so it becomes `in: n-1`; an interrupt requeuing itself stays `in: n` | | `I-LANTERN` and `LAMP-TABLE` | a `lamp_fuel` variable and daemons that warn at its thresholds | | `JIGS-UP` | the `die` effect and `world.death` | | `PROB` | the `chance` effect | | a room’s `M-ENTER` | `onEnter`, which Infocom style runs before the description | | a `PER` exit routine with several refusals (the chimney) | an exit’s `denials` | | the parser’s ORPHAN and WHICH-PRINT (“Which door do you mean, the wooden door or the trap door?”) | built in: a question, answered by the next line | | the parser’s OOPS and AGAIN | built in | | an object with no `TEXT` (EXAMINE lists a container’s contents) | `description: ''` | | `ACTORBIT` objects | characters (`npcs`), with places, things they hold (`holds`) and descriptions by state | | `VILLAINS`, `HERO-BLOW`, `VILLAIN-BLOW`, the melee tables | a character’s `combat` block and `world.combat` (the tables are the engine’s) | | a villain’s ACTION modes (`F-DEAD`, `F-UNCONSCIOUS`, `F-CONSCIOUS`, `F-BUSY?`, `F-FIRST?`) | `onDeath`, `onUnconscious`, `onWake`, `onBusy`, `firstStrike` | | `SIZE`, `CAPACITY`, `LOAD-ALLOWED`, `FUMBLE-NUMBER` | `size`, `container.weight`, `carry` | | `V-DIAGNOSE`, `I-CURE` | DIAGNOSE and healing, built in | | an `ACTION` routine data can’t express (`I-SWORD`, the troll catching what you throw) | a [script](https://mrballistic.github.io/brass-lantern/reference/world-schema#scripts) | | a `PER` exit routine with a side effect (`UP-CHIMNEY-FUNCTION`) | an exit’s `then` event | | the parser’s GWIM for ATTACK (“(with the sword)”) | built in, Infocom style | | `TVALUE` (points while a treasure is in the case) | a score entry with a condition: `{ if: 'inside:painting:trophy_case', points: 6 }` | | `TVALUE` as the thief reads it (what’s worth stealing) | `treasure: 6` on the item, read by `ctx.treasure` | | `INVISIBLE` on a character (the thief, lurking) | `hidden: true`, or `{ npcState, hidden }` to change it | | `INVISIBLE` on an object (the stiletto until the thief dies) | the `hide` and `reveal` effects | | `SACREDBIT`, `MAZEBIT` | room `tags: ['sacred']`, `tags: ['maze']`, read by `ctx.tags` | | `I-THIEF` and `ROBBER-FUNCTION` | a daemon running a script (`thief_turn`), with combat hooks and rules on the thief | | the cyclops’s daemon and `CYCLOWRATH` | a daemon script and a variable | | `ASK`/`TELL … ABOUT` and orders (“thief, give me the bag”) | `topics`, `refuseOrder` and `instead.order` on a character | | an object `ACTION` that prints and returns false, so the verb goes on | an `instead` rule with `continue: true` | | `JIGS-UP` resetting things (the trap door) | `death.then` | | a raw-input loop in a room’s M-ENTER (the Loud Room) | a room’s `capture`, with `{ free: true }` replies | | a room’s M-END | the room’s `onEnd` | | V-WAIT’s three turns of the clock | `world.wait: { turns: 3 }` | | `FCLEAR … TOUCHBIT` on a room | `{ unvisit: 'room' }` | | `FSET … NDESCBIT` in play (the tied rope) | `{ unlist }` and `{ relist }` | | `GOTO room <>` (no description: the mirror) | `{ go: 'room', quiet: true }` | | `PICK-ONE` (no repeats until all are used) | a script: `pickOne` in `packages/engine/src/worlds/zork1.ts` | | `PROB n m` (ZPROB: worse odds once LUCKY is false) | a script checking the `unlucky` flag | | PRSI’s routine before PRSO’s | the rule order in Infocom style | | `SACREDBIT` on an object (the platinum bar) | the item tag `sacred`, which the thief script respects | | JIGS-UP’s branches: Hades once you’ve seen the Altar, dying while dead, “Bad luck, huh?” | `death.variants`, `death.instead`, a conditional `death.message` line | | `ALWAYS-LIT` for a spirit | `darkness.litIf` | | DEAD-FUNCTION | a world `capture` while `flag:dead` | | BURN (LIGHT … WITH) and FLAMEBIT/BURNBIT | the BURN verb, `flaming` and `burnable` | | VEHBIT and VTYPE (the magic boat) | an item’s `vehicle: { travels: 'water' }`, BOARD and DISEMBARK | | NONLANDBIT on a room (the river, the reservoir at high tide) | the room’s `water` (a condition for the reservoir) | | a vehicle’s M-BEG and M-END | its rules (asked before the room’s) and its `onEnd`; Zork’s RBOAT M-BEG is in the world capture | | I-RIVER, RIVER-SPEEDS, RIVER-LAUNCH | a self-requeuing fuse and LAUNCH’s table, in scripts | | GOTO’s grue from one dark room into another | `darkness.stumble` | | PRINT-CONT’s “(outside the magic boat)” and first-seen-first order | built in, Infocom style | | EMPTY-HANDED and WEIGHT (the narrow passage: nothing heavier than 4) | an exit’s `if: 'heaviest<=4'` | | a room’s M-ENTER that acts before you look (the bat’s FLY-ME) | `onEnter` with `{ look: true }` first, then a script that picks a room | | a room’s M-END that checks the turn’s command (BOOM-ROOM) | the room’s `onEnd` running a script that reads `ctx.command` | | two objects standing for one thing at two places (the basket on its chain) | two items swapped by the RAISE and LOWER rules; things inside travel with the real one | | NO-OBJS’ LIGHT-SHAFT score on the first lit turn | the room’s `onEnd` setting a flag that `scoring` counts | | HACK-HACK’s random endings (V-WAVE, V-RAISE) | a world verb’s fixed `reply`, naming its object with `{target}` | | SCORE-UPD’s win at 350 (the whisper, the map, the secret path) | a daemon on `score>=350 & !flag:won` | | FINISH (the score, then RESTART/RESTORE/QUIT) | an `endings` entry with `score: true` and a footer | | GOTO’s “You have moved into a dark place.” | `darkness.arrive` | | TOUCHBIT set by OPEN, ROB and the like | built in for OPEN (Infocom style); the `{ touch }` step elsewhere | | the parser’s GWIM for a missing tool (“(with the shovel)”) | a script picking the one TOOLBIT thing held | | MOVE putting things first in a room (newest-first listings) | built in for things; a character who arrived this turn is listed first | `style: 'infocom'` makes the engine follow Zork’s conventions: - “There is a sword here.”; - brief descriptions of rooms you’ve seen; - newest-first listings; - Zork’s SCORE line and status line (“West of House Score: 0 Moves: 0”); - Zork’s questions (“What do you want to take?”) and TAKE ALL (“lamp: Taken.”, including what can’t be taken, and why). ## The differential test `packages/engine/tests/worlds/zork1-diff.test.ts` runs a walkthrough through both versions and compares every reply, ignoring case, spacing and quote style. A mismatch prints both sides: ``` > open sack native: Opening the brown sack reveals a lunch, and a clove of garlic. original: Opening the brown sack reveals a clove of garlic, and a lunch. ``` - **The walkthrough** covers the early slices: the house and forest, the cellar and the gallery with the lamp lit, the parser (a bare TAKE and its answer, “Which door do you mean?”, AGAIN, OOPS, TAKE ALL and DROP ALL), the troll, the maze, and the cyclops (ULYSSES). - **Scripted sessions** (`packages/engine/tests/worlds/zork1-sessions.test.ts`) check every puzzle: each is a short command list played from the Round Room in both versions and compared reply by reply, its wrong orders and side branches included (the dam, the leak, the Loud Room, the mirrors, the rope, the exorcism, the candles, ghost mode). **Both sides are seeded**: a test-only option seeds the interpreter’s own generator (`new ZMachineSession(…, { seed })`), so each session plays the same way every time, with a seed pinned per side where the thief stays away. - **Fights are random,** and our dice aren’t Zork’s, so the walkthrough has **sync points**: walking into the Troll Room, each side retries until the troll doesn’t strike first, and at the fight each side attacks until its troll is dead. The original retries by starting its session again, the native port by trying another seed. The thief wanders at random from the first move, so either side starts over on the next seed whenever he shows up. - **`packages/engine/tests/worlds/zork1-fight.test.ts`** checks the fight itself: it fights the real troll 300 times, collects everything it prints, and requires every line of 200 native fights to be one the original prints. It found a difference between Zork’s source and its story file: the source gives a knocked-out player’s foes extra rounds, and Release 119 doesn’t. The story file wins. - **`packages/engine/tests/worlds/zork1-thief.test.ts`** does the same for the thief: 60 original sessions wait in the dark cellar with a treasure until he comes, and every line 100 native runs print must be one the original prints. - **The whole game** (`packages/engine/tests/worlds/zork1-full.ts`, adapted from the walkthrough Microsoft’s Jericho project ships for Zork I) runs natively from the first move to the barrow on a pinned seed, and must end with every treasure in the case, 350 points, the whisper, the map’s text and the closing text. **`packages/engine/tests/worlds/zork1-chapters.test.ts`** compares it with the original in nine chapters: for chapter *k* both sides replay chapters 1 to *k* and compare chapter *k* reply by reply, fights being sync points (`@fight with `). Each chapter has a seed pair on which the two sides agree; none falls back to line sets. - **`packages/engine/tests/worlds/zork1-thief-timing.test.ts`** measures the thief: 40 seeds a side wait in the Round Room until he first shows, and the medians must agree within 25% (measured: 17 turns each). - **The room order is read from the story file.** `packages/engine/tests/helpers/zobjects.ts` decodes `zork1.z3`’s object tree, and the native world’s rooms must follow it, since the thief walks rooms in that order. - **Expected differences** go in `packages/engine/tests/worlds/zork1-allowlist.ts`, each with a reason, and the test fails if one stops being different. Two kinds: a misspelled word, where Zork says “I don’t know the word” and the native engine says it sees no such thing (and, in the app, asks the intent server; the OOPS that follows it matches); and replies after the fight that depend on how it went (DIAGNOSE, SCORE’s move count). - **Random lines** the original prints (the distant songbird) are filtered out: the engine’s generator is seeded and reproducible, but it can’t replay Zork’s own. Random outcomes (the grue, where things scatter when you die) are pinned by seeded unit tests instead, and the death texts are checked against a real death in the original. --- Source: https://mrballistic.github.io/brass-lantern/guide/how-it-works # How it works Three parts, kept deliberately apart, and shipped as three library packages ([Using the library](https://mrballistic.github.io/brass-lantern/guide/using-the-library)), plus an MCP server for coding agents ([Coding agents](https://mrballistic.github.io/brass-lantern/guide/coding-agents)): the engine and parser are `@brass-lantern/engine`, the terminal and stores are `@brass-lantern/vue`, and the intent server is `@brass-lantern/server`. The engine imports no Vue, Pinia or browser globals, so the same turn loop runs in a browser, in Node (`createGame`) and in tests. This page is a tour of the internals. File paths are in the [repo](https://github.com/mrballistic/brass-lantern), for readers of the source; nothing here is needed to use the packages. 1. **The engine** (`packages/engine/src/engine/`) is a deterministic state machine. It never knows which game it’s running: everything specific to a world, including its rules, is data. 2. **The parser** turns what the player typed into `{ action, target, indirect }`. A regex parser in the browser handles the canonical commands instantly. Anything it can’t parse, or parses into something the engine can’t act on, can go to the optional [intent server](https://mrballistic.github.io/brass-lantern/guide/intent-server), where an LLM reads it more loosely. The LLM only *classifies* input; it never writes story text. 3. **The world** (`packages/engine/src/worlds/`) is one object. Swap it and you have a different game. ``` browser intent server (optional) ┌───────────────────────────────────┐ ┌────────────────────────┐ │ terminal ─► store │ POST │ /api/parse-intent │ │ │ splitCommands │ ─────► │ rate limit, size caps │──► Gemini │ ▼ regex parser │ │ model chain, deadline │ │ ▼ engine ◄── world │ ◄───── │ reply sanitizing │ │ │ (miss? ask LLM) │ └────────────────────────┘ │ ▼ output + save │ └───────────────────────────────────┘ ``` ## From keystroke to reply The store (`packages/vue/src/stores/game.ts`) runs every line through the same steps: 1. **Meta commands** (SAVE, RESTORE, LOAD, UNDO, RESTART, SCRIPT, THEME, BLOOM, EFFECTS, EJECT, COOKIES) are handled by the store itself, and so is the answer to a SAVE or RESTORE prompt. 2. **Split.** `splitCommands` (`packages/engine/src/engine/parser.ts`) breaks the line into commands: - First the line is cleaned (`cleanInput`): trimmed, with every run of whitespace made one space. A line still longer than `MAX_INPUT_LENGTH` (1,000 characters) is refused whole: the parsers return nothing, and `createGame().send`, `interpret` and the Vue store answer “[That’s too long for me to follow. Nothing happened.]” (`TOO_LONG_REPLY`) before anything runs. Every pattern is written for the cleaned form, so no line costs more than linear time to read. - Clauses always split on `then`, `;` and full stops, except after `dr.`, `mr.` and the like. - Within a clause, `and` and commas split only when every piece is a recognized command, or an object after a list verb: `get key and wallet` becomes `get key` and `take wallet`. - A clause that isn’t clearly a list (“could you grab my keys and wallet”) stays whole. - Anything inside quotes is masked first, so a quoted “. ” or “, ” never splits a line (`answer “a well. yes”` is one command). A text verb (SAY, ANSWER) takes the rest of the line, and ends it. - Names that contain “and” (`andNames(world)`: every item and character name and alias with the word in it, longest first) are masked too, so `take flair from lost and found and go north` is two commands. The store and `createGame` work the list out once per world. Each command may be **captured** first: a room’s or the world’s `capture` (`captureLine` in `packages/engine/src/engine/engine.ts`) can take it before it’s parsed, which ends the line. Captured input never reaches the intent server. 3. **The conversation.** `interpret` (`packages/engine/src/engine/conversation.ts`) looks at the command in the light of the last one: - **an answer** to a question the engine just asked (“Which door do you mean?” → `trap`) fills in the waiting command and runs it, **without** asking the intent server; - **AGAIN** (`g`) runs the last command again; - **OOPS *word*** swaps the word nobody understood in the last line and runs that. Anything else is a fresh command, and drops any waiting question. 4. **Parse.** `fallbackParse` maps each command to an action, with synonyms, a second object (“give X to Y”, “put X in Y”), ALL and EXCEPT, and a bare word meaning GO. Besides `target` and `indirect`, an action can carry: - **`number`**: a number in an object slot (`turn dial to 4`, `set year to 776`), the value alongside the digits typed (`parseNumber`: digits to 1000, or H:MM as minutes); rules see that slot as `number`; - **`text`**: for a world verb with `target: 'text'`, the rest of the line, outer double quotes dropped; - **`prep`**: `in`, `on`, `under`, `behind`, `off`, `over` or `through`, for the forms that take one (PUT X UNDER Y, THROW X OFF Y, READ X THROUGH Y); - **`direction`**: for PUSH X NORTH (and CLIMB UP/DOWN a thing); - **ME, MYSELF, SELF and YOURSELF** stay as typed: the engine decides they name the player (the reserved ID `player`, for rules), and never matches them, or `player` from the intent server, against things’ names. 5. **Pronouns.** `it`, `them` and `that` become the last thing the engine acted on; `him` and `her`, the last person. 6. **Execute.** `execute(action, { world, state })` returns the lines to print, whether anything changed, and `understood: false` if it couldn’t make sense of the command (no such exit, no such item, no rule that applies). When a noun matches more than one thing, or a verb is missing its object, it returns a **question** (`ask`) instead, before touching anything. Before running a command that changes the game, the store keeps a snapshot for UNDO (the last 50, for this session only). 7. **Retry on a miss.** If the regex couldn’t parse the command, or the engine didn’t understand it, the store asks the intent server how to read it. If the LLM’s reading is different and the engine can act on it, that result is shown instead; otherwise the literal reply stands. 8. **Time passes.** After a command the engine acted on (never after a misunderstood one, a question, or a command like VERBOSE that takes no time), the room’s end routines run (`onEnd`), then the move counter goes up, wounds heal, timers fire, daemons run, characters fight, and ambient lines print, in that order (`packages/engine/src/engine/time.ts`, after Zork’s CLOCKER). If the light changed, the reply says so. 9. **Output.** Lines are styled by their prefix and typed out, and the game is saved if anything changed. **The one rule to keep** Step 7 runs the engine on the literal reading first, and possibly again on the LLM’s reading. That’s only safe because **a miss never changes the game**. When you add an engine handler, decide whether you can act before you touch any state. `packages/engine/tests/engine/engine-hooks.test.ts` checks this. The intent server sees the room by ID and name (`red_mug (red coffee mug)`), so its answer uses IDs the engine matches exactly. Everything still goes through the same fuzzy matcher (`packages/engine/src/engine/fuzzy.ts`), so a slightly-off answer still lands. Names match strongest first: the exact name, an alias or the ID; a whole word of a name or alias; a whole word of the ID, or part of a name; part of the ID; the starts of words (every typed word of three or more letters must match, so an extra word like “red ball” for a plain “ball” misses); and, in brass style, a close misspelling (a typed word of five or more letters at least 80% alike, comparing the start of a longer word too, so “lunberg” finds Lumbergh; among misspellings, a close match to a whole name word beats one found only through an ID word or a word’s start). Objects and people are compared together, so EXAMINE LUMBERGH finds Lumbergh rather than his mug; on a tie EXAMINE prefers objects, and ATTACK, FOLLOW and the person in GIVE/SHOW … TO prefer people. Equally good matches ask “Which do you mean?”. Infocom style never guesses misspellings. ## Cartridges and sessions The terminal talks to whatever is running through one interface (`useSession()` in `packages/vue/src/stores/session.ts`): - **The cartridge menu**, when nothing is inserted. - **A native world**, run by the engine above. - **A Z-machine story**, run by ifvms. See [Playing story files](https://mrballistic.github.io/brass-lantern/guide/z-machine). Inserting or ejecting a cartridge clears the screen. ## The engine `execute` (`packages/engine/src/engine/engine.ts`) dispatches on the action to one handler per verb (`packages/engine/src/engine/verbs/`), and each handler reads the world’s rules rather than knowing any particular game. **The object tree.** Every item has one parent: a room, the player, another item, or nowhere yet (`GameState.locations`, in `packages/engine/src/engine/model.ts`). Inventory, a room’s contents and a container’s contents are all read from that one map. Containers and surfaces nest. What the player can *see* (through glass) and *reach* (into open things) is computed from the tree, and the parser only matches what can be seen. | Concept | Where in the world | Notes | |---|---|---| | Conditions | anywhere | `flag:`, `has:`, `held:`, `in:`, `visited:`, `inside:`, `open:`, `locked:`, `on:`, `here:`, `var:`, `number:`, `said:`, `target:`, `indirect:`, `direction:`, `terrain:`, `water:`, `aboard`, `seen:`, `following:`, `carrying`, `lit:`, `alive:`, `awake:`, `fighting:`, `with:`, `!`, `&`. One parser, `packages/engine/src/engine/conditions.ts`. | | Rules | `instead`, `after` on items and rooms | Replace a verb’s default, or follow it. `packages/engine/src/engine/rules.ts`. | | Containers, doors | `item.container`, `item.surface`, `item.door` | Open, close, lock, put in, take from. | | Exits | `room.exits` | A room ID, or `{ to, if, denial, door, denials, then }`. GO runs through rules. | | Vehicles | `item.vehicle`, `room.terrain` / `water` / `air`, `world.onFoot` | BOARD, DISEMBARK, and Zork’s rules for water and land, generalized to named terrains: a vehicle `travels` on some, rests on others (`packages/engine/src/engine/verbs/vehicle.ts`, `movement.ts`). | | Effects | `events` | Lines and typed effects: flags, moves, variables, timers, chance, death, endings (`packages/engine/src/engine/effects.ts`). | | Darkness | `room.dark`, `item.light`, `world.darkness` | In an unlit dark room you can only find what you carry (`packages/engine/src/engine/model.ts` `isLit`). | | Death, endings | `world.death`, `world.endings` | `packages/engine/src/engine/death.ts`, `packages/engine/src/engine/endings.ts`. | | Characters | `npcs`, `room.npcs` | Places, held things, states, hiding and descriptions (`GameState.npcs`, read through `packages/engine/src/engine/model.ts`). | | Topics, orders | `npc.topics`, `npc.orders`, `npc.obeys`, `npc.refuseOrder`, `instead.order` | ASK *X* ABOUT *Y* and “*X*, do this” (`packages/engine/src/engine/verbs/talk.ts`; see [Orders](#orders) below). | | Followers | `npc.follows`, `{ follow }` | Characters who go where the player goes (`packages/engine/src/engine/verbs/movement.ts`). | | Combat, health | `npc.combat`, `world.combat` | Zork’s blows, tables, wounds and healing (`packages/engine/src/engine/combat.ts`). | | Weight | `world.carry`, `item.size` | `packages/engine/src/engine/weight.ts`. | | Scripts | `world.scripts` | The code hatch: functions that return steps (`packages/engine/src/engine/scripts.ts`). `descriptionScript` also builds descriptions from state; `{var:NAME}` and `{number}` fill in text (`packages/engine/src/engine/text.ts`). | | Events | `events` | Line lists. `[Flag set: …]`, `[Added to inventory: …]` and `[… consumed]` lines change state. Events from `onEnter`, `onTake`, `onWear`, `onSmash` and `bareHanded` fire once; use-rule and gift events run every time. | | Flags | `flagLabels` | The friendly label in an event line, mapped to a flag ID. | | Use rules | `item.onUse` | The older form of `instead.use`. | | Gifts | `npc.onGive`, `npc.refuse` | Giving takes the item; a refusal keeps it. | | Gated rooms | `room.requires`, `room.denial` | | | The ending | `finale` | Smash X in room Y holding Z: event, epilogue, score, footer. | | Timers | `ambient` | Lines every N turns while a condition holds. Turns count only commands the engine acted on. | | Score | `scoring`, `ranks`, `maxScore`, `scoreLine`, `rankLine` | Summed from flags, conditions that hold, and the `score` variable; the lines are templates. | | Status line | `style`, `statusLine` | Zork’s room, score and moves in Infocom style; `MOVES: n` (or score and moves) otherwise. | When a world needs behavior the schema can’t express, the engine gets a *generic* hook (in `packages/engine/src/types/world.ts` and the engine); it never branches on a world’s IDs. [CONTRIBUTING.md](https://github.com/mrballistic/brass-lantern/blob/main/CONTRIBUTING.md) has the details. ### Orders “*X*, *command*” (and TELL *X* TO *command*) is its own verb, `order`, handled by `handleOrder` in `packages/engine/src/engine/verbs/talk.ts`. The pipeline, in order: 1. **Address.** The character is matched in the player’s room, or among those whose `heardFrom` names it. No one matching is a miss. 2. **`instead.order` rules** answer first, and the order’s words stay words (they aren’t resolved as things). 3. **Parse the inner command** with `fallbackParse`, as the player would have typed it. A character with neither `orders` nor `obeys` stops here with `refuseOrder`. 4. **Resolve its objects before anything changes**, among what the *character* can reach (its room’s things and what it holds), plus ME (the player), a typed number, and other characters present. A name that matches nothing is a miss; one that matches several is a question, and neither takes time or changes state. 5. **`orders` rules**, keyed by the inner verb (or the first typed word when the parsed verb has no table, so the parsed verb wins a clash). Their conditions see the inner command through the same command record rules use: `target:`, `indirect:`, `number:`, `said:`, `direction:`. 6. **`obeys`**: if no rule answered, the engine performs GO, TAKE, DROP or GIVE ME itself, moving the character or its things. A refusal here is understood and takes a turn. 7. **Ends the line.** A result for a character with `orders` or `obeys` carries `stopLine`, and the store drops the rest of the typed line (Zork’s P-CONT). A miss never does, so the intent-server retry isn’t affected. **Adding a verb** is usually a world change: declare it in `world.verbs` and give things `instead` rules for it. The parser learns its words from the world, and the browser tells the intent server which world verbs to accept, so neither needs editing. A *built-in* verb, with default behavior in the engine, is a change to the engine itself (for contributors; see [CONTRIBUTING.md](https://github.com/mrballistic/brass-lantern/blob/main/CONTRIBUTING.md)), and takes three edits: - the regex in `packages/engine/src/engine/parser.ts`, plus its words in `BUILT_IN_WORDS`; - the dispatcher in `packages/engine/src/engine/engine.ts` (wrapped in `withRules`), plus its HELP text; - `ACTION_VOCAB` in `packages/server/src/llm.ts`. Without that last one, the server throws away the LLM’s answer as an unknown verb. **Saves** are format 2.0. A 1.0 save (from before the object tree) is converted on load by `packages/engine/src/engine/migrate.ts`, so players keep their games. A 2.0 save that has no place for an item (one a later version of the world added) gets that item’s starting place; an item recorded as `null` (used up) stays gone. ## The terminal The CRT is hand-written CSS (`packages/vue/src/styles/crt.css`), with no canvas and no UI framework. Every rule is scoped to the game’s shell, which fills its container, so the effects stay inside the game’s box on any page: - a boot sequence, scanlines, phosphor bloom and a barrel vignette; - flicker at two rates, plus occasional glitches; - phosphor decay on older lines, and a block cursor with a square-wave blink. The default look, `crt-amber`, is deliberately period-accurate rather than WCAG-compliant. The colors are twelve `--bl-*` role variables set by a **theme**, so a game can ship `crt-green`, a plain `simple` look (light or dark, following the system) or a palette of its own, and the player can switch with THEME. Reduced motion turns off flicker, glitch and noise. [Using the library](https://mrballistic.github.io/brass-lantern/guide/using-the-library#themes) has the details. Each output line is classified by its first characters (`packages/engine/src/engine/output.ts`). The class sets both its style and its typewriter speed: | Prefix | Type | Speed | |---|---|---| | `> ` | your input | instant | | `📍` | room header | 10ms/char | | event emoji (`💼🌀👔💕💻💾🔨💥📎✨🪄🤜😖😴🐟🌺📬📞`) | scripted moment | 18ms/char | | `═`, `“` | banner, narration | 12ms/char | | `[` | system | instant | | anything else | prose | 10ms/char | A restored session renders instantly, with no typewriter replay, and its boot sequence is shortened. The header shows the game’s `terminalName` and `version` options, then the cartridge’s title (in builds with a menu); on the right, a native world’s status line (`MOVES: n`, or Zork’s “West of House Score: 0 Moves: 0” in Infocom style) or the story’s own, and a COOKIES button when analytics are configured. ## Saves and analytics - **Saves** live in `localStorage` only, one per cartridge, under `:save:` (or the cartridge’s `saveKey`), with up to 500 lines of history. [Cartridges and storage](https://mrballistic.github.io/brass-lantern/reference/cartridges#browser-storage) lists every key. They’re written after every change. SAVE *name* keeps an extra copy under `:named:`, and RESTORE *name* brings it back. If storage is unavailable (private browsing), play continues and SAVE says so. - **UNDO** history is memory only: up to 50 snapshots, gone on reload, RESTART or RESTORE. - **SCRIPT** downloads a transcript as a text file when it stops; nothing is stored. - **Analytics** are the app’s, not the library’s: the packages send nothing anywhere. A game tells `GameOptions.analytics.onEvent` about `game_start` and `session_resumed` (each with a `cartridge` parameter for story files) and, for native worlds, `game_completed` with the move count; what happens next, and whether the player has consented, is up to the app. Given `analytics.openConsent`, the header shows a COOKIES link and the COOKIES command calls it. The demo site, for example, sends those events to Google Analytics only when `VITE_GA_MEASUREMENT_ID` is set at build time, and only after the player accepts its `ConsentBanner`; it honors Do Not Track, and a build without an ID shows no banner. --- Source: https://mrballistic.github.io/brass-lantern/guide/intent-server # The intent server Players don’t type like parsers. The intent server hears what the regex parser couldn’t handle, such as “make that thing stop beeping” or “pocket my billfold”, and asks an LLM which of the engine’s verbs and IDs that means. It’s optional: without it the game is fully playable, and loose phrasing just gets a “didn’t understand” reply. It ships as `@brass-lantern/server`, with `parseIntent` (a function) and `intentRoute` (an Express router). The repo also has a small Express app around them for the demo, described under [Run the repo’s server](#run-the-repo-s-server). ## Use it in your own backend The package reads no environment variables; you pass the key in. [Using the library](https://mrballistic.github.io/brass-lantern/guide/using-the-library#the-intent-server) has the full wiring: ```ts import { intentRoute } from '@brass-lantern/server/express'; app.set('trust proxy', 'loopback'); // behind a reverse proxy on the same machine app.use(express.json({ limit: '32kb' })); app.use('/api', intentRoute({ apiKey, models, timeoutMs, rateLimitPerMinute })); ``` Behind Apache or nginx, `trust proxy` lets the rate limit see each player’s address; without it every request seems to come from the proxy, and all your players share one bucket. Then set `intentEndpoint: '/api/parse-intent'` in the game’s options. Without it (or with `null` or an empty string) the game never asks. ## Run the repo’s server ```bash npm install # at the repo root: installs every workspace cd packages/server cp .env.example .env # then set GEMINI_KEY (get one at https://aistudio.google.com/apikey) npm run dev # http://127.0.0.1:3001 ``` With `npm run dev` running at the repo root too, Vite proxies `/api/*` to it, so the browser just calls its own origin. This app reads its settings from the environment (below); the library does not. **Keep the key on the server** The browser never talks to Google; only this server does, with the key in a request header. **Never give the key a `VITE_` prefix.** Vite inlines `VITE_*` variables into the public bundle. ## The API `POST /api/parse-intent` ```json { "input": "hand gary his mug", "context": { "roomName": "Hallway", "exits": ["south", "cubicle", "north", "break_room"], "items": ["mug"], "npcs": ["gary (Gary)"], "inventory": ["stapler"], "verbs": [] } } ``` Response: `{ "action": "give", "target": "mug", "indirect": "gary" }`, or `{ "action": "unknown" }` when there’s no good reading or every model fails. Items whose name matches their ID are sent as the bare ID; others as `id (name)`. The reply can also carry a `prep` (`put … under`, `throw … off`, `read … through`), a `direction` (`push … north`), and a `number` (`turn dial to 4`, 0 to 1000; the client keeps it only when `target` or `indirect` is the word `number` or its digits). For an order (`tell robot to take lamp`), `indirect` is the command as a few plain lowercase words instead of an identifier; the engine parses it as it would typed text. `verbs` lists the world’s own verbs, which the server accepts alongside its built-in list. Bad requests and rate limiting get an error status instead: 400 with only an `error` field for a missing input or malformed context; 400 with `fallback` as well for input over 200 characters; 429 (with `Retry-After`) and 500, both with `fallback`. The `fallback` is always `{ "action": "unknown" }`. The bundled client treats any non-OK response as `unknown`, so the player just sees the literal reply. The repo’s server app also answers `GET /health` with `{"ok":true}`; `intentRoute` itself serves only `POST /parse-intent`. ## How it decides The server calls Gemini’s REST API directly, with no SDK: - **Structured output.** `responseMimeType: application/json` with a schema whose verb field is an enum of the engine’s actions. Temperature 0, minimal thinking, 150 output tokens. - **A model chain inside one deadline.** It tries `gemini-3.5-flash-lite`, then `gemini-3.6-flash`, within 5 seconds in total. Every attempt but the last is capped at 2.5s, so a stalled model still leaves time for the fallback. - **Error handling.** A retired model (404), rate limiting (429) and server errors (5xx) move on to the next model. Bad requests and bad keys (400/401/403) stop, since every model would repeat them. - **Sanitizing.** The reply must name a known verb, and `target` / `indirect` must look like identifiers. Anything else is dropped, so model output can never carry prose to the player. - **The prompt** treats player input as data, and tells the model to answer with IDs from the context. ## Configuration These apply to the repo’s own server app. All of them are read in `packages/server/src/config.ts`; the library entry points take the same settings as parameters instead. | Variable | Default | | |---|---|---| | `GEMINI_KEY` | (required) | The server refuses to start without it. | | `GEMINI_MODELS` | `gemini-3.5-flash-lite,gemini-3.6-flash` | Comma-separated, tried in order. `GEMINI_MODEL` (one model) also works. | | `PORT` | `3001` | | | `HOST` | `127.0.0.1` | Loopback by default; put a reverse proxy in front. | | `RATE_LIMIT_PER_MINUTE` | `30` | Per client, per process. | Google retires models regularly. A retired model just answers 404 and the chain moves on, so update the list when you notice, before the last one goes. ## Abuse limits There’s no auth, by design: the endpoint only classifies short commands. Its cost is bounded instead: - **Input caps:** 200 characters of input, and context lists of at most 50 entries of 100 characters each. - **Rate limit:** per client, with IPv6 bucketed by /64 so one user can’t rotate addresses, plus a global ceiling. - **No CORS headers**, so other websites can’t spend your quota from their visitors’ browsers. - **Your provider’s quota** as the backstop. Keep a spending limit on the key. --- Source: https://mrballistic.github.io/brass-lantern/guide/deploying # Deploying A Brass Lantern game is a static site, plus the intent server if you want loose phrasing understood. If you use the packages in your own app ([Using the library](https://mrballistic.github.io/brass-lantern/guide/using-the-library)), deploy your app the way you already do and mount `intentRoute` in your backend; this page is about the repo’s demo site and server. ## Static only `npm run build` writes `apps/site/dist/`, which any static host can serve: GitHub Pages, Netlify, an S3 bucket, a web server. Without an intent server the game still plays on the regex parser alone; players just get a “didn’t understand” reply where the server would have helped. To serve from a subpath, set the base at build time: ```bash VITE_BASE=/my-game/ npm run build ``` That’s how the [demo](https://mrballistic.github.io/brass-lantern/demo/) is published: `.github/workflows/pages.yml` builds these docs and the demo together and deploys them to GitHub Pages on every push to `main`. ## With the intent server The SPA calls `/api/parse-intent` on its own origin, so serve both from one host and proxy `/api/` to the server: ```apache # Apache (mod_proxy, mod_proxy_http) DocumentRoot /var/www/my-game FallbackResource /index.html ProxyPass /api/ http://127.0.0.1:3001/api/ ProxyPassReverse /api/ http://127.0.0.1:3001/api/ ``` ```nginx # nginx root /var/www/my-game; location / { try_files $uri /index.html; } location /api/ { proxy_pass http://127.0.0.1:3001; } ``` Build and run the server: ```bash npm ci && npm run build -w @brass-lantern/server GEMINI_KEY=… NODE_ENV=production node packages/server/dist/index.js ``` **Install the dev dependencies** The repo’s server imports `express`, which this repo keeps as a dev dependency (the published package treats it as an optional peer). Don’t install with `npm ci --omit=dev` before running `node packages/server/dist/index.js`: the server won’t start. Install everything, or write your own small app around `intentRoute` and install `express` yourself. In production, use a process manager (systemd, pm2, a container) and keep the key in an environment file only that service can read. The server drains in-flight requests on SIGTERM, so rolling restarts don’t drop players’ commands. ## Suggestions from running one - **Cache hashed assets forever and `index.html` never**, and upload the assets before `index.html`. Then a deploy never serves a page whose scripts haven’t arrived yet. - **Deploy as a user that can only write the site**, not an admin. A CI deploy key is as powerful as the account it logs into. - **Pin your CI actions to commit SHAs**, and keep deploy secrets in a deployment environment that only release tags can use. - **Check that the key never reaches the bundle.** A CI step that greps `dist/` for `AIza` fails the build if it ever does. - **Analytics (the demo site):** set `VITE_GA_MEASUREMENT_ID` at build time to turn on its GA4 reporting. The consent banner appears only then. In your own app, analytics are whatever you wire to `GameOptions.analytics`. --- Source: https://mrballistic.github.io/brass-lantern/guide/building-worlds/ # Building worlds A world is one TypeScript object, typed by [`World`](https://mrballistic.github.io/brass-lantern/reference/world-schema). There’s no build step, no scripting language and no engine code to write: rooms, items, people and rules are data, and the engine plays them. This section is three worked examples, each a real world file that a test plays from start to finish, so the code you read here is code that runs: - **[A two-room game](https://mrballistic.github.io/brass-lantern/guide/building-worlds/two-rooms)** starts from nothing: two rooms, a hidden key, a locked door and a way to win. Start here. - **[The demo game: Snack Attack](https://mrballistic.github.io/brass-lantern/guide/your-first-world)** walks through the three-room world the demo boots into, with people, dialogue, hints, a score and a finale. - **[Recipes](https://mrballistic.github.io/brass-lantern/guide/building-worlds/recipes)** are small worlds for one idea each: containers and keys, darkness and death, timers, endings with verbs of your own, a guard to fight, scripts, a librarian with topics, a wandering cat, a room that listens, a raft, and a robot, a dial and a dune buggy. Every one of them ships with the engine, so you can play it, read it or start from it: `import { twoRooms, tutorial, containers } from '@brass-lantern/engine/worlds'`. ## Where a world lives Anywhere in your app. Put it in a file of its own, typed with `World`: ```ts // my-world.ts import type { World } from '@brass-lantern/engine'; export const myWorld: World = { startRoom: 'porch', rooms: { /* … */ }, items: { /* … */ }, npcs: {}, dialogue: {}, events: { intro: [ /* … */ ] }, flagLabels: {}, }; ``` Then hand it to the terminal as a cartridge ([Using the library](https://mrballistic.github.io/brass-lantern/guide/using-the-library) has the setup): ```ts import { mountGame } from '@brass-lantern/vue'; import '@brass-lantern/vue/style.css'; import { myWorld } from './my-world'; mountGame('#app', { cartridges: [{ kind: 'world', id: 'my-world', title: 'MY WORLD', world: myWorld }], storagePrefix: 'my-world', }); ``` With one cartridge the terminal boots straight into it; with several it shows a menu. ## The loop 1. **Write a little.** One room, one item, one rule. 2. **Play it** in the browser, or in a test (below), which is faster. 3. **Run the checks.** `auditWorld(myWorld)` lists effects naming things that don’t exist, exits to nowhere, unknown conditions and events, and IDs, flags or variables named `__proto__`, `constructor` or `prototype` (the engine ignores those names, so they can’t reach `Object.prototype`; a player who types one just gets the reply for a word the game doesn’t know). Those mistakes fail silently in play, so make them fail loudly in a test: expect it to return `[]`. A test that plays your world is a few lines: ```ts import { expect, it } from 'vitest'; import { auditWorld, createGame } from '@brass-lantern/engine'; import { twoRooms } from '@brass-lantern/engine/worlds'; it('has no broken references', () => { expect(auditWorld(twoRooms)).toEqual([]); }); it('can be won', () => { const game = createGame(twoRooms, { seed: 1 }); for (const line of ['examine mat', 'take key', 'unlock door with key', 'open door', 'north', 'read letter']) game.send(line); expect(game.state.gameOver).toBe(true); }); ``` [Testing a world](https://mrballistic.github.io/brass-lantern/guide/testing) covers the rest. ## How names are matched The player’s words find a thing (or a character, or a topic) by trying these, strongest first, and taking the best that finds anything: 1. **exactly** its name, an alias or its ID (`take brass lantern`); 2. **a whole word** of its name or alias (`take lantern`); 3. **a whole word of its ID**, or **part of** its name (`take lant`); 4. **part of** its ID; 5. **word starts**: every word of three or more letters the player typed matches the start of a word in its name, aliases or ID (`take lantern of brass`). Shorter words (`a`, `of`, `my`) are ignored, and so are common determiners and possessives (`this`, `that`, `his`, `your`, `some`, …): the player may type them, and they never have to match; 6. in brass style only, **a close misspelling**: a typed word of five or more letters that is at least 80% alike (the start of a longer word counts too), so `lunberg` finds Lumbergh. Among misspellings, a close match to a whole word of the name beats one found only through an ID word or a word’s start. Objects and people are compared together, so `examine lumbergh` finds Lumbergh rather than his mug. On a tie, EXAMINE prefers objects; ATTACK, FOLLOW and the person in GIVE/SHOW … TO prefer people; other verbs look at objects first. If two things match equally well, the game asks “Which do you mean?”. Infocom style never guesses misspellings, and neither do turns the intent server answers. In brass style a world can tease the player, once per thing, with [`typoReplies`](https://mrballistic.github.io/brass-lantern/reference/world-schema#world). The word-start step is strict on purpose: **a word the world doesn’t know stops the match.** If the player types `take red ball` and the world calls the thing only “ball”, nothing matches, even though there is a ball right there (in brass style, a close misspelling of a word that *is* there still matches): ``` > take red ball You don’t see a “red ball” here. ``` So when your description gives a thing a color, a size or a material, put that word in its name or an alias. Either of these works: ```ts ball: { name: 'ball', aliases: ['red ball'], description: 'A red rubber ball.', portable: true, tags: [] }, ball: { name: 'red ball', description: 'A red rubber ball.', portable: true, tags: [] }, ``` ``` > take red ball Taken: ball. ``` (Before 2.1.0 one matching word was enough, so `give smiley flair` could land on some *other* flair once the smiley one was gone. If a test of yours stops finding a thing after upgrading, this is why: add the alias.) Exits have no aliases: their labels are the words, so give an exit every label a player might type. ## Characters: orders and FOLLOW A character can be told to do things: `robot, go east`, `floyd, follow me`. Its `orders` rules answer, keyed by the verb, and `obeys` lets it carry out GO, TAKE, DROP and GIVE by itself. [Orders](https://mrballistic.github.io/brass-lantern/reference/world-schema#orders) has the whole story; [Orders, numbers and a buggy](https://mrballistic.github.io/brass-lantern/guide/building-worlds/recipes#orders-numbers-and-a-buggy) is a worked example. In an `orders.go` rule, **`direction:`** is the place the character was told to go, as the player typed it. The robot in that recipe won’t drive onto sand by itself: ```ts robot: { name: 'robot', description: 'A squat robot on treads, with one clamp for an arm.', obeys: ['go', 'take'], obeyReplies: { go: 'Whirr, click!', take: 'Click!' }, orders: { go: [{ if: 'direction:dunes', say: ['The robot looks at its treads, then at the sand, and stays put.'] }], }, }, ``` ``` > robot, go to the dunes The robot looks at its treads, then at the sand, and stays put. > robot, go east Whirr, click! ``` “Go to the dunes” matches `direction:dunes`, so the rule answers. “Go east” matches no rule, so the robot obeys. The word is compared as typed (lowercase, without “to the”), so `direction:east` works for compass directions too. FOLLOW is a built-in verb. `follow floyd` doesn’t make Floyd anything; it tells the player how to bring him along. `floyd, follow me` is an order, so the character decides, in an `orders.follow` rule: ```ts floyd: { name: 'Floyd', article: '', description: 'Floyd, a cheerful robot.', orders: { follow: [{ if: 'target:player', then: 'floyd_tags_along' }] }, }, // in events: floyd_tags_along: ['“Floyd go too!” he squeaks.', { follow: 'floyd' }], ``` ``` > follow floyd You’d rather Floyd came to you. Try FLOYD, FOLLOW ME. > floyd, follow me “Floyd go too!” he squeaks. > north … Floyd follows you. ``` **`article: ''`** is for proper names. The engine’s own lines about a character start with “The” (“The robot can’t go that way.”); `''` makes them “Floyd can’t go that way.” rather than “The Floyd …”. An `instead.follow` rule on the character replaces the built-in reply to `follow floyd` (“Floyd is too quick for you.”). ## Darkness A room with `dark: true` needs a light the player brings in: an item with `light: true` that is on. In the dark the player can use only what they carry: acting on anything else says “It’s too dark to see.” (`darkness.tooDark` changes the line), and so does EXAMINE of a character who’s there. Talking to characters, giving them things, ordering them and fighting them still work. ``` > examine troll It’s too dark to see. ``` A `flaming` item with no switch, such as a torch or a lit match, is already burning: LIGHT TORCH says “It’s already lit.” Being on fire doesn’t make it a light, though; give it `light: true` and switch it on with an effect if it should light rooms. [Darkness and death](https://mrballistic.github.io/brass-lantern/guide/building-worlds/recipes#darkness-and-death) is a worked example, and [Darkness](https://mrballistic.github.io/brass-lantern/reference/world-schema#darkness) lists the fields. ## Saves and new versions of your world Players’ saves hold the whole game: where every item is, flags, variables. When you ship a new version of a world with **new items**, a save made before them has no place for them. On load, the engine puts each item the save doesn’t mention where the world starts it, so an older save sees the new things where you placed them. An item the player used up (moved to `null`) stays gone. The browser terminal does this on every load; if you keep saves yourself, pass them through `migrateSave(world, raw)` from `@brass-lantern/engine` to get the same. Say version 1 of the two-room game has nothing in the hall but the side table, and version 2 adds an umbrella. All you do is add it, as you would to a new world: ```ts // Version 1 hall: { name: 'Hall', /* … */ items: ['side_table'], /* … */ }, // Version 2: the umbrella is new hall: { name: 'Hall', /* … */ items: ['side_table', 'umbrella'], /* … */ }, // and in items: umbrella: { name: 'umbrella', description: 'A black umbrella, still damp.', portable: true, tags: [] }, ``` A player who saved in the hall under version 1 restores under version 2 and finds it there: ``` > look 📍 Hall A dusty hall that smells of old books. The front door is south. You can see: umbrella. Sitting on the side table is: A letter Exits: south. ``` In the save itself, `migrateSave` fills in the one place the old save was missing, and leaves everything else alone: ```ts // before: version 1’s save has no umbrella locations: { doormat: 'porch', key: 'player', side_table: 'hall', letter: 'side_table', /* … */ } // after migrateSave(version2, save): the umbrella starts where version 2 puts it locations: { doormat: 'porch', key: 'player', side_table: 'hall', letter: 'side_table', /* … */ umbrella: 'hall' } ``` That covers new items only. Change where an existing item starts and old saves keep it where it was; rename an item’s ID and old saves treat it as new, putting it back at its start even if the player had taken it. Prefer adding to renaming. ## Style - **Brass** (the default) is this engine’s own voice: “You can see: …”, “Taken: lamp.”, an exit line. - **Infocom** (`style: 'infocom'`) follows Zork: “There is a lamp here.”, “Taken.”, no exit line, brief revisits. The native [Zork I](https://mrballistic.github.io/brass-lantern/guide/porting-zork) uses it. Write player-facing text with curly quotes and apostrophes (“ ” ’), as the engine’s own replies do. --- Source: https://mrballistic.github.io/brass-lantern/guide/building-worlds/two-rooms # A two-room game This page builds the smallest complete game, [`packages/engine/src/worlds/examples/two-rooms.ts`](https://github.com/mrballistic/brass-lantern/blob/main/packages/engine/src/worlds/examples/two-rooms.ts), from nothing. You’re home after a long time away; the door is locked, and you left the key somewhere obvious. [A test](https://github.com/mrballistic/brass-lantern/blob/main/packages/engine/tests/worlds/examples/two-rooms.test.ts) plays it to the end. The whole game is one file, `two-rooms.ts`, holding one object. Its outline: ```ts import type { World } from '@brass-lantern/engine'; export const twoRooms: World = { // Two rooms: startRoom and rooms // Things: items // What happens: npcs, dialogue, flagLabels, events and endings }; ``` Each section below shows one part of that object, in order; put them inside the braces one after another and you have the file. It’s also [shown whole](#the-whole-file) at the end. The game ships with the engine, so you can play it straight away without typing any of it: `import { twoRooms } from '@brass-lantern/engine/worlds'`. ## Two rooms ```ts startRoom: 'porch', rooms: { porch: { name: 'Front Porch', description: 'A creaky porch in front of an old house. A doormat lies at your feet.', exits: { north: { to: 'hall', door: 'front_door' }, in: { to: 'hall', door: 'front_door' }, }, listExits: ['north'], items: ['doormat'], npcs: [], onEnter: [], scenery: ['front_door'], }, hall: { name: 'Hall', description: 'A dusty hall that smells of old books. The front door is south.', exits: { south: { to: 'porch', door: 'front_door' }, out: { to: 'porch', door: 'front_door' }, }, listExits: ['south'], items: ['side_table'], npcs: [], onEnter: [], scenery: ['front_door'], }, }, ``` - **`startRoom`** is where a new game begins. - **`exits`** map what the player types to where it goes. A plain string (`north: 'hall'`) is enough for an open way through; here each exit is an object naming a **`door`**, an item that has to be open before anyone passes. Several labels for the same way (`north`, `in`) are normal. - **`listExits`** is what the exit line shows. Without it, every label is listed. - **`scenery`** lists items that are present without being *in* the room: the door belongs to both rooms, so both name it, and it’s never listed or taken. - **`items`**, **`npcs`** and **`onEnter`** are required on every room, even when empty. ## Things ```ts items: { doormat: { name: 'doormat', aliases: ['mat'], description: 'WELCOME, it says, unconvincingly.', portable: false, refusal: 'It’s nailed down. Somebody really wanted you welcome.', tags: [], scenery: true, instead: { examine: [{ if: '!flag:found_key', then: 'find_key' }] }, }, key: { name: 'brass key', aliases: ['key'], description: 'Small, brass, a little green.', portable: true, tags: [] }, front_door: { name: 'front door', aliases: ['door'], description: 'Solid oak, painted red a long time ago.', portable: false, tags: [], door: true, container: { openable: true, locked: true, key: 'key' }, }, side_table: { name: 'side table', aliases: ['table'], description: 'A spindly side table.', portable: false, tags: [], scenery: true, surface: true, contains: ['letter'], }, letter: { name: 'letter', aliases: ['envelope', 'note'], description: 'An envelope with your name on it, in your own handwriting.', portable: true, tags: [], instead: { read: [{ then: 'read_letter' }] }, }, }, ``` - **Every item has a `name`** (shown to players), a **`description`** (EXAMINE), says whether it’s **`portable`**, and has **`tags`** (labels for your own rules; `[]` is fine). **`aliases`** are other words for it. The world’s `items` is required too. - **`scenery: true`** on an item keeps it out of the room’s list of things (the description already mentions the mat). It’s still in the room’s `items`, so it can be examined. **`refusal`** is what TAKE says to something that isn’t portable. - **The door** is an item with `door: true` and a `container` block: it can be opened, it starts locked, and the `key` item unlocks it. UNLOCK DOOR WITH KEY and OPEN DOOR work with no rules at all. - **The mat hides the key.** An **`instead`** rule replaces what a verb normally does. A rule is `{ if, then }`: `if` is a [condition](https://mrballistic.github.io/brass-lantern/reference/conditions-and-events#conditions) (`!flag:found_key`, “the flag `found_key` isn’t set”), and `then` names an event to run. EXAMINE MAT runs the `find_key` event the first time; once `found_key` is set the rule’s `if` fails and EXAMINE prints the description as usual. - **The key starts nowhere.** It isn’t in any room’s `items`, so it’s offstage until an event moves it. - **The side table is a `surface`**: what’s on it is always visible and in reach, which is where the hall’s “Sitting on the side table is:” comes from. `contains` puts the letter there at the start. - **The letter ends the game** with an `instead` rule on READ. ## What happens ```ts npcs: {}, dialogue: {}, flagLabels: {}, events: { intro: ['You’re back at the old house at last. You’re sure you left the key somewhere obvious.'], find_key: ['You lift a corner of the mat. Underneath: a brass key.', { set: 'found_key' }, { move: 'key', to: 'porch' }], read_letter: ['“Dear me,” it begins. “If you’re reading this, you remembered the mat. Welcome home.”', { end: 'home' }], }, endings: { home: { lines: ['✨ You’re home.'], footer: ['Type RESTART to play again.'] }, }, ``` - **`events`** are named lists of steps. A string prints; an object is an [effect](https://mrballistic.github.io/brass-lantern/reference/conditions-and-events#events-and-effects). `find_key` prints a line, sets a flag and moves the key onto the porch. - **`intro`** plays when a new game starts. - **`end`** plays one of the world’s **`endings`**: its lines, then the footer, and the game is over. A line starting with an emoji (✨) is shown as an event; [How lines are styled](https://mrballistic.github.io/brass-lantern/reference/conditions-and-events#how-lines-are-styled) has the rules. - **`npcs`, `dialogue` and `flagLabels`** are required. This game has no people, and its flags are set by effects rather than [bracket lines](https://mrballistic.github.io/brass-lantern/reference/conditions-and-events#bracket-lines-the-older-form), so they’re empty. ## The whole file The three parts, assembled. The sections above leave out only what goes around them: the import, a comment, and `export const twoRooms: World = {` with its closing `};`. (The file in the repo also has `// #region` comments marking the three parts for this page. They change nothing, and they’re left out here.) **two-rooms.ts** ```ts import type { World } from '@brass-lantern/engine'; /** * The smallest complete game: two rooms, a locked door, a hidden key and a * way to win. Built step by step in docs/guide/building-worlds/two-rooms.md; * tests/worlds/examples/two-rooms.test.ts plays it. */ export const twoRooms: World = { startRoom: 'porch', rooms: { porch: { name: 'Front Porch', description: 'A creaky porch in front of an old house. A doormat lies at your feet.', exits: { north: { to: 'hall', door: 'front_door' }, in: { to: 'hall', door: 'front_door' }, }, listExits: ['north'], items: ['doormat'], npcs: [], onEnter: [], scenery: ['front_door'], }, hall: { name: 'Hall', description: 'A dusty hall that smells of old books. The front door is south.', exits: { south: { to: 'porch', door: 'front_door' }, out: { to: 'porch', door: 'front_door' }, }, listExits: ['south'], items: ['side_table'], npcs: [], onEnter: [], scenery: ['front_door'], }, }, items: { doormat: { name: 'doormat', aliases: ['mat'], description: 'WELCOME, it says, unconvincingly.', portable: false, refusal: 'It’s nailed down. Somebody really wanted you welcome.', tags: [], scenery: true, instead: { examine: [{ if: '!flag:found_key', then: 'find_key' }] }, }, key: { name: 'brass key', aliases: ['key'], description: 'Small, brass, a little green.', portable: true, tags: [] }, front_door: { name: 'front door', aliases: ['door'], description: 'Solid oak, painted red a long time ago.', portable: false, tags: [], door: true, container: { openable: true, locked: true, key: 'key' }, }, side_table: { name: 'side table', aliases: ['table'], description: 'A spindly side table.', portable: false, tags: [], scenery: true, surface: true, contains: ['letter'], }, letter: { name: 'letter', aliases: ['envelope', 'note'], description: 'An envelope with your name on it, in your own handwriting.', portable: true, tags: [], instead: { read: [{ then: 'read_letter' }] }, }, }, npcs: {}, dialogue: {}, flagLabels: {}, events: { intro: ['You’re back at the old house at last. You’re sure you left the key somewhere obvious.'], find_key: ['You lift a corner of the mat. Underneath: a brass key.', { set: 'found_key' }, { move: 'key', to: 'porch' }], read_letter: ['“Dear me,” it begins. “If you’re reading this, you remembered the mat. Welcome home.”', { end: 'home' }], }, endings: { home: { lines: ['✨ You’re home.'], footer: ['Type RESTART to play again.'] }, }, }; ``` ## Play it You need Node 24 or later. In a new folder: ```bash npm init -y npm pkg set type=module npm i @brass-lantern/engine ``` Save the file above as `two-rooms.ts`, and this beside it as `play.ts`: ```ts import { createGame } from '@brass-lantern/engine'; import { twoRooms } from './two-rooms.ts'; const game = createGame(twoRooms); console.log(game.opening.join('\n')); for (const command of ['north', 'examine mat', 'take key', 'unlock door with key', 'open door', 'north', 'take letter', 'read letter']) { console.log(`> ${command}`); console.log(game.send(command).lines.join('\n')); } ``` ```bash node play.ts ``` Node 24 and later run TypeScript files as they are, no compiler needed (`npx tsx play.ts` works too). `createGame` runs a world with no browser: `game.opening` is what the game says before the first command, and `game.send(line)` takes whatever a player would type and answers with its `lines`. No server or API key is needed. To skip the typing, import the shipped copy instead: `import { twoRooms } from '@brass-lantern/engine/worlds'`. It prints this, word for word (the test checks the same transcript): ``` You’re back at the old house at last. You’re sure you left the key somewhere obvious. 📍 Front Porch A creaky porch in front of an old house. A doormat lies at your feet. Exits: north. > north The front door is closed. > examine mat You lift a corner of the mat. Underneath: a brass key. > take key Taken: brass key. > unlock door with key Unlocked. > open door Opened. > north 📍 Hall A dusty hall that smells of old books. The front door is south. Sitting on the side table is: A letter Exits: south. > take letter Taken: letter. > read letter “Dear me,” it begins. “If you’re reading this, you remembered the mat. Welcome home.” ✨ You’re home. Type RESTART to play again. ``` **In the browser**, the same world plays in the terminal from `@brass-lantern/vue`: a styled window, like an old CRT screen, with the typing prompt, saves and the rest of what a player sees. The shortest way there is a new [Vite](https://vite.dev) app: ```bash npm create vite@latest my-game -- --template vanilla-ts cd my-game npm i @brass-lantern/engine @brass-lantern/vue vue pinia ``` The template already has an `index.html` with `
` that loads `src/main.ts`. Copy `two-rooms.ts` into `src/`, then replace everything in `src/main.ts` with: ```ts import { mountGame } from '@brass-lantern/vue'; import '@brass-lantern/vue/style.css'; import './style.css'; import { twoRooms } from './two-rooms.ts'; mountGame('#app', { cartridges: [{ kind: 'world', id: 'two-rooms', title: 'TWO ROOMS', world: twoRooms }], storagePrefix: 'two-rooms', }); ``` and everything in `src/style.css` with: ```css html, body, #app { height: 100%; margin: 0; overflow: hidden; } ``` ```bash npm run dev ``` Open the address it prints and the game boots. What the options mean: - **`cartridges`** are the games on offer. A cartridge with `kind: 'world'` is a world like this one; `id` names it (in its saves, for one), and `title` is what the menu shows. With one cartridge there’s no menu: the terminal boots straight into it. - **`storagePrefix`** names everything the game keeps in the browser (saves, the player’s theme), so two games on one site don’t share saves. - **The game fills `#app`**, which is why `style.css` gives it the whole window. Vite tells TypeScript what a `.css` import is (the template’s `vite/client` types), and it accepts the `.ts` ending on `./two-rooms.ts`, as Node does. Outside Vite, [Using the library](https://mrballistic.github.io/brass-lantern/guide/using-the-library#put-a-game-on-a-page) says what to declare for the stylesheet, and covers themes and the rest of the options. Players won’t type exactly the commands above. “Look under the mat” or “use the key on the door” aren’t commands the engine can act on by itself, so with the [intent server](https://mrballistic.github.io/brass-lantern/guide/intent-server) running they can be mapped onto EXAMINE MAT and UNLOCK DOOR WITH KEY. Without it (as with `createGame`, which never uses it) the player gets a nudge to rephrase. ## Where next - Give the game a person to talk to, hints and a score: [Snack Attack](https://mrballistic.github.io/brass-lantern/guide/your-first-world) shows all three. - Make the hall dark until you find a lamp: [darkness](https://mrballistic.github.io/brass-lantern/guide/building-worlds/recipes#darkness-and-death). - Every field, with its default: the [world schema](https://mrballistic.github.io/brass-lantern/reference/world-schema). --- Source: https://mrballistic.github.io/brass-lantern/guide/your-first-world # The demo game: Snack Attack A world is one TypeScript object: rooms, items, people, the lines they say, and the rules that join them. If you haven’t written one yet, start with [a two-room game](https://mrballistic.github.io/brass-lantern/guide/building-worlds/two-rooms); this page goes further, and builds **Snack Attack**, the tutorial world (`tutorial` in `@brass-lantern/engine/worlds`, [source on GitHub](https://github.com/mrballistic/brass-lantern/blob/main/packages/engine/src/worlds/tutorial.ts)), piece by piece. A test plays that exact file to the end, so everything here matches what the engine does. The story: it’s 12:01, your pretzels are stuck in the break room vending machine, and you need your badge, a heavy object, and maybe some advice from Gary in Accounts. ## The shape ```ts import type { World } from '@brass-lantern/engine'; export const tutorial: World = { startRoom: 'cubicle', rooms: { /* … */ }, items: { /* … */ }, npcs: { /* … */ }, dialogue: { /* … */ }, events: { intro: [ /* … */ ] }, flagLabels: { /* … */ }, // optional: hints, scoring, ranks, idle, quit, confused, ambient, finale }; ``` Every room, item and person has an **ID**, its key (`desk_drawer`, `gary`). IDs are snake_case and never shown to players. The engine’s state is small: where you are, where every item is, a set of **flags** (named booleans like `drawer_open`), a few numbers, which events have fired, and how many moves you’ve made. Everything a world does comes down to reading and setting those. ## Rooms ```ts rooms: { cubicle: { name: 'Your Cubicle', description: 'Gray carpet on the walls, gray carpet on the floor. Your desk has one drawer. It is closed.', exits: { north: 'hallway', hallway: 'hallway', out: 'hallway' }, listExits: ['hallway'], items: ['desk_drawer', 'stapler'], npcs: [], onEnter: [], }, break_room: { name: 'The Break Room', description: 'A vending machine hums in the corner. Your lunch, a single bag of pretzels, hangs from its spiral, not quite falling.', exits: { south: 'hallway', hallway: 'hallway', out: 'hallway' }, listExits: ['hallway'], items: ['vending_machine'], npcs: [], onEnter: [{ if: '!flag:saw_pretzels', then: 'see_pretzels' }], requires: 'has:badge', denial: 'The break room door has a badge reader. You do not have a badge on you.', }, } ``` - **`exits`** maps what the player can type to where it leads. Give each destination a few labels (a direction, a place name, `out`). The parser is forgiving about phrasing (“go to the hallway”, “hall”, “n”), but only matches labels you provide. - **`listExits`** is what the player sees. The engine adds the direction that goes to the same place: `Exits: hallway (north).` - **`requires`** is a [condition](https://mrballistic.github.io/brass-lantern/reference/conditions-and-events#conditions) for entering, and **`denial`** is the reply when it fails. A good denial hints at what’s missing. - **`onEnter`** fires an event when you arrive, if its condition holds. **An `onEnter` event fires at most once per game.** ## Items ```ts items: { desk_drawer: { name: 'desk drawer', aliases: ['drawer', 'desk'], description: 'A metal drawer that sticks.', portable: false, refusal: 'It is attached to the desk, which is attached to your career.', tags: [], onUse: [ { if: '!flag:drawer_open', then: 'open_drawer' }, { say: ['The drawer is already open. It holds nothing else of value.'] }, ], }, badge: { name: 'badge', aliases: ['id', 'card'], description: 'Your employee badge. The photo is from a better year.', portable: true, tags: [] }, stapler: { name: 'stapler', description: 'Heavy. Reliable. Faintly menacing.', portable: true, tags: ['weapon'] }, } ``` `name` is what players see; `aliases` are other words they might use. Every word of three or more letters a player types has to be in one of them (or the ID), so “metal drawer” finds nothing here until `metal drawer` is an alias; see [How names are matched](https://mrballistic.github.io/brass-lantern/guide/building-worlds/#how-names-are-matched). `portable: false` items stay put, and `refusal` is what trying to take one says. The badge isn’t in any room: opening the drawer hands it over. **`onUse`** is a list of rules, and the first one whose conditions hold wins. Each rule can fire an event (`then`), print lines (`say`), require another item nearby (`with`), or test a condition (`if`). End with a plain `say` as the fallback. PUSH, PULL and PRESS mean USE, and OPEN falls back to use rules on anything that isn’t a container, so “open drawer” works. Items also have `onTake`, `onWear` and `onSmash` hooks, and `instead`/`after` rules for any verb; see the [schema](https://mrballistic.github.io/brass-lantern/reference/world-schema#item). ## People ```ts npcs: { gary: { name: 'Gary', description: 'Gary from Accounts. He has opinions about the water cooler.', onGive: { mug: 'gary_mug' }, refuse: { badge: '“That’s your badge, man. Keep your badge.”' }, refuseGift: '“I don’t want that.”', }, }, dialogue: { gary: { default: '“Has anyone seen my mug?” Gary asks nobody in particular.', 'flag:gary_happy': '“Thanks for the mug. Hey, if that machine eats your money, just hit it. Hard. With something heavy.”', }, }, ``` GIVE MUG TO GARY takes the mug and fires `gary_mug`. With only one person in the room, GIVE MUG is enough. **`refuse`** declines a specific item and lets the player keep it. Gary is a proper name, so he has `article: ''`: the engine’s own lines about him (FOLLOW GARY, an order he can’t carry out) say “Gary”, not “the Gary”. **Dialogue** maps conditions to lines; TALK TO uses the **last** entry whose condition holds, so list them from least to most advanced. Here, Gary’s advice only appears once he’s happy, which turns him into a hint. ## Events and flags Events are named lists of lines. Most are prose. Lines in square brackets are shown to the player *and* change the game: ```ts events: { intro: [ '═══════════════════════════════', ' SNACK ATTACK', '═══════════════════════════════', '✨ IT IS 12:01. YOU ARE HUNGRY.', ], open_drawer: [ 'You yank the drawer open. Inside, under a decade of sticky notes: your badge.', '[Added to inventory: badge]', '[Flag set: Drawer open]', ], gary_mug: [ '💼 Gary takes the mug and looks at it like a long-lost child.', '💼 “You’re a good one,” he says, and lowers his voice. “That machine? Hit it.”', '[Flag set: Gary is happy]', ], }, flagLabels: { 'drawer open': 'drawer_open', 'gary is happy': 'gary_happy', }, ``` `[Flag set: Gary is happy]` sets whichever flag `flagLabels['gary is happy']` names. `[Added to inventory: badge]` and `[Badge consumed]` match items by their display **name**. `intro` plays when a new game starts. Line prefixes also choose the styling and typewriter speed: `💼` lines are scripted moments, `“` lines are narration. The full list is in [conditions and events](https://mrballistic.github.io/brass-lantern/reference/conditions-and-events#how-lines-are-styled). ## The ending Snack Attack wins with the **finale**: smash a particular item, in a particular room, while holding a particular other item. (The other way to end a game is the `end` effect; see [Endings](https://mrballistic.github.io/brass-lantern/reference/world-schema#endings) and the [two-room game](https://mrballistic.github.io/brass-lantern/guide/building-worlds/two-rooms).) ```ts finale: { room: 'break_room', item: 'vending_machine', with: 'stapler', event: 'free_lunch', epilogue: [ { if: 'flag:gary_happy', then: 'ending_with_gary' }, { if: '!flag:gary_happy', then: 'ending_alone' }, ], footer: 'footer', bareHanded: 'hurt_hand', bareHandedAgain: 'Your hand still hurts. The machine is winning.', wrongRoom: 'Not here. Save it for the machine.', }, ``` SMASH MACHINE WITH STAPLER plays `free_lunch`, then every epilogue event whose condition holds, then the score, then `footer`, and the game ends. The epilogue is where choices pay off: help Gary and you share the pretzels. ## The extras ```ts hints: [ { if: '!has:badge', text: 'The break room needs a badge. Check your desk.' }, { if: '!flag:gary_happy', text: 'Gary lost his mug. Gary knows things.' }, { if: '!flag:lunch_freed', text: 'The machine has your pretzels. Bring something heavy.' }, ], scoring: [ { flag: 'drawer_open', points: 10 }, { flag: 'gary_happy', points: 20 }, { flag: 'lunch_freed', points: 20 }, ], ranks: [{ min: 0, title: 'Intern' }, { min: 50, title: 'Lunch Liberator' }], confused: ['The office hums, uncomprehending. (Type HELP.)'], ambient: [ { if: 'in:break_room & !flag:lunch_freed', every: 2, lines: ['The vending machine hums, smug.', 'The pretzels sway, just out of reach.'], }, ], ``` - **HINT** shows the first hint whose condition holds, so order them along the critical path. - **SCORE** sums points for flags that are set, and names the highest rank reached. - **`confused`** replies rotate for input nothing could understand. - **`ambient`** lines interrupt every few turns while their condition holds. They’re good for a ringing phone, a ticking clock, or a smug vending machine. ## Play it Pass your world to `mountGame` as one of its `cartridges` (`{ kind: 'world', id: 'snack-attack', title: 'SNACK ATTACK', world: tutorial }`; see [Using the library](https://mrballistic.github.io/brass-lantern/guide/using-the-library#put-a-game-on-a-page)). Snack Attack itself ships with the engine: `import { tutorial } from '@brass-lantern/engine/worlds'`. The winning transcript for Snack Attack: ``` > open drawer > take stapler > north > take mug and give mug to gary > talk to gary > north > smash machine with stapler ``` Next: [test your world](https://mrballistic.github.io/brass-lantern/guide/testing), so it stays winnable as it grows. --- Source: https://mrballistic.github.io/brass-lantern/guide/building-worlds/recipes # Recipes Each recipe is a small, complete world about one idea, in [`packages/engine/src/worlds/examples/`](https://github.com/mrballistic/brass-lantern/tree/main/packages/engine/src/worlds/examples). Every transcript on this page is what [a test](https://github.com/mrballistic/brass-lantern/blob/main/packages/engine/tests/worlds/examples/pages.test.ts) 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](https://mrballistic.github.io/brass-lantern/guide/building-worlds/two-rooms): 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](https://mrballistic.github.io/brass-lantern/guide/building-worlds/two-rooms#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](https://mrballistic.github.io/brass-lantern/reference/conditions-and-events#conditions) 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](https://mrballistic.github.io/brass-lantern/reference/world-schema#rules) has the whole shape, and [Events and effects](https://mrballistic.github.io/brass-lantern/reference/conditions-and-events#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](https://mrballistic.github.io/brass-lantern/guide/building-worlds/two-rooms) 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`](https://mrballistic.github.io/brass-lantern/reference/conditions-and-events#events-and-effects) 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](https://mrballistic.github.io/brass-lantern/reference/world-schema#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`](https://mrballistic.github.io/brass-lantern/reference/world-schema#world). **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](https://mrballistic.github.io/brass-lantern/reference/world-schema#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](https://mrballistic.github.io/brass-lantern/reference/world-schema#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](#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](https://mrballistic.github.io/brass-lantern/reference/world-schema#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](https://mrballistic.github.io/brass-lantern/reference/world-schema#style)** (`style: 'infocom'`), like Zork: rooms show no exit line, and a room you’ve been in before shows just its name. - **[`capture`](https://mrballistic.github.io/brass-lantern/reference/world-schema#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](https://mrballistic.github.io/brass-lantern/reference/world-schema#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](https://mrballistic.github.io/brass-lantern/reference/world-schema#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. --- Source: https://mrballistic.github.io/brass-lantern/guide/testing # Testing a world A world is data, and data rots quietly: an exit points at a renamed room, a flag label gets a typo, the ending becomes unreachable. Two kinds of test catch nearly all of it, and both run in plain Node with any test runner. The examples here use [Vitest](https://vitest.dev/). ## Play it `createGame` from `@brass-lantern/engine` runs a world with no browser: feed it the lines a player would type, and read the replies. A test reads like a transcript: ```ts import { describe, expect, it } from 'vitest'; import { createGame } from '@brass-lantern/engine'; import { tutorial } from '@brass-lantern/engine/worlds'; describe('Snack Attack', () => { it('plays to the best ending', () => { const game = createGame(tutorial, { seed: 1 }); const text = [ 'open drawer', 'take stapler', 'north', 'take mug and give mug to gary', 'talk to gary', 'north', 'smash machine with stapler', ].flatMap((line) => game.send(line).lines).join('\n'); expect(game.state.gameOver).toBe(true); expect(text).toContain('You share the pretzels'); expect(text).toContain('[Score: 50 of 50'); }); }); ``` Swap `tutorial` for your own world (`import { myWorld } from './my-world'`). `seed` makes the game’s randomness repeatable, so a world with chance effects or fights plays the same way on every run. Write at least two: the **shortest win**, and a run that **earns every point**. When either breaks, you’ve changed the critical path or the scoring, on purpose or not. To pin every word, compare the whole transcript: `expect(text).toMatchInlineSnapshot()` fills itself in on the first `npx vitest -u`, and from then on any change to the text fails the test until you look at it. `send` takes a whole line, as the player would type it: chained commands, pronouns, AGAIN, OOPS, UNDO and the answer to a question all work. When the game asks “Which door do you mean?”, the reply’s `awaiting` is true and the next `send` answers it. There’s no intent server in a test, so write commands the regex parser understands, and name things with words the world knows. Every word of three or more letters has to be in the thing’s name, an alias or its ID, so `take red ball` misses a thing called only “ball” (“You don’t see a “red ball” here.”). In brass style a close misspelling of a word in the name also matches. In the browser the intent server might rescue that; in a test it fails, which is how you find the alias players will need. Since 2.1.0 this is stricter than it was, so a test that passed on 2.0 can start failing here: add the alias (see [How names are matched](https://mrballistic.github.io/brass-lantern/guide/building-worlds/#how-names-are-matched)). ## Check the data `auditWorld(world)` returns a list of problems, one string each, and an empty list when it finds none: ```ts import { expect, it } from 'vitest'; import { auditWorld } from '@brass-lantern/engine'; import { myWorld } from './my-world'; it('has no broken references', () => { expect(auditWorld(myWorld)).toEqual([]); }); ``` It finds the mistakes that fail silently in play: - effects naming items, rooms, events or endings that don’t exist, and unknown effects; - events named by rules, `onEnter`, `onTake`, `onWear`, `onSmash`, `onGive`, daemons and the finale that don’t exist; - conditions of unknown kinds, or naming items and rooms that don’t exist (in rules, triggers, exits, `requires`, daemons, hints and scoring); `direction:` takes any lowercase word, since an `orders.go` rule sees the place as typed (`direction:basement`); - exits to nowhere, doors that aren’t items, items listed in rooms or containers that don’t exist; - a world verb word that a built-in verb already owns, and the reserved IDs `player` and `number`. It doesn’t walk the map. A few more checks are worth writing for your own world. Every room reachable from the start: ```ts it('every room is reachable from the start', () => { const seen = new Set(); const queue = [myWorld.startRoom]; while (queue.length) { const id = queue.shift()!; if (seen.has(id)) continue; seen.add(id); for (const exit of Object.values(myWorld.rooms[id]?.exits ?? {})) { const to = typeof exit === 'string' ? exit : exit.to; if (to) queue.push(to); } } expect(Object.keys(myWorld.rooms).filter((id) => !seen.has(id))).toEqual([]); }); ``` More along the same lines: every `[Flag set: …]` label is in `flagLabels`; every flag in a `requires`, hint or scoring entry can actually be set by some event; every gated room has a `denial`; every `listExits` label is a real exit. **Copy style** is easy to test too. If you use curly quotes, fail on straight ones: ```ts it('uses smart punctuation', () => { const strings: string[] = []; const walk = (v: unknown): void => { if (typeof v === 'string') strings.push(v); else if (Array.isArray(v)) v.forEach(walk); else if (v && typeof v === 'object') Object.values(v).forEach(walk); }; walk(myWorld); expect(strings.filter((s) => /["']/.test(s))).toEqual([]); }); ``` That walks every string in the world, IDs and conditions included, which is fine as long as those don’t use quotes either. --- Source: https://mrballistic.github.io/brass-lantern/reference/world-schema # World schema Every field a world can use. The source of truth is [`packages/engine/src/types/world.ts`](https://github.com/mrballistic/brass-lantern/blob/main/packages/engine/src/types/world.ts), which has a comment on each one. “Condition” means a [condition string](https://mrballistic.github.io/brass-lantern/reference/conditions-and-events#conditions); “event” means a key in `events`. ## World | Field | Type | | |---|---|---| | `startRoom` | room ID | Where a new game begins. | | `onFoot?` | string[] | Terrains the player can walk into and get out of a vehicle in. Default `['land']`; a custom terrain is walkable only if listed. See [Vehicles](#vehicles). | | `rooms` | `Record` | | | `items` | `Record` | | | `npcs` | `Record` | | | `dialogue` | `Record` | What TALK TO says. | | `events` | `Record` | Lines and [effects](https://mrballistic.github.io/brass-lantern/reference/conditions-and-events#events-and-effects). `intro` plays when a new game starts. | | `flagLabels` | `Record` | Maps the lowercased label in `[Flag set: …]` lines to a flag ID. | | `typoReplies?` | string[] | Lines said when a misspelled name is matched anyway (brass style only), once per thing, taken in turn. `{typed}` is the word as typed, `{name}` the thing’s name with its article in lowercase (“the guard”), so start a reply with `{typed}` or other text rather than `{name}`. Leave it out and corrections are silent. The engine records what it has teased in `GameState.typoMocked` (optional; saved with the game). Example: `“{typed}”? You mean {name}.` | | `hints?` | `Hint[]` | HINT shows the first whose condition holds. | | `scoring?` | `{ flag, points }[]` or `{ if, points }[]` | SCORE sums points for set flags, plus conditions that hold right now (a treasure in the case), plus the `score` variable. | | `maxScore?` | number | The total SCORE reports. Default: the sum of `scoring`. | | `ranks?` | `{ min, title }[]` | The highest `min` the score reaches is the rank. | | `scoreLine?` | string | SCORE’s first line, replacing the style’s own. `{score}`, `{max}` and `{moves}` (“3 moves”) fill in: `'Your potential is {score} of a possible {max}, in {moves}.'` | | `rankLine?` | string | SCORE’s rank line, replacing the style’s own. `{rank}` fills in: `'This score gives you the rank of {rank}.'` | | `diagnose?` | `{ healthy?, wounded? }` | DIAGNOSE’s own lines for being unhurt and for being wounded, as fixed text. Unset, the engine’s wording stands. | | `idle?` | string | Reply to WAIT or SIT where they don’t lead anywhere. Default: “Time passes.” | | `quit?` | string | Reply to QUIT. | | `confused?` | string[] | Replies for input nothing understood, rotated. | | `ambient?` | `Ambient[]` | Timed interruptions. | | `finale?` | `Finale` | The win condition. | | `verbs?` | `Record` | Verbs this world adds. See [World verbs](#world-verbs). | | `vars?` | `Record` | Starting values for numeric variables. | | `seed?` | number | Seeds the random generator, for reproducible games. Default: the clock. | | `daemons?` | `{ if, then }[]` | Run after every turn the engine acts on, while `if` holds. `then` is an event name or steps. See [Time](#time). | | `darkness?` | object | Texts and behavior for dark rooms. See [Darkness](#darkness). | | `death?` | object | What dying does. See [Death](#death). | | `endings?` | `Record` | Named endings for the `end` effect. See [Endings](#endings). | | `style?` | `'brass'` or `'infocom'` | Output conventions. See [Style](#style). Default `'brass'`. | | `emptyInventory?` | string | INVENTORY with nothing carried. Default: “You are empty-handed.” | | `smashRefusal?` | string | SMASH where nothing can be smashed, or at a person. | | `title?` | string | The game’s full title, for VERSION and Infocom-style transcripts (“ZORK I: The Great Underground Empire”). | | `credits?` | string[] | Lines VERSION prints after the title. | | `carry?` | `Carry` | Carrying weight. Without it there’s no limit. See [Weight](#weight). | | `combat?` | `CombatRules` | The player’s side of fights. See [Combat](#combat). | | `scripts?` | `Record` | The code hatch: functions that return steps. See [Scripts](#scripts). | | `capture?` | `Capture` | Takes input anywhere, after the room’s own capture. See [Capture](#capture). | | `wait?` | `{ turns }` | WAIT runs the clock up to `turns` times, stopping after a turn where something happened (Zork: 3). Each counts as a move. | | `statusLine?` | `'moves'` or `'score'` | The header in brass style: `MOVES: n` (the default) or `SCORE: n MOVES: n`. Infocom style always shows the room, score and moves. | ## Room | Field | Type | | |---|---|---| | `name` | string | Shown as the room header. | | `description` | string | | | `firstDescription?` | string | Replaces `description` on the first visit only. | | `dark?` | boolean | Needs a light source to see in. | | `descriptions?` | `{ if, text }[]` | Descriptions that depend on the state of things (“a small window which is open”). The first whose condition holds replaces `description`. | | `descriptionScript?` | script name | A [script](#scripts) whose `say` lines (joined with newlines) are the description, ahead of `firstDescription` and `descriptions`. It runs on every look, so it describes the game as it is now. Any step that isn’t a `say` is ignored (and the audit flags it), a script that says nothing falls back to the next description, and the engine restores the seed afterwards, so describing never changes the game. | | `exits` | `Record` | What the player can type, and where it goes. Several labels per destination is normal. A label of `wait` or `sit` is taken by WAIT/SIT. See [Exit](#exit). | | `listExits?` | label[] | What the exit line shows, in order; each gets the compass direction that leads the same way. Omit to list every label. | | `items` | item ID[] | Items in the room at the start. | | `npcs` | NPC ID[] | | | `onEnter` | `{ if?, then, repeat? }[]` | Events fired on arrival when the condition holds: once per game, or every time with `repeat: true`. Leave out `if` and it always fires. | | `requires?` | condition | Must hold to enter. | | `denial?` | string | Shown when `requires` fails. | | `scenery?` | item ID[] | Items present here without being in the room: a door shared by two rooms, a window, the sky. Examinable and usable, never listed or taken. | | `instead?`, `after?` | `Record` | Rules for verbs used in this room. See [Rules](#rules). | | `tags?` | string[] | Free-form labels for scripts to read (`maze`, `sacred`). The engine doesn’t. | | `capture?` | `Capture` | Takes input here before it’s parsed. See [Capture](#capture). | | `water?` | boolean or condition | Water (Zork’s NONLANDBIT): only a water vehicle goes here. A condition for a room that changes (a reservoir that drains: `'!flag:low_tide'`). See [Vehicles](#vehicles). | | `air?` | boolean or condition | Air (Zork II’s balloon): shorthand for `terrain: 'air'`. Same shape as `water`. | | `terrain?` | string | What kind of ground this is, for vehicles. Default `'land'` (or `'water'` / `'air'` where those shorthands hold); a set `terrain` wins over them. Any name works (`'sand'`). See [Vehicles](#vehicles). | | `onEnd?` | `{ if, then }[]` | Run at the end of every command here, after the action and before the clock (Zork’s M-END). After WAIT’s turns when `wait` is set. | ### Exit | Field | Type | | |---|---|---| | `to?` | room ID | Where it goes. Omit for an exit that only prints `denial` (“The door is boarded.”). | | `if?` | condition | Must hold for this exit alone. | | `denial?` | string | Shown when `if` fails, or always if there’s no `to`. Default: “You can’t go that way.” | | `door?` | item ID | An item with `door: true` that must be open. A closed door says “The *name* is closed.” | | `denials?` | `{ if, text }[]` | Refusals with their own reasons, checked first; the first whose `if` holds refuses with `text` (Zork’s chimney). | | `then?` | event | Runs as the player goes through, before arriving (Zork’s exit routines). | Message-only exits aren’t listed unless `listExits` names them. ## Item | Field | Type | | |---|---|---| | `name` | string | Display name, and what event lines like `[Added to inventory: …]` match. Keep it unique. | | `aliases?` | string[] | Other words players might use. Matched, never shown. Every word of three or more letters a player types must be in the name, an alias or the ID, so add the descriptive words players will reach for: `aliases: ['red ball']`. See [How names are matched](https://mrballistic.github.io/brass-lantern/guide/building-worlds/#how-names-are-matched). | | `descriptionScript?` | script name | A script whose `say` lines are EXAMINE’s text, ahead of `description` (same rules as a room’s). See [Descriptions from state](#descriptions-from-state). | | `description` | string | EXAMINE. Leave it empty (`''`) and EXAMINE does what Zork does for an object with no text: a container lists what’s in it or says “The *name* is empty.”; anything else is “There’s nothing special about the *name*.” | | `portable` | boolean | Can it be taken? | | `refusal?` | string | Reply to taking a non-portable item. | | `tags` | string[] | Free-form; the engine doesn’t read them. | | `onTake?` | event | Fires the first time it’s taken. | | `onUse?` | `UseRule[]` | See below. | | `onWear?` | event | Fires the first time it’s worn; WEAR again says “already wearing”. | | `onSmash?` | event | Fires once, then the item is gone. | | `contains?` | item ID[] | Items that start inside or on this one. | | `container?` | `Container` | Makes it a container. See below. | | `surface?` | boolean | Things can be put on it; what’s on it is always visible and reachable. | | `scenery?` | boolean | Present but never listed (the house, the forest). | | `door?` | boolean | A door between rooms; exits name it. Uses `container` for openable/open/locked/key. | | `size?` | number | Its weight, in worlds with `carry` (Zork’s SIZE). Default 5. | | `weapon?` | boolean | Something to fight with. | | `vehicle?` | `{ travels, restsOn?, landing?, leave?, arrive?, lookScript? }` | Something the player can get into and travel in. See [Vehicles](#vehicles). | | `onEnd?` | `{ if, then }[]` | A vehicle’s end routines: while the player is aboard they run instead of the room’s. | | `burnable?` | boolean | BURN can set it alight (Zork’s BURNBIT). | | `flaming?` | boolean | It can set things alight: always, or while it’s on if it switches (Zork’s FLAMEBIT). LIGHT or TURN ON on one that isn’t `switchable` says “It’s already lit.” It gives light only if it also has `light` and is on. | | `treasure?` | number | What it’s worth (Zork’s TVALUE). The engine doesn’t read it; scripts and scoring can. | | `text?` | string | What READ shows. Default: the description. | | `initialDescription?` | string | Its own sentence in a room until first taken. | | `roomDescription?` | string | Its own sentence in a room after that. Items with neither are gathered into “You can see: …”. | | `roomDescriptionScript?` | script name | A world script whose `say` lines are its room sentence, ahead of both (Zork’s DESCFCN). It says everything itself: no “(outside the raft)” follows, and its contents aren’t listed. One that says nothing falls back to the others. | | `climbRefusal?` | `{ if?, text }` | Infocom style: climbing it up or down where there’s no way that way says `text` (Zork’s tree: “There are no climbable trees here.”). | | `switchable?` | boolean | TURN ON and TURN OFF work on it. | | `light?` | boolean | Gives light while on: it lights a dark room it’s in, carried there, or inside something open or transparent there. | | `home?` | room ID | Where it goes if the player dies carrying it. | | `article?` | string | “a”, “an”, “some” or “” in listings. | | `contentsHeading?` | string | The heading over its contents (“Your collection of treasures consists of:”). | | `instead?`, `after?` | `Record` | See [Rules](#rules). | ### Container | Field | Type | | |---|---|---| | `openable?` | boolean | Has a lid or door. Containers that aren’t openable are always open. | | `open?` | boolean | Starts open. | | `locked?` | boolean | Starts locked. | | `key?` | item ID | Locks and unlocks it. | | `transparent?` | boolean | You can see inside even when it’s closed. | | `capacity?` | number | How many items fit directly inside. | | `weight?` | number | The total weight it holds (Zork’s CAPACITY), in worlds with `carry`. | | `opened?`, `closed?` | string | Lines for opening and closing it, instead of the defaults. | You can see into a surface, an open container or a transparent one; you can reach into a surface or an open container. The parser only matches what you can see, and taking something you can see but can’t reach says which container is closed. ### UseRule Rules are tried in order and the **first** whose conditions hold runs. For two-object commands, rules on both items are checked, so one rule covers “put the disk in the drive” and “use the drive with the disk”. | Field | Type | | |---|---|---| | `if?` | condition | | | `with?` | item ID | Another item that must be in the room or carried. If the player names a second object, it must be this one. `'number'` matches a command whose second object is a number typed by the player (TURN DIAL TO 4); test which with `number:`. | | `then?` | event | | | `say?` | string[] | Lines printed without changing anything. | USE also covers PUSH, PULL, PRESS and ATTACH X TO Y. OPEN and PUT fall back to an item’s use rules when it isn’t a container (or isn’t in your hands), so OPEN DRAWER works on a drawer that has only a use rule. Using an item with `onWear` and no matching rule wears it. Verbs like SLEEP, UNPLUG or INSTALL are [world verbs](#world-verbs) now: declare them and give items `instead` rules. ## Rules Items and rooms can carry rules for any verb, built-in or declared by the world: ```ts instead: { move: [{ if: 'flag:rug_moved', say: ['It won’t budge again.'] }, { then: 'rug_moved' }] }, after: { take: [{ if: '!flag:took_egg', then: 'took_egg' }] }, ``` - **`instead`** rules run in place of the verb’s default. - **`after`** rules run after the default succeeds and changes something. - **Lookup order:** the target item’s rules, then the indirect item’s, then the room’s. The first rule whose `if` holds (and whose `with` matches the other object, when given) wins. - A rule is the same shape as a UseRule (above). - **`as: 'target'` or `'indirect'`** limits a rule to its item’s role in the command (Zork’s PRSO and PRSI): a tube that refuses things put *into* it, not itself put somewhere. - **`prep`** limits it to a preposition: `prep: 'in'` answers PUT … IN, not PUT … ON. - **In Infocom style** the second object’s rules are asked before the first’s, as Zork’s PERFORM does (POUR WATER ON BELL asks the bell first). - **`continue: true`** on an `instead` rule runs it and then lets the verb’s default go on as well (Zork’s “print, then RFALSE”): a line before the TAKE that still happens. - **On a character**, rules answer verbs aimed at it, including `order` (see [NPC](#npc)). - **The older hooks still work:** `onUse` is `instead.use`, and `onTake` is a one-shot `after.take`. ## World verbs A world can add verbs without touching the engine or the intent server: ```ts verbs: { pray: { words: ['pray'], target: 'none', reply: 'If you pray enough, your prayers may be answered.' }, move: { words: ['move', 'shift'], target: 'required' }, drive: { words: ['drive', 'drive to'], target: 'optional', go: true }, }, ``` | Field | Type | | |---|---|---| | `words` | string[] | Words and phrases that mean it. Phrases are matched before built-in verbs, single words after. | | `target` | `'none'`, `'optional'`, `'required'` or `'text'` | `'text'` takes the rest of the line as typed words (SAY, ANSWER), read by `said:`. | | `indirect?` | string[] | Prepositions that introduce a second object (`with`, `on`). | | `reply?` | string | When no rule applies. Default: “Nothing happens.” `{target}` is replaced by the object’s name, `{a target}` by its name with an article. | | `held?` | boolean | The object must be something you hold, or can see inside something you hold (Zork’s HELD): POUR WATER means the water in your bottle. | | `go?` | boolean | Treat it as GO: through the target exit, or the exit labeled with the verb’s ID. `instead.go` rules (the vehicle’s, the room’s) answer it as they answer GO. | | `afterBuiltIns?` | boolean | Its words may be built-in words: it reads only lines no built-in verb reads, after them (Zork III’s bare TURN DIAL, where the engine’s TURN wants TO or WITH). | - A world verb does nothing by itself: give items or rooms `instead` rules for it. - With no target, it looks for a rule on the room, then on anything in reach (SNOOZE finds the alarm clock). - A word a built-in verb already uses (`take`, `open`, …) is ignored, unless the verb is `afterBuiltIns`; `auditWorld` reports any (“verb word … is a built-in”). Since 2.1.0 that includes `follow`: a world that declared its own FOLLOW should drop it, and its `orders.follow` rules keep working (see [Orders](#orders)). - A `go: true` verb meets the same `instead.go` rules as GO, so a rule that refuses to leave refuses walking and driving alike: ```ts verbs: { drive: { words: ['drive', 'drive to'], target: 'optional', go: true } }, rooms: { garage: { // … instead: { go: [{ if: '!flag:gate_open', say: ['The gate is shut.'] }] }, }, }, ``` ``` > drive north The gate is shut. > north The gate is shut. ``` ## Style - **`'brass'`** (the default) lists items as “You can see: …”, says “Taken: lamp.”, and shows an exit line. - **`'infocom'`** follows Zork’s conventions: - “There is a sword here.” and “Taken.”; - no exit line; - a room you’ve visited shows just its name and contents unless you LOOK (SUPERBRIEF shows only the name, in either style); - lists newest first; - SCORE says “Your score is 15 (total of 350 points), in 40 moves.”, and (like VERBOSE, BRIEF and SUPERBRIEF) runs no clock: no move, no timers, though the room’s end routine still runs; - the header shows the room, score and moves, like Zork’s status line; - questions, TAKE ALL and transcripts use Zork’s wording; - bookkeeping lines like `[Flag set: …]` act without being shown; - each thing’s contents are listed right after it; - READ takes the thing first (“(Taken)”), EXAMINE of a thing with no description reads it, and opening a container whose one untouched thing has a first-seen sentence says “The coffin opens.” and that sentence; - a door with no lines of its own “opens” and “is now closed”, and OPEN and CLOSE say “It is already open.” and “It is already closed.”; - PUT … ON something that isn’t a surface says “There’s no good surface on the …”; - a room’s `scenery` (Zork’s local globals) only answers to a word when nothing else in reach does; - the second object’s rules come before the first’s; - untouched things’ first-seen sentences are listed before everything else; - EXAMINE of a closed box says “The box is closed.” ## Time After every turn the engine acts on (never after a misunderstood command), these happen in order: 1. **The room’s end routines** (`onEnd`, Zork’s M-END). 2. **Fuses** count down, and those reaching zero run. A fuse is set by the `schedule` effect and removed by `cancel`; one set during a turn starts counting the next turn. 3. **Daemons** run, in order, each while its `if` holds. 4. **Ambient lines** print. A lamp that burns down is a variable and a few daemons: ```ts vars: { lamp_fuel: 185 }, daemons: [ { if: 'on:lamp', then: [{ add: 'lamp_fuel', by: -1 }] }, { if: 'on:lamp & var:lamp_fuel=85 & here:lamp', then: ['The lamp appears a bit dimmer.'] }, { if: 'on:lamp & var:lamp_fuel<0', then: 'lamp_dies' }, ], ``` With the world’s `wait` set, WAIT runs steps 1 to 3 up to that many times, stopping after one where something happened (a line printed or a fuse fired), and the room’s end routines run after them. A **move** is one of these turns: MOVES in the header and SCORE count them. Commands that take no game time (VERBOSE, BRIEF, SUPERBRIEF, UNDO, SAVE, RESTORE, SCRIPT, VERSION, a question back to the player) don’t count, and nothing runs after them. ## Darkness | Field | Type | | |---|---|---| | `look?` | string | LOOK and arriving in an unlit dark room. Default: “It is pitch black.” | | `tooDark?` | string | Acting on something you can’t see. Default: “It’s too dark to see.” | | `fall?` | string | When the room goes dark around you. Default: “It is now pitch black.” | | `arrive?` | string | Said on arriving in an unlit room, before its darkness line (Zork’s “You have moved into a dark place.”). | | `stumble?` | `{ chance, then, aboard? }` | Walking from an unlit dark room into another: `chance`% of `then` instead (Zork’s grue, 80), or `aboard` in a vehicle. | | `litIf?` | condition | While it holds, every room is lit (Zork’s ALWAYS-LIT, for a spirit). Mustn’t use `lit:`. | | `blunder?` | `EventStep[]` | Run when the player tries a direction with no exit in the dark. Zork’s grue: `[{ chance: 80, then: [{ die: '…' }], else: ['You can’t go that way.'] }]`. | In an unlit dark room you can only find what you’re carrying. Trying to act on anything else gets `tooDark`: an understood refusal, so the intent server isn’t asked to re-guess. EXAMINE of a character who’s there gets it too (“It’s too dark to see.”); talking to a character, giving it things, ordering it and fighting it still work in the dark. Turning a light on or off says so (`fall`, or the room’s description). Only an item with `light` that is on lights a room. A `flaming` thing with no switch (a torch, a lit match) answers LIGHT with “It’s already lit.”, and gives no light by itself. If it should also light the room, give it `light: true` and switch it on with a `{ switch: 'torch', on: true }` effect. ## Death | Field | Type | | |---|---|---| | `message?` | `(string or { if, text })[]` | Printed after the cause; an entry with `if` only while it holds (Zork’s “Bad luck, huh?”). | | `penalty?` | number | Added to the score. | | `lives?` | number | Deaths survived before the final one. | | `respawn?` | room ID | Where the player wakes. | | `resurrection?` | string[] | | | `scatter?` | room ID[] | Carried things are spread over these, at random (seeded). Things with a `home` go there instead; with no scatter rooms, they stay where the player fell. | | `treasures?` | `'dark'` or `{ to }` | `'dark'`: treasures go to an unlit land room instead, walking the rooms in order at even odds each (Zork’s RANDOMIZE-OBJECTS). `{ to: 'case' }`: treasures without a `home` all go to that room, item or character (a trophy case), drawing no randomness. | | `keepTimers?` | event[] | Timers a death leaves running, with their counts. Every other timer is cleared. | | `final?` | string[] | The last death, which ends the game. | | `then?` | event | Runs after a resurrection, to reset things (Zork’s trap door, unbarred). | | `variants?` | `{ if, resurrection?, respawn?, then?, before? }[]` | The first whose `if` holds (decided as you die) replaces those fields; `before` runs ahead of the respawn. Zork sends you to Hades as a spirit once you’ve seen the Altar. | | `instead?` | `{ if, lines }[]` | Checked first: the first that holds prints its lines (not the cause) and ends the game. Dying while already dead. | The `die` effect uses it. Without a `death` block, dying prints the cause and ends the game. Pending fuses are canceled on death, except those in `keepTimers`. ## Endings `endings: { victory: { lines: [...], score: true, footer: ['Type RESTART to play again.'] } }`. The `end` effect plays one: its lines, the score and rank if `score` is set, then the footer, and the game is over. The `finale` is an ending too, reached by smashing. ## NPC | Field | Type | | |---|---|---| | `name` | string | | | `article?` | string | Brass style: the article in the engine’s lines that start with the character’s name (“The robot can’t go that way.”, FOLLOW’s reply). Default `'the'`; `''` for a proper name, so Floyd isn’t “The Floyd”. Mid-sentence it’s lowercased: “You’d rather the robot came to you.” See [Orders](#orders). | | `description` | string | EXAMINE. In an unlit dark room, EXAMINE of a character gets the [darkness](#darkness) reply. | | `onGive?` | `Record` | GIVE hands the item over and fires the event. | | `refuse?` | `Record` | Declines that item; the player keeps it. | | `refuseGift?` | string | Declines anything else. | | `holds?` | item ID[] | What it carries at the start. Things a character holds can’t be seen or taken. | | `descriptions?` | `{ if, text }[]` | Its line in the room and its EXAMINE reply, by state; the first whose condition holds wins. | | `descriptionScript?` | script name | A script whose `say` lines are that description, ahead of `descriptions`. | | `instead?`, `after?` | `Record` | Rules for verbs aimed at it: THROW X AT it, GIVE, TAKE, a world verb. | | `combat?` | `Combatant` | Makes it someone you can fight. See [Combat](#combat). | | `aliases?` | string[] | Other words for it (“robber”, “man”). Matched like an item’s: every word of three or more letters typed must be in the name, an alias or the ID. | | `scenery?` | boolean | Present but not listed: the room’s own description mentions it (Zork’s cyclops). `{ npcState, scenery }` changes it in play. | | `hidden?` | boolean | Starts hidden: in its room for scripts, but not seen, listed, matched or fought until revealed. | | `topics?` | `Record` | ASK or TELL it ABOUT a topic. A list is tried in order; the first entry whose `if` holds answers. Text that names an event runs it. | | `topicAliases?` | `Record` | Other words for a topic. A topic keyed or aliased `me` or `myself` answers ASK *X* ABOUT ME and ABOUT MYSELF (either word reaches it). | | `noTopic?` | string | For a topic it has nothing on. Default: its TALK TO line. | | `refuseOrder?` | string | Its answer to an order. Default: “*Name* ignores you.” | | `orders?` | `Record` | Rules for orders, by the inner command’s verb. See [Orders](#orders). | | `obeys?` | `('go' \| 'take' \| 'drop' \| 'give')[]` | Built-in orders it carries out itself when no order rule answers. | | `obeyReplies?` | `Record` | Its acknowledgement when it obeys one of those. Default: “Okay.” | | `follows?` | condition | While it holds, the character goes where the player goes. See [Followers](#followers). | | `followLine?` | string | What it says on arriving after you. Unset: brass style prints “*Name* follows you.”; Infocom style prints nothing. | | `heardFrom?` | room ID[] | Rooms from which the player can give it orders while it’s elsewhere (Zork III’s dungeon master on the parapet, ordered from the cell); its orders are carried out where it stands. | Characters’ places and states live in the game state (`npcs`), starting from the rooms that list them. In brass style the room shows “Present: …”; in Infocom style each character prints its own line. **Hidden characters.** A character that’s `hidden` (from the start, or by the `npcState` effect with `hidden: true`) is still in its room: `ctx.npcIn` and conditions like `with:` see it, but the player doesn’t, and it doesn’t fight. `{ npcState: 'thief', hidden: false }` reveals it. The `seen:` condition is true only when it’s in the player’s room and not hidden. ### Orders “*name*, *command*”, “tell *name* to *command*” and a bare “tell *name*” are orders. What happens, in order: 1. **`instead.order`** rules on the character answer first, as for any verb. In one, `ctx.command.words.indirect` is the order as typed. A character with only `instead.order` (no `orders`, no `obeys`) answers, or says `refuseOrder`, and the rest of the line carries on. 2. Otherwise the order is read as a command of its own, with its objects resolved among what the character can reach in **its** room (ME or MYSELF is the player, YOURSELF the character itself; a number typed as an object counts). A word that names nothing there is a miss that changes nothing, so the intent server may still read it; one that matches several things asks which, taking no time. A character can’t refer to what the player carries. 3. **`orders`** rules are keyed by the inner command’s verb: `orders: { push: [...], take: [...] }`. They are the same shape as other [rules](#rules), and their conditions see the inner command (`target:`, `indirect:`, `number:`, `said:`, `direction:`). **The first word typed is tried first**, when it has a table (PUSH reads as USE, so “robot, push the button” runs `orders.push` even when `orders.use` exists); otherwise the parsed verb’s table (“robot, press the button” runs `orders.use`). A rule with `continue: true` runs and then lets step 4 go on. 4. If no rule answered and the verb is in `obeys`, the character performs it, saying `obeyReplies[verb]` (default “Okay.”): - **`go`**: it walks that exit of its room. Nothing is printed but its reply: no leaving or arriving lines. No exit, or one that refuses, is a miss that says why (“The robot can’t go that way.”). In Infocom style it walks as the player does: no exit says “You can’t go that way.”, and an exit that refuses says its own refusal. - **`take`** and **`drop`**: the thing moves to or from the character (weight limits apply only to the player). One it can’t take or drop is a miss that says why, by name: “The robot can’t take the dial.”, “The robot doesn’t have the sock.” In Infocom style a take it can’t do says the player’s own take line. - **`give`**: “give me the key” moves it from the character to the player (the second object is the player). 5. Otherwise `refuseOrder`, or “*Name* ignores you.” In brass style the why-lines in step 4 start with the character’s `article` (default “the”): “The robot can’t go that way.” Give a proper name `article: ''` and it reads “Floyd can’t go that way.” **An order to a character with `orders` or `obeys` ends the rest of the line** (Zork clears the typed-ahead commands), unless it was a miss. That includes an obeying character’s refusal, which takes a turn like any understood command. A character with neither answers orders only through `instead.order` and `refuseOrder`. **`heardFrom`** lists rooms from which the player can give a character orders while it’s somewhere else. Its orders are carried out where it stands (Zork III’s dungeon master, ordered from the cell while he stands on the parapet). ```ts robot: { name: 'robot', obeys: ['go', 'take'], obeyReplies: { go: 'Whirr, buzz, click!' }, orders: { push: [{ if: 'target:red_button', then: 'lift_cage' }], use: [{ say: ['The robot ignores that.'] }], // “robot, press the button”; “robot, push the button” runs orders.push }, }, ``` ### FOLLOW FOLLOW is a built-in verb. With no rule of the world’s: - bare FOLLOW asks “What do you want to follow?”; - FOLLOW *character* points the player at the order form: “You’d rather Floyd came to you. Try FLOYD, FOLLOW ME.” (with `article: ''`); - FOLLOW *thing* says “You can’t follow that.”; - FOLLOW *something not here* is a miss, so the intent server may read it. An `instead.follow` rule on the character (or the room) answers FOLLOW *character* instead. **“*X*, FOLLOW ME”** is an order: it runs `orders.follow`, where ME is the player (`target:player`). The engine keeps no following state of its own, so a rule that agrees sets it with the `follow` effect, and the character then goes where the player goes ([Followers](#followers)): ```ts floyd: { name: 'Floyd', article: '', description: 'Floyd, a cheerful robot.', orders: { follow: [{ if: 'target:player', then: 'floyd_tags_along' }] }, }, // events: floyd_tags_along: ['“Floyd go too!” he squeaks.', { follow: 'floyd' }], ``` ``` > follow floyd You’d rather Floyd came to you. Try FLOYD, FOLLOW ME. > floyd, follow me “Floyd go too!” he squeaks. > north 📍 Study … Floyd follows you. ``` A character with no `orders.follow` rule answers “floyd, follow me” as any other order it has no rule for (step 5 above). ### Followers `follows` is a condition on the character; while it holds, after each move the **player** makes (GO, doors, ENTER, CLIMB; not a script’s `goTo` or a `moveVehicle`), the character moves into the player’s new room if it was in the room the player left, and is awake and not hidden. It’s checked after the move, so `in:ROOM` sees the new room. Its `followLine` prints as it arrives. The `follow` and `unfollow` effects set and clear the character’s `following` state (an optional field of its game state, not a flag), which `following:NPC` reads, for a switch rather than a condition. Characters who arrive take the engine’s placing order, so room listings keep it. ## Weight ```ts carry: { limit: 100, self: 5, fumble: { over: 7, chance: 8 } }, ``` | Field | Type | | |---|---|---| | `limit` | number | The total weight the player can carry when healthy. | | `self?` | number | The player’s own weight, counted in (Zork’s ADVENTURER is 5). | | `fumble?` | `{ over, chance }` | Carrying more than `over` things, each TAKE has `count × chance` percent to fumble. | | `tooHeavy?`, `tooHeavyHurt?`, `fumbled?` | string | The refusals. | An item weighs its `size` plus everything inside it. Wounds lower the limit and healing restores it. ## Combat A character with `combat` can be fought: ATTACK *it* WITH *a weapon*. The engine owns the mechanics, ported from Zork I: strength against strength picks one of six blow tables, and a seeded roll picks the result (missed, staggered, wounded, knocked out, killed, disarmed). After every turn the engine acts on, characters in the room who are fighting swing back. | `Combatant` field | Type | | |---|---|---| | `strength` | number | The troll: 2. | | `weapon?` | item ID | What it fights with, while it holds it. | | `fears?` | `{ item, by }` | The player’s weapon that weakens it (the troll fears the sword, by 1). | | `wake?` | number | Percent added each turn to its chance of waking while out cold. Default 25. | | `firstStrike?` | number | Percent chance each turn to start a fight while you’re in the room. | | `messages?` | `Partial>` | Its blows at the player, one picked at random. `{weapon}` and `{defender}` are filled in. | | `onDeath?`, `onUnconscious?`, `onWake?` | event | When it dies, is knocked out, comes round. | | `onBusy?` | event | Instead of swinging when its weapon is on the floor (the troll recovers his axe). | | `CombatRules` field | Type | | |---|---|---| | `messages?` | same | The player’s blows. | | `strength?` | `{ min, max }` | The player’s strength, from `min` at no score to `max` at `maxScore` (Zork: 2 to 7). | | `cureWait?` | number | Turns for one wound to heal (Zork: 30). | | `texts?` | `Partial>` | Fixed lines: refusals, the death line, the fog. Defaults: Zork’s words in Infocom style, short ones in brass. | **In Infocom style** ATTACK works the way Zork’s parser does: with one weapon in hand, `kill troll` picks it (“(with the sword)”); otherwise it asks what to attack with; a weapon you aren’t holding is refused without taking a turn. DIAGNOSE reports your wounds. A [recipe](https://mrballistic.github.io/brass-lantern/guide/building-worlds/recipes#a-guard-to-fight) shows a whole fight. ## Numbers, typed words and prepositions **Numbers.** A number in an object slot is the player’s typed number (Zork’s INTNUM): `turn dial to 4`, `set year to 776`. It’s digits up to 1000, or H:MM as minutes (hours under 8 count as afternoon); a bigger number is just an unknown word. Both forms are built-in and go through rules as the verb `turn` with the second object `number`. With no rule, Infocom style says Zork’s “This has no effect.”; brass style misses, so the intent server may still read the input. A bare TURN X keeps its own meaning. The command keeps the digits as typed, so a number no rule wants misses with them (“take 5”: “You don’t see a “5” here.”); `number` is a reserved ID, like `player`. Digits that name a thing in sight or a character here (an item named “locker 12” or aliased `5`; whole words only, so “1” is not locker 12) are that thing, as Zork’s parser reads a word it knows before trying a number: `turn dial to 12` with locker 12 present is TURN DIAL WITH the locker, its rules fire, and `number:12` still holds. Only when nothing is called that are the digits `number`. TURN X WITH a thing, with no rule, says “This has no effect.” in every style. ```ts dial: { instead: { turn: [ { with: 'number', if: 'number:4', then: 'door_opens' }, { with: 'number', say: ['The dial clicks and nothing happens.'] }, ] }, }, ``` - **`number:N`** and `numberN` read it in conditions; **`{ setVar: 'year', from: 'number' }`** stores it; **`{number}`** in a line prints it, and `ctx.number` has it in a script. - A rule with `with: 'number'` matches only a command whose second object is a number. - An order can carry one (“robot, turn the dial to 4” runs `orders.turn` with `number:4`). **Typed words.** A world verb with `target: 'text'` (SAY, INCANT, ANSWER) takes the rest of the line as typed words. They are never resolved to things, so naming nothing is no miss. Outer quotes are dropped and whitespace collapsed. **`said:WORDS`** matches them as lowercase whole words, with punctuation and quotes ignored (`said:a well` matches `answer “A well.”`), and `ctx.text` has them. The verb consumes the whole rest of the line, so it ends the line: `say "well". west` drops WEST, as in Zork. A quoted phrase isn’t split at its full stops or commas. Only double quotes (straight or curly) quote; a single quote is an apostrophe (`answer 'don't know'` keeps every word). This applies to every world: a quoted “. ” doesn’t end a command. Unlike Zork, where only a quoted phrase is typed words, **unquoted text counts too**: `answer well` solves a riddle that wants “a well”. **Prepositions.** These forms go through the ordinary rules. With no rule, Infocom style gives Zork’s reply below; brass style misses with the same line (all but READ, which reads), so the intent server can still read the input. A rule answers in both styles. | Form | Rule verb | Built-in reply | |---|---|---| | PUT/PUSH/SLIDE X UNDER Y | `put`, `prep: 'under'` | “You can’t do that.” | | PUT X BEHIND Y | `put`, `prep: 'behind'` | “That hiding place is too obvious.” | | THROW X OFF/OVER Y | `throw`, `prep: 'off'` or `'over'` | “You can’t throw anything off of that!” | | READ X THROUGH/WITH Y | `read`, with the second object | reads X | | PUSH X *direction*, PUSH X TO Y | `push`, `direction:DIR` / the second object | “You can’t push things to that.” | **ME.** ME and MYSELF in an object slot name the player; so do SELF and YOURSELF, unless something in sight (or someone here) is named or aliased that. Rules match them with `target:player` or `indirect:player` (and `with: 'player'`). The engine adds no replies of its own for ME: a verb aimed at it with no rule misses with the word typed (“You don’t see a “me” here.”), so a world says what Zork says (“You can’t tie anything to yourself.”) in a rule. The reserved ID `player` is never matched against a thing’s or character’s name (a “record player” is safe from TAKE ME); typed, “player” is an ordinary word. In an order, ME is the speaker (“robot, give me the key”) and YOURSELF the character (“robot, push yourself”). ASK *X* ABOUT ME (or MYSELF) reaches a topic keyed or aliased `me` or `myself`, and nothing else: `topicAliases: { you: ['me'] }` answers both. ## Descriptions from state - **Templates.** `{var:NAME}` (0 when unset) and `{number}` fill in from the game in any room, item or character description, a `descriptions` entry, an event line, and an item’s `initialDescription` and `roomDescription` wherever they list it (the room, a container’s contents, a surface, opening a container). A `{number}` with no number typed stays as written. Expanding reads the game and changes nothing. ```ts tally: { name: 'tally board', description: 'Chalk marks.', portable: false, tags: [], roomDescription: 'A tally board on the wall reads {var:tally}.' }, ``` With `tally` at 3, LOOK lists “A tally board on the wall reads 3.” - **`descriptionScript`** on a room, item or character names a [script](#scripts) that builds the description from the game: its `say` lines, joined with newlines. It comes first, ahead of `descriptions`, `firstDescription` and `description`. It’s for descriptions a condition list can’t say: a grid of cells, a dial’s setting, a room that lists its own exits. - **`roomDescriptionScript`** on an item is its sentence in a room listing, ahead of its other sentences; it says everything itself, so no “(outside the boat)” follows and its contents aren’t listed (Zork’s DESCFCN). - **`vehicle.lookScript`** describes a vehicle from inside. See [Vehicles](#vehicles). - A description script may use `ctx.roll`, but the engine puts the seed back afterwards, so looking never changes the game. A script that says nothing falls back to the next description. The audit runs each description script once on a new game and flags a step that isn’t a `say`, or a script name that doesn’t exist. ## Scripts The code hatch, for behavior data can’t express (a thief’s mind, a sword that glows near monsters): ```ts scripts: { fortune: (ctx) => [ctx.roll(3) === 1 ? '“Beware of geese.”' : '“Soup is coming.”'], }, events: { reading: [{ script: 'fortune' }] }, ``` A script gets a read-only view of the game and returns ordinary steps, which the engine runs. `ctx` has: - `state` (frozen in development and tests), `world`; - `random()` and `roll(n)`, from the game’s seeded generator, so saves and UNDO replay exactly; - `here(id)`, `carried(id)`, `holder(id)`, `room()`, `npc(id)`; - `npcIn(id, room)` (hidden or not), `hidden(id)`; - `aboard()`, the vehicle the player is in, `water(room?)` and `terrain(room?)` (the room’s terrain name); - `rooms()` in the world’s order, `visited(room)`, `tags(room)`, `lit(room?)`; - `children(place)`, what’s directly in a room, item or character, in listing order; - `test(condition)`, whether a [condition](https://mrballistic.github.io/brass-lantern/reference/conditions-and-events#conditions) holds now, through the engine’s own parser (scripts never parse conditions themselves); - `exits(room)`, the exits the player could take from that room now (`{ direction, to }[]`): no entry in its `denials` holds, its `if` holds and its door is open. It’s the same check the player’s moves and characters’ orders use, so a script never offers an exit GO would refuse; - `resolve(words, scope?)`, words read as an item the way the parser would: `'here'` (within reach, the default), `'held'` or `'all'`; null if none matches; - `number` and `text`, the number or the typed words in the command being run (TURN DIAL TO 4, SAY HELLO); - `treasure(id)`, an item’s `treasure` value or 0; - `playerStrength()`, the player’s fight strength now; - `line`, the raw input, and `action`, the parsed command, when a [capture](#capture) runs the script; `parse(text)`, which reads a command with the world’s verbs as the parser would; - `arg`, from `{ script, arg }`; - `command`, the command being run with its objects resolved to IDs, when a rule ran the script. `command.words` keeps the words typed for objects that didn’t resolve. Scripts run only where events run, so a command the engine didn’t understand still changes nothing. The world audit fails on a `script` effect naming no script. See the [scripts recipe](https://mrballistic.github.io/brass-lantern/guide/building-worlds/recipes#scripts). ## Vehicles ```ts raft: { name: 'raft', vehicle: { travels: 'water' }, container: { open: true }, … }, pond: { name: 'Pond', water: true, … }, ``` - **BOARD** (GET IN, CLIMB IN) gets in a vehicle that’s on the ground here; **DISEMBARK** (GET OUT, GET OFF, STAND) gets out, except where the room’s terrain isn’t one the player walks (water, air, sand unless `onFoot` lists it): “You realize that getting out here would be fatal.” While aboard, EXIT on its own is DISEMBARK too (Zork’s V-EXIT); otherwise it’s the direction out. In Infocom style, DISEMBARK with no object names the one vehicle in sight: “(raft)”. - **Terrains:** every room has one: `terrain`, or `'water'` / `'air'` from those shorthands, else `'land'`. The world’s `onFoot` (default `['land']`) lists those the player walks on. A vehicle’s `travels` is a terrain name or list (`['land', 'sand']`); the old `'water'`, `'air'` and `'none'` still work (`'none'` is `[]`: it never moves). Its `restsOn` (default `['land']`, unless it travels on land) are the terrains it comes to rest on from one it travels, and it can’t cross from one to another. - **Moving:** on foot, a room’s terrain must be in `onFoot`, else “You can’t go there without a vehicle.” Aboard, the destination’s terrain must be in `travels`, or in `restsOn` while you’re on a terrain in `travels`, else “You can’t go there in a raft.” (a vehicle won’t go overland). The vehicle goes wherever you go, scripted moves included, and you stay aboard. - **Landing:** `landing` (the same forms as `leave`) is said as the vehicle comes onto a `restsOn` terrain from a traveled one you can’t walk on (`landing: 'The balloon lands.'`). Unset, a water vehicle says Zork’s “The raft comes to a rest on the shore.” and a blank line; others say nothing. - **`leave` / `arrive`:** said as it leaves with you aboard (before the new room) and as it arrives (after), and by `{ moveVehicle }`. `leave` is worked out in the room being left (a script’s `ctx.room()` is the origin, and its draw comes before any grue draw), once the move is known to be allowed. `arrive` and `landing` are worked out in the new room. Each of the three is a string, a list (one line picked with the seeded generator; a plain string draws nothing), or `{ script: 'name' }` (that script’s `say` lines print; its other steps don’t run). A script’s draws from the generator are kept, unlike a description script, which restores the seed. The audit runs each script once and flags any step that isn’t a `say`. Example: a dune buggy over a sand maze is `vehicle: { travels: ['land', 'sand'], leave: ['The engine roars.', 'Sand sprays.'] }` with the maze rooms `terrain: 'sand'`. - **Aboard:** DROP puts things in the vehicle, TAKE *vehicle* says “You’re inside of it!”, and the room’s things stay in reach. The vehicle’s rules are asked before the room’s (Zork’s M-BEG): an `instead.go` on it can refuse directions. Its `onEnd` runs in place of the room’s. GO goes through rules too, so a room can have `instead.go` rules. - **Looking:** the header names the vehicle (“Pond, in the raft”); the vehicle isn’t listed, its contents are; in Infocom style the room’s things are “(outside the raft)”, as Zork’s PRINT-CONT does. A vehicle’s `lookScript` (`vehicle: { travels: 'air', lookScript: 'basketLook' }`) describes it from inside: its `say` lines follow the room’s description, and the name on a brief arrival, but not a room whose own `descriptionScript` described it in full (Zork’s vehicle M-LOOK, which a room’s M-LOOK cuts off). - Dying takes you out of the vehicle, which stays where you died. Conditions `aboard`, `aboard:ITEM`, `water:here|ROOM` and `terrain:NAME[:here|ROOM]`; effects `{ board }` and `{ disembark }`; script helpers `ctx.aboard()`, `ctx.water(room?)` and `ctx.terrain(room?)`. See the [raft recipe](https://mrballistic.github.io/brass-lantern/guide/building-worlds/recipes#a-raft-on-a-pond). ## Capture ```ts capture: { if: '!flag:quiet', script: 'echo' }, ``` A room’s capture, then the world’s, sees each command of a line before it’s parsed, while its `if` holds. The script reads `ctx.line` and returns steps to take it, or nothing to let it parse as usual. Add `{ free: true }` for a reply that takes no time. It also sees commands that arrive already parsed (AGAIN, OOPS, an answer to a question, the intent server’s reading), with `ctx.line` unset and `ctx.action` holding the command, so a limit like a spirit’s can’t be slipped past. A capture that only cares about raw words returns nothing when `ctx.line` is unset. - Taking a command ends the line: the rest is dropped, as Zork’s Loud Room drops it. - It runs ahead of the intent server, so captured input is never sent to the LLM. - SAVE, RESTORE, UNDO and the other store commands come first, so a capture can’t trap the player; it never runs once the game is over. - A capture that declines changes nothing, even if it rolled the dice. Zork uses it twice: the Loud Room, which hears everything as noise until you say ECHO, and a spirit’s limits (DEAD-FUNCTION) after death. See the [listening room recipe](https://mrballistic.github.io/brass-lantern/guide/building-worlds/recipes#a-room-that-listens). ## Dialogue ```ts dialogue: { gary: { default: '“Has anyone seen my mug?”', 'flag:gary_happy': '“Thanks for the mug.”', 'flag:gary_happy & has:stapler': '“Careful with that stapler.”', }, } ``` `default` plus any number of condition keys. TALK TO uses the **last** key whose condition holds, so order them from least to most specific. ## Ambient | Field | Type | | |---|---|---| | `if` | condition | | | `every` | number | Turns between interruptions. A turn is a command the engine acted on. | | `lines` | string[] | Printed in rotation. | ## Finale Smashing `item` in `room` while carrying `with` ends the game: `event`, then each matching `epilogue` event in order, then the score and rank, then `footer`. | Field | Type | | |---|---|---| | `room` | room ID | | | `item` | item ID | | | `with` | item ID | The tool. | | `event` | event | | | `epilogue` | `{ if, then }[]` | Checked after `event` runs, so flags it sets count. | | `footer` | event | | | `bareHanded?` | event | One-shot, for smashing `item` without the tool. | | `bareHandedAgain?` | string | Later bare-handed attempts. | | `wrongRoom?` | string | Smashing it with the tool somewhere else. | --- Source: https://mrballistic.github.io/brass-lantern/reference/conditions-and-events # Conditions and events ## Conditions Conditions are strings, used by `requires`, exits (`if`, `denials`), room `descriptions`, `onEnter`, rules (`instead`, `after`, `onUse`), dialogue keys, hints, scoring, daemons, the finale’s epilogue and ambient lines. One parser handles all of them. A missing or blank condition always holds, so a trigger (`onEnter` and the like) may leave out `if`. | Condition | True when | |---|---| | `flag:NAME` | the flag is set | | `has:ITEM` | the item is in the inventory | | `held:ITEM` | the item is carried at any depth: directly, or inside something carried (Zork’s HELD?). `has:` means carried directly | | `in:ROOM` | the player is in that room | | `visited:ROOM` | the player has been there | | `inside:ITEM:PLACE` | the item is directly in PLACE: a room, another item, or `player` | | `open:ITEM` | the container or door is open | | `locked:ITEM` | it’s locked | | `on:ITEM` | it’s switched on | | `here:ITEM` | the player can reach it (in the room, carried, or in something open) | | `var:NAMEN` | a numeric variable compared with `=`, `<`, `>`, `<=` or `>=` (unset is 0) | | `carryingN` | how many things the player holds directly | | `target:ID`, `indirect:ID` | that slot of the command being run resolved to that ID (`player` for ME and MYSELF, and SELF or YOURSELF when nothing here is called that); false otherwise | | `direction:DIR` | the direction typed in `PUSH X NORTH` (compass, `up`, `down`), or, in an `orders.go` rule, the place the character was told to go, as typed: “milton, go to the basement” is `direction:basement`. Any lowercase word passes the audit | | `number:N`, `numberN` | the number typed in the command being run (`TURN DIAL TO 4`, `SET DIAL TO 776`); false when the command has none. A rule with `with: 'number'` matches that command | | `said:WORDS` | the words typed after a text verb (`target: 'text'`, e.g. `ANSWER A WELL`), compared as lowercase whole words with punctuation and quotes ignored; false when the command has none | | `heaviestN` | the weight of the heaviest thing the player holds directly, counting what’s inside it (Zork’s narrow passage: `heaviest<=4`) | | `scoreN` | the score, as SCORE reports it (Zork wins at `score>=350`); a `scoring` entry can’t test it | | `lit:here`, `lit:ROOM` | the room has light | | `following:NPC` | the character is following the player (`{ follow }` set it; a character’s `follows` condition is separate) | | `alive:NPC` | the character isn’t dead | | `awake:NPC` | alive and conscious | | `fighting:NPC` | in a fight with the player (and conscious) | | `with:NPC` | in the player’s room (hidden or not) | | `seen:NPC` | in the player’s room and not hidden | | `aboard`, `aboard:ITEM` | the player is in a vehicle (that one) | | `water:here`, `water:ROOM` | the room is water now | | `terrain:NAME`, `terrain:NAME:ROOM` | the room (default: the player’s) is that terrain now: `land`, `water`, `air`, or one of the world’s own | | `!…` | negates any of the above | | `a & b` | every part holds: `in:break_room & !flag:lunch_freed` | There’s no “or”. Write two rules, or two dialogue keys, instead. An unrecognized condition is false in play. `auditWorld` reports it, and a condition naming an item or room that doesn’t exist, in rules, triggers, exits, `requires`, daemons, hints and scoring (it doesn’t check dialogue keys, ambient lines or room descriptions). ## Events and effects An event is a named list of steps, run in order. A string is printed. An object is an effect: | Effect | Does | |---|---| | `{ say: 'text' }` | Prints, like a plain string. | | `{ set: 'flag' }`, `{ clear: 'flag' }` | Turns a flag on or off. | | `{ move: 'item', to: 'room' }` | Moves an item to a room, `'player'`, `'here'` (the player’s room), another item, a character, or `null` (offstage). | | `{ moveNpc: 'npc', to: 'room' }` | Moves a character, or `null` (gone). | | `{ follow: 'npc' }`, `{ unfollow: 'npc' }` | Sets or clears the character’s `following` state (`npcs..following` in the game state; no flag): the character then goes where the player goes (GO, doors, ENTER, CLIMB; not scripted moves), like a `follows` condition on the character. | | `{ npcState: 'npc', fighting?, staggered?, strength?, hidden?, scenery? }` | Sets a character’s combat state, hides and reveals it, or (`scenery`) keeps it out of the room’s list and puts it back (Zork’s NDESCBIT in play). | | `{ script: 'name', arg? }` | Runs one of the world’s [scripts](https://mrballistic.github.io/brass-lantern/reference/world-schema#scripts) and the steps it returns. | | `{ hide: 'item' }`, `{ reveal: 'item' }` | Hides an item where it is (not seen, listed or taken), or shows it again. | | `{ board: 'item' }`, `{ disembark: true }` | Puts the player in a vehicle that’s in the room, or takes them out. | | `{ touch: 'item' }` | Marks an item handled (Zork’s TOUCHBIT): its first-seen sentence is over. | | `{ unlist: 'item' }`, `{ relist: 'item' }` | Keeps an item where it is, seen and usable but out of the room’s list, or lists it again (the tied rope). | | `{ open }`, `{ close }`, `{ lock }`, `{ unlock }` | Changes a container’s or door’s state. | | `{ switch: 'item', on: true }` | Switches a light (or anything switchable). | | `{ add: 'var', by: n }`, `{ setVar: 'var', to: n }` | Changes a numeric variable. | | `{ setVar: 'var', from: 'number' }` | Sets it to the number typed in the command (0 if none). | | `{ score: n }` | Adds to the score. | | `{ go: 'room' }` | Moves the player there and describes it. With `quiet: true`, without describing it (Zork’s mirror). | | `{ look: true }` | Describes the player’s room in full, as LOOK does. | | `{ unvisit: 'room' }` | Forgets the player has been there, so the next arrival shows the full description (Zork clears TOUCHBIT when the dam drains). | | `{ schedule: 'event', in: n }`, `{ cancel: 'event' }` | Runs an event after `n` turns, or stops it. | | `{ chance: 80, then: [...], else: [...] }` | Picks a branch at random (from a seeded generator, so saves replay exactly). | | `{ if: 'condition', then: [...], else: [...] }` | Picks a branch by a [condition](#conditions). `else` is optional. | | `{ stopLine: true }`, `{ stopLine: 'text' }` | Drops the rest of the command line (Zork’s P-CONT). The text, if any, is said only if commands were left. | | `{ moveVehicle: 'item', to: 'room' }` | Moves a vehicle to a room. If the player is aboard they arrive with it, as on any arrival; otherwise its `leave` and `arrive` lines are said when the player sees it go or come. It isn’t a move the player makes, so followers stay behind. | | `{ free: true }` | The turn takes no time: no move, no timers or daemons (a capture’s echo). | | `{ noDarkLine: true }` | A line already said the light went out, so the engine doesn’t add its own. | | `{ run: 'event' }` | Runs another event here. | | `{ die: 'cause' }` | Kills the player. See [Death](https://mrballistic.github.io/brass-lantern/reference/world-schema#death). Nothing after it runs. | | `{ end: 'ending' }` | Plays an ending. See [Endings](https://mrballistic.github.io/brass-lantern/reference/world-schema#endings). Nothing after it runs. | Effects print nothing unless they say so. An effect naming something that doesn’t exist does nothing in play, and `auditWorld` reports it. It also checks the events named by rules, `onEnter`, `onTake`, `onWear`, `onSmash`, `onGive`, daemons and the finale. ### Templates `{var:NAME}` (the variable, 0 when unset) and `{number}` (the number typed in the command; left as written when there is none) fill in when a line is printed: event lines, descriptions, and an item’s `initialDescription` and `roomDescription` wherever the engine lists it (in the room, in a container or on a surface, or when opening a container shows it): ```ts vars: { tally: 3 }, items: { tally: { name: 'tally board', description: 'Chalk marks.', portable: false, tags: [], roomDescription: 'A tally board on the wall reads {var:tally}.' }, }, ``` ``` > look 📍 Hall A hall. The study is north. A tally board on the wall reads 3. ``` ### Bracket lines (the older form) Some lines are printed **and** change the game. They still work, and in an Infocom-style world they act without being shown: | Line | Effect | |---|---| | `[Flag set: Gary is happy]` | Sets the flag `flagLabels['gary is happy']` names. An unmapped label does nothing, so test for it. | | `[Added to inventory: badge]` | Gives the player the item whose `name` is “badge”. | | `[Badge consumed]` | Removes the item whose `name` is “badge” from the inventory. | Facts worth knowing: - **Events from `onEnter`, `onTake`, `onWear`, `onSmash` and the finale’s `bareHanded` fire once per game.** Events from a use rule’s `then` and from `onGive` run every time the rule matches, so guard them with a `!flag:` condition if they should happen once. - **Bracket lines only turn flags on.** To turn one off, use the `{ clear: 'flag' }` effect. - **Item lines match display names**, not IDs. To upgrade an item, consume the old one and add the new: `[Lamp consumed]`, then `[Added to inventory: lit lamp]`. - **`intro`** plays when a new game starts. A chapter break is just a line, such as `✨ CHAPTER 2: THE AFTERNOON`. ## How lines are styled Every line the player sees is classified by how it starts, which sets its look and typewriter speed: | Starts with | Style | Use it for | |---|---|---| | `📍` | room header | added by the engine; don’t start your own lines with it | | `💼 🌀 👔 💕 💻 💾 🔨 💥 📎 ✨ 🪄 🤜 😖 😴 🐟 🌺 📬 📞` | scripted moment | events (18ms/char) | | `═` or `“` | banner, narration | 12ms/char | | `[` | system | flags, inventory, hints, score (instant) | | anything else | prose | descriptions, replies (10ms/char) | A line starting with an emoji that isn’t in the list renders as prose. The set is fixed in this version, so pick from the list for scripted moments. **Copy style:** the built-in replies use curly quotes and apostrophes (“ ” ’). Your world will read best if it does too. --- Source: https://mrballistic.github.io/brass-lantern/reference/commands # Player commands The built-in verbs are fixed; a world gives them things to act on, and can add verbs of its own. Anything the regex parser doesn’t recognize, or recognizes but can’t act on, can go to the [intent server](https://mrballistic.github.io/brass-lantern/guide/intent-server), which maps loose phrasing onto these. | Command | Also | Notes | |---|---|---| | GO *place* | `n` `s` `e` `w` `ne` `nw` `se` `sw` `u` `d` `in` `out`, `walk/head/run/move to …`, a bare exit label | Matches the room’s exit labels. | | ENTER [*place*] | `go into` | An exit by name, the way in, or the exit through a door you name. | | CLIMB [*thing*] | `climb up`, `climb down` | Up, or the room’s `climb` exit. | | LOOK | `l` | Describes the room again. | | TAKE *item* | `get` `grab` `pick up` | | | DROP *item* | `put down` `leave` | | | TAKE ALL, DROP ALL, PUT ALL IN / ON *thing* | `everything`; `… but X and Y`, `… except X, Y` | One line per thing: “lamp: Taken.”. TAKE ALL takes what’s in reach and not inside something you carry (in Infocom style, what’s directly in the room, with a reason for anything that can’t be taken). | | EXAMINE *thing* | `x` `inspect` `look at` | Items and people, and what’s inside or on an item. In the dark you can’t examine people either (“It’s too dark to see.”). | | FOLLOW *person* | | Bare FOLLOW asks what to follow. With no rule of the world’s, a person (say, Floyd) gets “You’d rather Floyd came to you. Try FLOYD, FOLLOW ME.” and a thing “You can’t follow that.” To bring someone along, order them: *person*, FOLLOW ME. See [FOLLOW](https://mrballistic.github.io/brass-lantern/reference/world-schema#follow). | | READ *thing* | | The item’s text. | | OPEN / CLOSE *thing* | `shut` | Containers and doors. | | LOCK / UNLOCK *thing* WITH *key* | | | | PUT *item* IN / ON *thing* | `insert`, `place`, `set` | Into an open container with room, or onto a surface. UNDER and BEHIND are asked of the world’s rules (in Infocom style, otherwise “You can’t do that.” / “That hiding place is too obvious.”). | | TAKE *item* FROM *thing* | `get … out of`, `remove … from` | | | LOOK IN *thing* | `search` | What’s inside. | | TURN ON / OFF *thing* | `switch on`, `light`; `extinguish`, `douse`, `blow out`, `put out` | Switchable items. LIGHT on something already burning with no switch (a torch) says “It’s already lit.” | | BURN *thing* WITH *item* | `light … with`, `burn down`, `ignite`, `incinerate` | Something burnable, with something burning. The thing’s rules answer first (lighting candles with a match). | | TURN *thing* WITH *tool* | `to`, `for`; SET *thing* TO *x* | A rule on the thing decides; otherwise “This has no effect.” TURN ON … WITH … is TURN ON. The second object can be a number: TURN DIAL TO 4, SET YEAR TO 776 (digits up to 1000, or H:MM); with no rule for it, that answers “This has no effect.” in Infocom style, and elsewhere the intent server gets a turn. Digits that name something here (locker 12) are that thing, not the number. | | PLUG *thing* WITH *item* | | The same: a rule, or “This has no effect.” | | BOARD *vehicle* | `get in`, `climb in`, `sit in` | Into a vehicle on the ground here. See [vehicles](https://mrballistic.github.io/brass-lantern/reference/world-schema#vehicles). | | DISEMBARK [*vehicle*] | `get out`, `get off`, `stand` | Out again, if it’s safe. EXIT on its own does this while you’re aboard, and is a direction (out) otherwise. | | USE *item* [ON *thing*] | `push` `pull` `press` `operate` `attach X to Y` | See [use rules](https://mrballistic.github.io/brass-lantern/reference/world-schema#userule). OPEN and PUT fall back to an item’s use rules when it isn’t a container. | | GIVE *item* TO *person* | `hand` `offer` `return` | With one person present, GIVE *item* is enough. `GIVE ` works too (`GIVE LUMBERGH THE REPORT`). | | TALK TO *person* | `speak/chat with`, `question` | With one person present, TALK is enough. | | ASK *person* ABOUT *topic* | `tell … about …` | The person’s answer on that topic, if the world gives it [topics](https://mrballistic.github.io/brass-lantern/reference/world-schema#npc); otherwise what TALK TO says. ABOUT ME and ABOUT MYSELF reach a topic keyed or aliased `me` or `myself`. | | *person*, *command* | `tell/order/ask … to …` | An order (a bare TELL *person* is one too). *person*, FOLLOW ME is one too, answered by the character’s `orders.follow` rules. A character the world lets obey carries out GO, TAKE, DROP and GIVE ME, or answers a rule (“robot, push the button”); others answer or ignore it. An order to such a character ends the rest of the line. | | WEAR *item* | `put on` | A thing in sight but not held is taken first: (first taking the shirt). | | SMASH *thing* [WITH *item*] | `break` `destroy` `wreck` `whack` `beat` | | | ATTACK *someone* WITH *weapon* | `kill` `hit` `fight` `stab` | At a character who fights, combat; at anything else, SMASH (in a world without combat). In Infocom style, `kill troll` picks the one weapon you hold, or asks. | | THROW *item* [AT *target*] | `toss` `hurl`; OFF, OVER | A rule on the target decides; otherwise it lands on the floor. OFF and OVER, with no rule: Zork’s refusal in Infocom style; elsewhere the intent server gets a turn. | | PUSH *thing* *direction* | PUSH *thing* TO *x*; READ *thing* THROUGH *x* | Only a world’s rules answer these (otherwise “You can’t push things to that.” in Infocom style; elsewhere the intent server gets a turn); READ THROUGH reads it. | | DIAGNOSE | | Your wounds, and how much more you could take. | | WAIT / SIT | `z`, `sit down` `relax` | Takes a `wait` / `sit` exit if the room has one. | | INVENTORY | `i` `inv` | | | HINT | `hints` `clue` | | | VERBOSE / BRIEF / SUPERBRIEF | | Full descriptions always, on first visits only, or never (LOOK still shows them). Takes no game time. | | SCORE | | | | AGAIN | `g` | Repeats the last command, meaning the same things. | | OOPS *word* | | Replaces the word nobody understood in your last line, and tries it again: `examine lanturn`, then `oops lantern`. | | UNDO | | Takes back the last move, up to 50 moves. Not kept across a reload, a RESTART or a RESTORE. Takes no game time. | | QUIT | | Prints the world’s `quit` reply; the game carries on. | | SAVE [*name*] | | Saves the game under a name (`save before the troll`). Without a name, asks for one. Names are lowercase letters, digits, spaces, `-` and `_`, up to 32. | | RESTORE [*name*] | | Goes back to a named save. Without a name, lists them and asks which. | | LOAD | | Goes back to the automatic save, which is kept after every move. | | RESTART | | Wipes the automatic save and starts over. Named saves are kept. | | SCRIPT / UNSCRIPT | | Starts a transcript, then downloads it as a text file. | | VERSION | | The app’s version, then the world’s title and credits. | | THEME [*name*] | | Lists the color themes, or switches to one (`crt-amber`, `crt-green`, `simple`, `simple-light`, `simple-dark`, plus any the game adds). Remembered in the browser. Takes no game time. Works at the cartridge menu and in story files too, like BLOOM and EFFECTS. | | BLOOM ON / OFF | | Turns the phosphor glow on or off. | | EFFECTS ON / OFF | | Turns every screen effect on or off. | | COOKIES | `privacy` | Analytics settings, when analytics are configured. | | EJECT | | Back to the cartridge menu (builds with more than one cartridge). | | LOAD (at the menu) | `open`, or drop a file on the terminal | Play a Z-machine story file from your computer. In a native world, LOAD restores your save instead. | | REMOVE *number* (at the menu) | `forget` | Take a story you loaded off the shelf. | | PLAY | | Start a story file again after it ends. | | CANCEL | | At a SAVE or RESTORE prompt, or a story file’s. | | HELP | `?` | | **World verbs:** a world can add its own (SNOOZE, PRAY, MOVE, Zork’s ULYSSES …); they’re listed in its HELP. See [World verbs](https://mrballistic.github.io/brass-lantern/reference/world-schema#world-verbs). **Typed words:** a world’s SAY, ANSWER or INCANT (see [World verbs](https://mrballistic.github.io/brass-lantern/reference/world-schema#world-verbs)) takes the rest of the line, quoted or not: `answer “a well”`. A quoted phrase is never split at its full stops, commas or `and`, and the text verb ends the line: `say "well". west` says “well” and drops WEST, as in Zork. **ME:** `me` and `myself` mean you where a thing is expected, and so do `self` and `yourself` unless something here is called that. In an order, “robot, give me the key” hands it to you, and “robot, push yourself” means the robot. A world decides what, if anything, happens; with nothing to say, the reply is the usual one for a word that names nothing here (“You don’t see a “me” here.”). **Chaining:** `take key and wallet`, `north then look`, `west. take lamp.` Each piece runs separately (and gets its own intent-server retry if it misses). A name that contains “and” stays whole: in a world where “lost and found” is a thing’s name or one of its aliases, `take flair from lost and found and go north` is two commands, not three. Words that only appear in a description don’t count. **Pronouns:** `it`, `them` and `that` mean the last thing you acted on, as in `take the mug then give it to gary`; `him` and `her` mean the last person. **Names:** every word of three or more letters you type has to be in the thing’s name or one of its other words: `take red ball` doesn’t find a thing the world calls only “ball”. Shorter words are ignored, and a word can be shortened (`lant` for lantern). In brass style a close misspelling of a word in the name matches too (`lunberg` for Lumbergh). See [How names are matched](https://mrballistic.github.io/brass-lantern/guide/building-worlds/#how-names-are-matched). **Questions:** when a word matches more than one thing, the game asks which (“Which do you mean: the wooden door or the trap door?”, or in Infocom style “Which door do you mean, the wooden door or the trap door?”), and when a verb is missing its object, what (“What do you want to take?”). Answer with just the missing words (`trap`, `the sword`), or type a new command to move on. Answer BOTH (or ALL, EACH) to act on each, for TAKE, DROP and PUT; other verbs answer “One at a time: …?”. Questions take no game time, and answers are never sent to the intent server. **Keys:** Up and Down step through your last 100 commands, keeping whatever you’d started typing. Enter on an empty line, or any letter, finishes the text that’s typing out. --- Source: https://mrballistic.github.io/brass-lantern/reference/cartridges # Cartridges and storage ## Cartridges Cartridges are what you pass in `GameOptions.cartridges` (see [Using the library](https://mrballistic.github.io/brass-lantern/guide/using-the-library#every-option)). The types, `Cartridge`, `WorldCartridge` and `ZCodeCartridge`, are exported from both `@brass-lantern/engine` and `@brass-lantern/vue`. **A native world:** | Field | Type | Notes | |---|---|---| | `kind` | `'world'` | | | `id` | string | Unique; part of the save key. | | `title` | string | The menu and the header. | | `world` | `World` | See the [world schema](https://mrballistic.github.io/brass-lantern/reference/world-schema). | | `saveKey?` | string | Overrides the save key, e.g. to keep loading saves written under an older key. | **A Z-machine story file:** | Field | Type | Notes | |---|---|---| | `kind` | `'zcode'` | | | `id` | string | Unique; part of the transcript key. | | `title` | string | The menu and the header. | | `story` | string | The file’s URL, relative to `GameOptions.storyBaseUrl` (default `/`), e.g. `stories/zork1.z3`. | | `format` | string | Shown in the menu, e.g. `Z-machine v3`. | Stories a player loads with LOAD become cartridges too, with `local: true` and an id like `local-r119-880429-bf44` (release, serial number, checksum). You don’t write those. With one cartridge, the terminal boots straight into it: no menu, no EJECT, no LOAD. With more, it opens on the menu, and a reload goes straight back to the last cartridge if it has a game in progress. ## Browser storage Everything is in the player’s browser, under `storagePrefix`: | Key | What | |---|---| | `:save:` | A native world’s save, with up to 500 lines of history. Or the cartridge’s `saveKey`. | | `:named:` | A native world’s named SAVE. | | `:theme` | The player’s THEME, BLOOM and EFFECTS choices. | | `:cartridge` | The last cartridge inserted. EJECT clears it, so a reload after EJECT shows the menu. | | `:z::transcript` | A story’s screen, up to 500 lines, so a reload can redraw it. | | `:z:auto:` | A story’s autosave, written every turn and cleared when it ends. | | `:z:file:save::` | A named SAVE in a story. | Stories loaded with LOAD are in IndexedDB, in a database named `:stories`. The library stores nothing else. Anything your app keeps (an analytics consent answer, say) is its own: the demo site, for example, keeps its consent answer at `:analytics-consent` and, only after consent, an anonymous Google Analytics client ID at `:ga-cid` (and the session ID, `:ga-sid`, in sessionStorage).