@brass-lantern/mcp 0.0.0-stage → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +36 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +16 -0
- package/dist/docs/llms.txt +35 -0
- package/dist/docs/pages/guide/building-worlds/index.md +218 -0
- package/dist/docs/pages/guide/building-worlds/recipes.md +1406 -0
- package/dist/docs/pages/guide/building-worlds/two-rooms.md +339 -0
- package/dist/docs/pages/guide/coding-agents.md +120 -0
- package/dist/docs/pages/guide/deploying.md +56 -0
- package/dist/docs/pages/guide/getting-started.md +72 -0
- package/dist/docs/pages/guide/how-it-works.md +152 -0
- package/dist/docs/pages/guide/intent-server.md +92 -0
- package/dist/docs/pages/guide/porting-zork.md +115 -0
- package/dist/docs/pages/guide/testing.md +100 -0
- package/dist/docs/pages/guide/using-the-library.md +272 -0
- package/dist/docs/pages/guide/your-first-world.md +206 -0
- package/dist/docs/pages/guide/z-machine.md +96 -0
- package/dist/docs/pages/index.md +58 -0
- package/dist/docs/pages/reference/cartridges.md +47 -0
- package/dist/docs/pages/reference/commands.md +79 -0
- package/dist/docs/pages/reference/conditions-and-events.md +129 -0
- package/dist/docs/pages/reference/world-schema.md +592 -0
- package/dist/docs/pages.json +74 -0
- package/dist/docs.d.ts +21 -0
- package/dist/docs.js +89 -0
- package/dist/engine.d.ts +16 -0
- package/dist/engine.js +40 -0
- package/dist/load.d.ts +22 -0
- package/dist/load.js +105 -0
- package/dist/server.d.ts +23 -0
- package/dist/server.js +96 -0
- package/dist/stdio.d.ts +19 -0
- package/dist/stdio.js +49 -0
- package/dist/tools.d.ts +8 -0
- package/dist/tools.js +82 -0
- package/package.json +45 -4
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
# Using the library
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
| Package | What it is | Needs |
|
|
6
|
+
|---|---|---|
|
|
7
|
+
| `@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) |
|
|
8
|
+
| `@brass-lantern/vue` | The CRT terminal, the game store, themes and `mountGame`. | `vue` 3.5+, `pinia` 4 |
|
|
9
|
+
| `@brass-lantern/server` | The intent server as `parseIntent` and, for Express, `intentRoute`. | A Gemini key; `express` 5 for the route |
|
|
10
|
+
|
|
11
|
+
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.
|
|
12
|
+
|
|
13
|
+
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.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm i @brass-lantern/engine @brass-lantern/vue vue pinia
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
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.
|
|
22
|
+
|
|
23
|
+
## Put a game on a page
|
|
24
|
+
|
|
25
|
+
`mountGame` makes a Vue app and a Pinia of its own and mounts one game on an element:
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import { mountGame } from '@brass-lantern/vue';
|
|
29
|
+
import '@brass-lantern/vue/style.css';
|
|
30
|
+
import { tutorial } from '@brass-lantern/engine/worlds';
|
|
31
|
+
|
|
32
|
+
mountGame('#app', {
|
|
33
|
+
cartridges: [{ kind: 'world', id: 'snack-attack', title: 'SNACK ATTACK', world: tutorial }],
|
|
34
|
+
storagePrefix: 'my-game',
|
|
35
|
+
});
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
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:
|
|
39
|
+
|
|
40
|
+
```css
|
|
41
|
+
html, body, #app { height: 100%; margin: 0; overflow: hidden; }
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
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.
|
|
45
|
+
|
|
46
|
+
Already have a Vue app? Use the component, with a Pinia installed:
|
|
47
|
+
|
|
48
|
+
```vue
|
|
49
|
+
<script setup lang="ts">
|
|
50
|
+
import { BrassLantern, type GameOptions } from '@brass-lantern/vue';
|
|
51
|
+
import '@brass-lantern/vue/style.css';
|
|
52
|
+
|
|
53
|
+
const options: GameOptions = {
|
|
54
|
+
cartridges: [/* … */],
|
|
55
|
+
storagePrefix: 'my-game',
|
|
56
|
+
};
|
|
57
|
+
</script>
|
|
58
|
+
|
|
59
|
+
<template>
|
|
60
|
+
<BrassLantern :options="options" />
|
|
61
|
+
</template>
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
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.
|
|
65
|
+
|
|
66
|
+
### Every option
|
|
67
|
+
|
|
68
|
+
`GameOptions` is the only thing a game needs to be told:
|
|
69
|
+
|
|
70
|
+
| Option | Meaning |
|
|
71
|
+
|---|---|
|
|
72
|
+
| `cartridges` | One or more worlds or story files. With one the terminal boots straight in; with more it shows the menu. Required. |
|
|
73
|
+
| `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. |
|
|
74
|
+
| `terminalName` | Shown in the header. Default `BRASS LANTERN`. |
|
|
75
|
+
| `version` | Shown after the name in the header. |
|
|
76
|
+
| `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. |
|
|
77
|
+
| `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`. |
|
|
78
|
+
| `themes` | Extra named themes, offered by `THEME` beside the presets. One named like a preset is ignored, with a warning. |
|
|
79
|
+
| `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. |
|
|
80
|
+
| `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 `/`. |
|
|
81
|
+
| `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`. |
|
|
82
|
+
| `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`. |
|
|
83
|
+
|
|
84
|
+
`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.
|
|
85
|
+
|
|
86
|
+
### Two games on one page
|
|
87
|
+
|
|
88
|
+
Each game needs its own `storagePrefix`. The same prefix twice is the same game, with shared saves and shared stores. Remounting `<BrassLantern>` 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.
|
|
89
|
+
|
|
90
|
+
## Themes
|
|
91
|
+
|
|
92
|
+
The terminal ships five presets:
|
|
93
|
+
|
|
94
|
+
| Name | Look |
|
|
95
|
+
|---|---|
|
|
96
|
+
| `crt-amber` | The default: amber phosphor, with every effect on. |
|
|
97
|
+
| `crt-green` | Green phosphor, every effect on. |
|
|
98
|
+
| `simple` | Plain text on a plain page, no effects. Follows the system’s light or dark setting. |
|
|
99
|
+
| `simple-light` | The plain look, light. |
|
|
100
|
+
| `simple-dark` | The plain look, dark. |
|
|
101
|
+
|
|
102
|
+
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:
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
import { mountGame, type Theme } from '@brass-lantern/vue';
|
|
106
|
+
// `cartridges` as in “Put a game on a page” above.
|
|
107
|
+
|
|
108
|
+
const paper: Theme = {
|
|
109
|
+
palette: {
|
|
110
|
+
fg: '#2b2118', fgBright: '#000000', fgDim: '#7a6a58',
|
|
111
|
+
glow: 'transparent', glowStrong: 'transparent',
|
|
112
|
+
bg: '#f4efe3', border: '#d6cbb5', input: '#1d5fa8',
|
|
113
|
+
location: '#000000', event: '#b3410b', decorative: '#6a3d9a', system: '#7a6a58',
|
|
114
|
+
},
|
|
115
|
+
effects: { bloom: false, scanlines: false, flicker: false, vignette: false, noise: false, glitch: false, decay: true },
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
mountGame('#app', { cartridges, storagePrefix: 'my-game', theme: paper, themes: { paper } });
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`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.
|
|
122
|
+
|
|
123
|
+
**Player commands.** `THEME` lists the themes; `THEME <name>` 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 `<storagePrefix>:theme`.
|
|
124
|
+
|
|
125
|
+
`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.
|
|
126
|
+
|
|
127
|
+
**Reduced motion.** When the player’s system asks for reduced motion, flicker, glitch and noise are off whatever the theme says.
|
|
128
|
+
|
|
129
|
+
## Run a game with no browser
|
|
130
|
+
|
|
131
|
+
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:
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
import { createGame } from '@brass-lantern/engine';
|
|
135
|
+
import { tutorial } from '@brass-lantern/engine/worlds';
|
|
136
|
+
import { createInterface } from 'node:readline/promises';
|
|
137
|
+
|
|
138
|
+
const game = createGame(tutorial, { seed: 1 });
|
|
139
|
+
console.log(game.opening.join('\n'));
|
|
140
|
+
|
|
141
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
142
|
+
while (!game.state.gameOver) {
|
|
143
|
+
const reply = game.send(await rl.question('> '));
|
|
144
|
+
console.log(reply.lines.join('\n'));
|
|
145
|
+
}
|
|
146
|
+
rl.close();
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
`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`.
|
|
150
|
+
|
|
151
|
+
`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.
|
|
152
|
+
|
|
153
|
+
`createGame` has no LLM: input the parser can’t read gets the engine’s own reply.
|
|
154
|
+
|
|
155
|
+
`currentScore(world, game.state)` is the score SCORE reports: the world’s `scoring` entries that hold, plus its `score` variable.
|
|
156
|
+
|
|
157
|
+
### Your own loop
|
|
158
|
+
|
|
159
|
+
`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:
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
import { andNames, execute, fallbackParse, initialState, splitCommands } from '@brass-lantern/engine';
|
|
163
|
+
|
|
164
|
+
const names = andNames(world); // e.g. ['lost and found box', 'lost and found'], from item and character names and aliases
|
|
165
|
+
const state = initialState(world);
|
|
166
|
+
|
|
167
|
+
function run(line: string): string[] {
|
|
168
|
+
const out: string[] = [];
|
|
169
|
+
for (const command of splitCommands(line, world.verbs, names)) {
|
|
170
|
+
const action = fallbackParse(command, world.verbs);
|
|
171
|
+
out.push(...(action ? execute(action, { world, state }).lines : ['I didn’t understand that.']));
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
splitCommands('take flair from lost and found and go north', world.verbs, names);
|
|
179
|
+
// ['take flair from lost and found', 'go north']
|
|
180
|
+
splitCommands('take flair from lost and found and go north', world.verbs);
|
|
181
|
+
// ['take flair from lost', 'take found', 'go north']
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
`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.
|
|
185
|
+
|
|
186
|
+
## The intent server
|
|
187
|
+
|
|
188
|
+
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.
|
|
189
|
+
|
|
190
|
+
On the server, with Express 5:
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
import express from 'express';
|
|
194
|
+
import { intentRoute } from '@brass-lantern/server/express';
|
|
195
|
+
|
|
196
|
+
const app = express();
|
|
197
|
+
app.set('trust proxy', 'loopback'); // behind a reverse proxy on the same machine
|
|
198
|
+
app.use(express.json({ limit: '32kb' }));
|
|
199
|
+
app.use('/api', intentRoute({ apiKey: process.env.GEMINI_KEY! }));
|
|
200
|
+
app.listen(3001);
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
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.
|
|
204
|
+
|
|
205
|
+
In the browser:
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
mountGame('#app', { cartridges, storagePrefix: 'my-game', intentEndpoint: '/api/parse-intent' });
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Not on Express? `parseIntent` is the same thing as a function:
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
import { parseIntent } from '@brass-lantern/server';
|
|
215
|
+
|
|
216
|
+
const action = await parseIntent('hand gary his mug', context, { apiKey, timeoutMs: 5000 });
|
|
217
|
+
// { action: 'give', target: 'mug', indirect: 'gary' }, or { action: 'unknown' }
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
`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`.
|
|
221
|
+
|
|
222
|
+
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.
|
|
223
|
+
|
|
224
|
+
## Write a world and check it
|
|
225
|
+
|
|
226
|
+
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:
|
|
227
|
+
|
|
228
|
+
```ts
|
|
229
|
+
import { auditWorld, createGame, type World } from '@brass-lantern/engine';
|
|
230
|
+
|
|
231
|
+
const world: World = { /* rooms, items, events … */ } as World;
|
|
232
|
+
|
|
233
|
+
const problems = auditWorld(world); // string[]: broken references: exits to nowhere, missing events, unknown items
|
|
234
|
+
if (problems.length) throw new Error(problems.join('\n'));
|
|
235
|
+
|
|
236
|
+
const game = createGame(world, { seed: 1 });
|
|
237
|
+
game.send('look');
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
`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.
|
|
241
|
+
|
|
242
|
+
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).
|
|
243
|
+
|
|
244
|
+
## Story files (Z-machine)
|
|
245
|
+
|
|
246
|
+
A story file is a cartridge too:
|
|
247
|
+
|
|
248
|
+
```ts
|
|
249
|
+
{ kind: 'zcode', id: 'zork1', title: 'ZORK I', story: 'stories/zork1.z3', format: 'Z-machine v3' }
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
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.
|
|
253
|
+
|
|
254
|
+
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.
|
|
255
|
+
|
|
256
|
+
Using the runtime yourself, the session is its own entry, so you can do the same:
|
|
257
|
+
|
|
258
|
+
```ts
|
|
259
|
+
import { SaveStoreDialog, localStorageSaveStore } from '@brass-lantern/engine/zmachine';
|
|
260
|
+
|
|
261
|
+
const { ZMachineSession } = await import('@brass-lantern/engine/zmachine/session');
|
|
262
|
+
const session = new ZMachineSession(storyBytes, new SaveStoreDialog(localStorageSaveStore('my-game:')), {
|
|
263
|
+
onLines: (lines) => print(lines), onStatus() {}, onWaiting() {}, onExit() {}, onError: (m) => print([m]),
|
|
264
|
+
});
|
|
265
|
+
session.start();
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
`@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`.
|
|
269
|
+
|
|
270
|
+
## Examples
|
|
271
|
+
|
|
272
|
+
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.
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# The demo game: Snack Attack
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
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.
|
|
6
|
+
|
|
7
|
+
## The shape
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import type { World } from '@brass-lantern/engine';
|
|
11
|
+
|
|
12
|
+
export const tutorial: World = {
|
|
13
|
+
startRoom: 'cubicle',
|
|
14
|
+
rooms: { /* … */ },
|
|
15
|
+
items: { /* … */ },
|
|
16
|
+
npcs: { /* … */ },
|
|
17
|
+
dialogue: { /* … */ },
|
|
18
|
+
events: { intro: [ /* … */ ] },
|
|
19
|
+
flagLabels: { /* … */ },
|
|
20
|
+
// optional: hints, scoring, ranks, idle, quit, confused, ambient, finale
|
|
21
|
+
};
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
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.
|
|
25
|
+
|
|
26
|
+
## Rooms
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
rooms: {
|
|
30
|
+
cubicle: {
|
|
31
|
+
name: 'Your Cubicle',
|
|
32
|
+
description: 'Gray carpet on the walls, gray carpet on the floor. Your desk has one drawer. It is closed.',
|
|
33
|
+
exits: { north: 'hallway', hallway: 'hallway', out: 'hallway' },
|
|
34
|
+
listExits: ['hallway'],
|
|
35
|
+
items: ['desk_drawer', 'stapler'],
|
|
36
|
+
npcs: [],
|
|
37
|
+
onEnter: [],
|
|
38
|
+
},
|
|
39
|
+
break_room: {
|
|
40
|
+
name: 'The Break Room',
|
|
41
|
+
description: 'A vending machine hums in the corner. Your lunch, a single bag of pretzels, hangs from its spiral, not quite falling.',
|
|
42
|
+
exits: { south: 'hallway', hallway: 'hallway', out: 'hallway' },
|
|
43
|
+
listExits: ['hallway'],
|
|
44
|
+
items: ['vending_machine'],
|
|
45
|
+
npcs: [],
|
|
46
|
+
onEnter: [{ if: '!flag:saw_pretzels', then: 'see_pretzels' }],
|
|
47
|
+
requires: 'has:badge',
|
|
48
|
+
denial: 'The break room door has a badge reader. You do not have a badge on you.',
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
- **`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.
|
|
54
|
+
- **`listExits`** is what the player sees. The engine adds the direction that goes to the same place: `Exits: hallway (north).`
|
|
55
|
+
- **`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.
|
|
56
|
+
- **`onEnter`** fires an event when you arrive, if its condition holds. **An `onEnter` event fires at most once per game.**
|
|
57
|
+
|
|
58
|
+
## Items
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
items: {
|
|
62
|
+
desk_drawer: {
|
|
63
|
+
name: 'desk drawer',
|
|
64
|
+
aliases: ['drawer', 'desk'],
|
|
65
|
+
description: 'A metal drawer that sticks.',
|
|
66
|
+
portable: false,
|
|
67
|
+
refusal: 'It is attached to the desk, which is attached to your career.',
|
|
68
|
+
tags: [],
|
|
69
|
+
onUse: [
|
|
70
|
+
{ if: '!flag:drawer_open', then: 'open_drawer' },
|
|
71
|
+
{ say: ['The drawer is already open. It holds nothing else of value.'] },
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
badge: { name: 'badge', aliases: ['id', 'card'], description: 'Your employee badge. The photo is from a better year.', portable: true, tags: [] },
|
|
75
|
+
stapler: { name: 'stapler', description: 'Heavy. Reliable. Faintly menacing.', portable: true, tags: ['weapon'] },
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`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.
|
|
80
|
+
|
|
81
|
+
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.
|
|
82
|
+
|
|
83
|
+
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).
|
|
84
|
+
|
|
85
|
+
## People
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
npcs: {
|
|
89
|
+
gary: {
|
|
90
|
+
name: 'Gary',
|
|
91
|
+
description: 'Gary from Accounts. He has opinions about the water cooler.',
|
|
92
|
+
onGive: { mug: 'gary_mug' },
|
|
93
|
+
refuse: { badge: '“That’s your badge, man. Keep your badge.”' },
|
|
94
|
+
refuseGift: '“I don’t want that.”',
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
dialogue: {
|
|
98
|
+
gary: {
|
|
99
|
+
default: '“Has anyone seen my mug?” Gary asks nobody in particular.',
|
|
100
|
+
'flag:gary_happy': '“Thanks for the mug. Hey, if that machine eats your money, just hit it. Hard. With something heavy.”',
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
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”.
|
|
106
|
+
|
|
107
|
+
**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.
|
|
108
|
+
|
|
109
|
+
## Events and flags
|
|
110
|
+
|
|
111
|
+
Events are named lists of lines. Most are prose. Lines in square brackets are shown to the player *and* change the game:
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
events: {
|
|
115
|
+
intro: [
|
|
116
|
+
'═══════════════════════════════',
|
|
117
|
+
' SNACK ATTACK',
|
|
118
|
+
'═══════════════════════════════',
|
|
119
|
+
'✨ IT IS 12:01. YOU ARE HUNGRY.',
|
|
120
|
+
],
|
|
121
|
+
open_drawer: [
|
|
122
|
+
'You yank the drawer open. Inside, under a decade of sticky notes: your badge.',
|
|
123
|
+
'[Added to inventory: badge]',
|
|
124
|
+
'[Flag set: Drawer open]',
|
|
125
|
+
],
|
|
126
|
+
gary_mug: [
|
|
127
|
+
'💼 Gary takes the mug and looks at it like a long-lost child.',
|
|
128
|
+
'💼 “You’re a good one,” he says, and lowers his voice. “That machine? Hit it.”',
|
|
129
|
+
'[Flag set: Gary is happy]',
|
|
130
|
+
],
|
|
131
|
+
},
|
|
132
|
+
flagLabels: {
|
|
133
|
+
'drawer open': 'drawer_open',
|
|
134
|
+
'gary is happy': 'gary_happy',
|
|
135
|
+
},
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
`[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).
|
|
139
|
+
|
|
140
|
+
## The ending
|
|
141
|
+
|
|
142
|
+
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).)
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
finale: {
|
|
146
|
+
room: 'break_room',
|
|
147
|
+
item: 'vending_machine',
|
|
148
|
+
with: 'stapler',
|
|
149
|
+
event: 'free_lunch',
|
|
150
|
+
epilogue: [
|
|
151
|
+
{ if: 'flag:gary_happy', then: 'ending_with_gary' },
|
|
152
|
+
{ if: '!flag:gary_happy', then: 'ending_alone' },
|
|
153
|
+
],
|
|
154
|
+
footer: 'footer',
|
|
155
|
+
bareHanded: 'hurt_hand',
|
|
156
|
+
bareHandedAgain: 'Your hand still hurts. The machine is winning.',
|
|
157
|
+
wrongRoom: 'Not here. Save it for the machine.',
|
|
158
|
+
},
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
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.
|
|
162
|
+
|
|
163
|
+
## The extras
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
hints: [
|
|
167
|
+
{ if: '!has:badge', text: 'The break room needs a badge. Check your desk.' },
|
|
168
|
+
{ if: '!flag:gary_happy', text: 'Gary lost his mug. Gary knows things.' },
|
|
169
|
+
{ if: '!flag:lunch_freed', text: 'The machine has your pretzels. Bring something heavy.' },
|
|
170
|
+
],
|
|
171
|
+
scoring: [
|
|
172
|
+
{ flag: 'drawer_open', points: 10 },
|
|
173
|
+
{ flag: 'gary_happy', points: 20 },
|
|
174
|
+
{ flag: 'lunch_freed', points: 20 },
|
|
175
|
+
],
|
|
176
|
+
ranks: [{ min: 0, title: 'Intern' }, { min: 50, title: 'Lunch Liberator' }],
|
|
177
|
+
confused: ['The office hums, uncomprehending. (Type HELP.)'],
|
|
178
|
+
ambient: [
|
|
179
|
+
{
|
|
180
|
+
if: 'in:break_room & !flag:lunch_freed',
|
|
181
|
+
every: 2,
|
|
182
|
+
lines: ['The vending machine hums, smug.', 'The pretzels sway, just out of reach.'],
|
|
183
|
+
},
|
|
184
|
+
],
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
- **HINT** shows the first hint whose condition holds, so order them along the critical path.
|
|
188
|
+
- **SCORE** sums points for flags that are set, and names the highest rank reached.
|
|
189
|
+
- **`confused`** replies rotate for input nothing could understand.
|
|
190
|
+
- **`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.
|
|
191
|
+
|
|
192
|
+
## Play it
|
|
193
|
+
|
|
194
|
+
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:
|
|
195
|
+
|
|
196
|
+
```
|
|
197
|
+
> open drawer
|
|
198
|
+
> take stapler
|
|
199
|
+
> north
|
|
200
|
+
> take mug and give mug to gary
|
|
201
|
+
> talk to gary
|
|
202
|
+
> north
|
|
203
|
+
> smash machine with stapler
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Next: [test your world](https://mrballistic.github.io/brass-lantern/guide/testing), so it stays winnable as it grows.
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# Playing story files
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
## Playing Zork
|
|
6
|
+
|
|
7
|
+
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.
|
|
8
|
+
|
|
9
|
+
If you’ve never played it, Zork’s parser is older and stricter than Brass Lantern’s:
|
|
10
|
+
|
|
11
|
+
| Command | Notes |
|
|
12
|
+
|---|---|
|
|
13
|
+
| `N` `S` `E` `W` `NE` `NW` `SE` `SW` `UP` `DOWN` | Directions. Diagonals matter: the house is circled by them. |
|
|
14
|
+
| `LOOK` (`L`), `EXAMINE` *thing* | `X` isn’t a word in the Zork games; spell out EXAMINE. |
|
|
15
|
+
| `TAKE` *thing*, `TAKE ALL`, `DROP` *thing* | Also `PUT` *thing* `IN` *thing*. |
|
|
16
|
+
| `INVENTORY` (`I`) | |
|
|
17
|
+
| `OPEN`, `READ`, `MOVE`, `TURN ON`, `ATTACK` *creature* `WITH` *weapon* | The verbs you’ll need most. |
|
|
18
|
+
| `AGAIN` (`G`) | Repeats your last command. |
|
|
19
|
+
| `SCORE`, `DIAGNOSE` | Your score, and how hurt you are. |
|
|
20
|
+
| `VERBOSE`, `BRIEF` | Full room descriptions every time, or only on your first visit. |
|
|
21
|
+
| `SAVE`, `RESTORE` | See [Saving](#saving). SAVE before anything risky. |
|
|
22
|
+
| `EJECT` | Back to the cartridge menu. The game autosaves, so it’s there when you come back. |
|
|
23
|
+
|
|
24
|
+
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.
|
|
25
|
+
|
|
26
|
+
## Cartridges
|
|
27
|
+
|
|
28
|
+
A story file is a cartridge, listed in `GameOptions.cartridges` beside any native worlds. The demo’s list:
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { mountGame } from '@brass-lantern/vue';
|
|
32
|
+
import { tutorial, zork1 } from '@brass-lantern/engine/worlds';
|
|
33
|
+
|
|
34
|
+
mountGame('#app', {
|
|
35
|
+
cartridges: [
|
|
36
|
+
{ kind: 'world', id: 'snack-attack', title: 'SNACK ATTACK', world: tutorial },
|
|
37
|
+
{ kind: 'zcode', id: 'zork1', title: 'ZORK I', story: 'stories/zork1.z3', format: 'Z-machine v3' },
|
|
38
|
+
{ kind: 'zcode', id: 'zork2', title: 'ZORK II', story: 'stories/zork2.z3', format: 'Z-machine v3' },
|
|
39
|
+
{ kind: 'zcode', id: 'zork3', title: 'ZORK III', story: 'stories/zork3.z3', format: 'Z-machine v3' },
|
|
40
|
+
{ kind: 'world', id: 'zork1-native', title: 'ZORK I · NATIVE', world: zork1 },
|
|
41
|
+
],
|
|
42
|
+
storagePrefix: 'my-game',
|
|
43
|
+
});
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
- **With more than one cartridge**, the terminal shows a menu after it boots. Type a number to insert one; EJECT brings you back.
|
|
47
|
+
- **With one cartridge**, it boots straight in, with no menu.
|
|
48
|
+
- **After a reload**, the last cartridge you played comes straight back if it has a game in progress.
|
|
49
|
+
|
|
50
|
+
**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.
|
|
51
|
+
|
|
52
|
+
**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.
|
|
53
|
+
|
|
54
|
+
## Playing your own story files
|
|
55
|
+
|
|
56
|
+
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.
|
|
57
|
+
|
|
58
|
+
- **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.
|
|
59
|
+
- **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.
|
|
60
|
+
- **Saving works like any cartridge:** SAVE, RESTORE, autosave, and resume on reload.
|
|
61
|
+
- **REMOVE and its number** takes a story off the shelf. Its saved games stay, so loading it again picks up where you were.
|
|
62
|
+
- **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.
|
|
63
|
+
- **If the browser won’t store it** (private browsing, full storage), it still plays, until the next reload.
|
|
64
|
+
- **Only in builds with a menu.** A single-cartridge build boots straight into its game and has no LOAD.
|
|
65
|
+
|
|
66
|
+
## Finding more story files
|
|
67
|
+
|
|
68
|
+
- **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.
|
|
69
|
+
- **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.
|
|
70
|
+
- **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.
|
|
71
|
+
- **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.
|
|
72
|
+
|
|
73
|
+
## Saving
|
|
74
|
+
|
|
75
|
+
- **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.
|
|
76
|
+
- **Autosave:** the game also saves itself every turn, so a reload picks up exactly where you were.
|
|
77
|
+
- **When the story ends** (QUIT, or a final death), its autosave is cleared. Type PLAY to start again.
|
|
78
|
+
|
|
79
|
+
## How it works
|
|
80
|
+
|
|
81
|
+
- **ifvms** ([MIT](https://github.com/curiousdannii/ifvms.js)), the Z-machine inside Parchment, runs the story.
|
|
82
|
+
- 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.
|
|
83
|
+
- **`BrowserGlkOte`** turns the game’s screen updates into terminal lines, and its status line into the header.
|
|
84
|
+
- **`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.
|
|
85
|
+
|
|
86
|
+
The game’s own echo of your command, and its `>` prompt, are dropped, since the terminal draws its own.
|
|
87
|
+
|
|
88
|
+
## Limits
|
|
89
|
+
|
|
90
|
+
- **SCRIPT** (a transcript file) isn’t supported; it says so and the game carries on.
|
|
91
|
+
- **Full browser storage** (or any store that refuses a write) makes SAVE say “There’s no room to save that.”, rather than claiming success.
|
|
92
|
+
- **A story that won’t download** within 20 seconds shows an error; reload to try again, or EJECT.
|
|
93
|
+
- **Formats:** Z-machine versions 3, 4, 5 and 8 (what ifvms supports). No Glulx.
|
|
94
|
+
- **Windows:** only the status line from the upper window is shown, so games that draw menus or quote boxes there lose them.
|
|
95
|
+
- **No graphics, sound or timed input.**
|
|
96
|
+
- **Loose phrasing:** the intent server doesn’t help with story files, which have their own parsers.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# BRASS LANTERN: A text adventure engine on npm
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
- **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.
|
|
6
|
+
- **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.
|
|
7
|
+
- **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.
|
|
8
|
+
- **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.
|
|
9
|
+
- **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.
|
|
10
|
+
- **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.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
npm install @brass-lantern/engine @brass-lantern/vue vue pinia
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Three library packages, plus an MCP server
|
|
19
|
+
|
|
20
|
+
| Package | What it is |
|
|
21
|
+
|---|---|
|
|
22
|
+
| [`@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. |
|
|
23
|
+
| [`@brass-lantern/vue`](https://www.npmjs.com/package/@brass-lantern/vue) | The CRT terminal, themes and `mountGame`, as a Vue 3 component. |
|
|
24
|
+
| [`@brass-lantern/server`](https://www.npmjs.com/package/@brass-lantern/server) | Optional. An Express route that lets players type like people, through Gemini. |
|
|
25
|
+
| [`@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)). |
|
|
26
|
+
|
|
27
|
+
## A game on a page
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import { mountGame } from '@brass-lantern/vue';
|
|
31
|
+
import '@brass-lantern/vue/style.css';
|
|
32
|
+
import type { World } from '@brass-lantern/engine';
|
|
33
|
+
|
|
34
|
+
const world: World = {
|
|
35
|
+
startRoom: 'study',
|
|
36
|
+
rooms: {
|
|
37
|
+
study: {
|
|
38
|
+
name: 'The Study', description: 'A desk, a chair, and the dark.',
|
|
39
|
+
exits: {}, items: ['lantern'], npcs: [], onEnter: [],
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
items: {
|
|
43
|
+
lantern: { name: 'brass lantern', aliases: ['lamp'], description: 'Unlit, for now.', portable: true, tags: [] },
|
|
44
|
+
},
|
|
45
|
+
npcs: {}, events: {}, dialogue: {}, flagLabels: {},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
mountGame('#app', {
|
|
49
|
+
cartridges: [{ kind: 'world', id: 'study', title: 'THE STUDY', world }],
|
|
50
|
+
storagePrefix: 'study',
|
|
51
|
+
});
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
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.
|
|
55
|
+
|
|
56
|
+
## Built with Brass Lantern
|
|
57
|
+
|
|
58
|
+
**[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.
|