# 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' } }` |
| `