@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.
Files changed (37) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +36 -2
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +16 -0
  5. package/dist/docs/llms.txt +35 -0
  6. package/dist/docs/pages/guide/building-worlds/index.md +218 -0
  7. package/dist/docs/pages/guide/building-worlds/recipes.md +1406 -0
  8. package/dist/docs/pages/guide/building-worlds/two-rooms.md +339 -0
  9. package/dist/docs/pages/guide/coding-agents.md +120 -0
  10. package/dist/docs/pages/guide/deploying.md +56 -0
  11. package/dist/docs/pages/guide/getting-started.md +72 -0
  12. package/dist/docs/pages/guide/how-it-works.md +152 -0
  13. package/dist/docs/pages/guide/intent-server.md +92 -0
  14. package/dist/docs/pages/guide/porting-zork.md +115 -0
  15. package/dist/docs/pages/guide/testing.md +100 -0
  16. package/dist/docs/pages/guide/using-the-library.md +272 -0
  17. package/dist/docs/pages/guide/your-first-world.md +206 -0
  18. package/dist/docs/pages/guide/z-machine.md +96 -0
  19. package/dist/docs/pages/index.md +58 -0
  20. package/dist/docs/pages/reference/cartridges.md +47 -0
  21. package/dist/docs/pages/reference/commands.md +79 -0
  22. package/dist/docs/pages/reference/conditions-and-events.md +129 -0
  23. package/dist/docs/pages/reference/world-schema.md +592 -0
  24. package/dist/docs/pages.json +74 -0
  25. package/dist/docs.d.ts +21 -0
  26. package/dist/docs.js +89 -0
  27. package/dist/engine.d.ts +16 -0
  28. package/dist/engine.js +40 -0
  29. package/dist/load.d.ts +22 -0
  30. package/dist/load.js +105 -0
  31. package/dist/server.d.ts +23 -0
  32. package/dist/server.js +96 -0
  33. package/dist/stdio.d.ts +19 -0
  34. package/dist/stdio.js +49 -0
  35. package/dist/tools.d.ts +8 -0
  36. package/dist/tools.js +82 -0
  37. package/package.json +45 -4
@@ -0,0 +1,592 @@
1
+ # World schema
2
+
3
+ Every field a world can use. The source of truth is [`packages/engine/src/types/world.ts`](https://github.com/mrballistic/brass-lantern/blob/main/packages/engine/src/types/world.ts), which has a comment on each one. “Condition” means a [condition string](https://mrballistic.github.io/brass-lantern/reference/conditions-and-events#conditions); “event” means a key in `events`.
4
+
5
+ ## World
6
+
7
+ | Field | Type | |
8
+ |---|---|---|
9
+ | `startRoom` | room ID | Where a new game begins. |
10
+ | `onFoot?` | string[] | Terrains the player can walk into and get out of a vehicle in. Default `['land']`; a custom terrain is walkable only if listed. See [Vehicles](#vehicles). |
11
+ | `rooms` | `Record<id, Room>` | |
12
+ | `items` | `Record<id, Item>` | |
13
+ | `npcs` | `Record<id, NPC>` | |
14
+ | `dialogue` | `Record<npc id, Dialogue>` | What TALK TO says. |
15
+ | `events` | `Record<id, EventStep[]>` | Lines and [effects](https://mrballistic.github.io/brass-lantern/reference/conditions-and-events#events-and-effects). `intro` plays when a new game starts. |
16
+ | `flagLabels` | `Record<label, flag id>` | Maps the lowercased label in `[Flag set: …]` lines to a flag ID. |
17
+ | `typoReplies?` | string[] | Lines said when a misspelled name is matched anyway (brass style only), once per thing, taken in turn. `{typed}` is the word as typed, `{name}` the thing’s name with its article in lowercase (“the guard”), so start a reply with `{typed}` or other text rather than `{name}`. Leave it out and corrections are silent. The engine records what it has teased in `GameState.typoMocked` (optional; saved with the game). Example: `“{typed}”? You mean {name}.` |
18
+ | `hints?` | `Hint[]` | HINT shows the first whose condition holds. |
19
+ | `scoring?` | `{ flag, points }[]` or `{ if, points }[]` | SCORE sums points for set flags, plus conditions that hold right now (a treasure in the case), plus the `score` variable. |
20
+ | `maxScore?` | number | The total SCORE reports. Default: the sum of `scoring`. |
21
+ | `ranks?` | `{ min, title }[]` | The highest `min` the score reaches is the rank. |
22
+ | `scoreLine?` | string | SCORE’s first line, replacing the style’s own. `{score}`, `{max}` and `{moves}` (“3 moves”) fill in: `'Your potential is {score} of a possible {max}, in {moves}.'` |
23
+ | `rankLine?` | string | SCORE’s rank line, replacing the style’s own. `{rank}` fills in: `'This score gives you the rank of {rank}.'` |
24
+ | `diagnose?` | `{ healthy?, wounded? }` | DIAGNOSE’s own lines for being unhurt and for being wounded, as fixed text. Unset, the engine’s wording stands. |
25
+ | `idle?` | string | Reply to WAIT or SIT where they don’t lead anywhere. Default: “Time passes.” |
26
+ | `quit?` | string | Reply to QUIT. |
27
+ | `confused?` | string[] | Replies for input nothing understood, rotated. |
28
+ | `ambient?` | `Ambient[]` | Timed interruptions. |
29
+ | `finale?` | `Finale` | The win condition. |
30
+ | `verbs?` | `Record<id, WorldVerb>` | Verbs this world adds. See [World verbs](#world-verbs). |
31
+ | `vars?` | `Record<name, number>` | Starting values for numeric variables. |
32
+ | `seed?` | number | Seeds the random generator, for reproducible games. Default: the clock. |
33
+ | `daemons?` | `{ if, then }[]` | Run after every turn the engine acts on, while `if` holds. `then` is an event name or steps. See [Time](#time). |
34
+ | `darkness?` | object | Texts and behavior for dark rooms. See [Darkness](#darkness). |
35
+ | `death?` | object | What dying does. See [Death](#death). |
36
+ | `endings?` | `Record<id, …>` | Named endings for the `end` effect. See [Endings](#endings). |
37
+ | `style?` | `'brass'` or `'infocom'` | Output conventions. See [Style](#style). Default `'brass'`. |
38
+ | `emptyInventory?` | string | INVENTORY with nothing carried. Default: “You are empty-handed.” |
39
+ | `smashRefusal?` | string | SMASH where nothing can be smashed, or at a person. |
40
+ | `title?` | string | The game’s full title, for VERSION and Infocom-style transcripts (“ZORK I: The Great Underground Empire”). |
41
+ | `credits?` | string[] | Lines VERSION prints after the title. |
42
+ | `carry?` | `Carry` | Carrying weight. Without it there’s no limit. See [Weight](#weight). |
43
+ | `combat?` | `CombatRules` | The player’s side of fights. See [Combat](#combat). |
44
+ | `scripts?` | `Record<name, Script>` | The code hatch: functions that return steps. See [Scripts](#scripts). |
45
+ | `capture?` | `Capture` | Takes input anywhere, after the room’s own capture. See [Capture](#capture). |
46
+ | `wait?` | `{ turns }` | WAIT runs the clock up to `turns` times, stopping after a turn where something happened (Zork: 3). Each counts as a move. |
47
+ | `statusLine?` | `'moves'` or `'score'` | The header in brass style: `MOVES: n` (the default) or `SCORE: n MOVES: n`. Infocom style always shows the room, score and moves. |
48
+
49
+ ## Room
50
+
51
+ | Field | Type | |
52
+ |---|---|---|
53
+ | `name` | string | Shown as the room header. |
54
+ | `description` | string | |
55
+ | `firstDescription?` | string | Replaces `description` on the first visit only. |
56
+ | `dark?` | boolean | Needs a light source to see in. |
57
+ | `descriptions?` | `{ if, text }[]` | Descriptions that depend on the state of things (“a small window which is open”). The first whose condition holds replaces `description`. |
58
+ | `descriptionScript?` | script name | A [script](#scripts) whose `say` lines (joined with newlines) are the description, ahead of `firstDescription` and `descriptions`. It runs on every look, so it describes the game as it is now. Any step that isn’t a `say` is ignored (and the audit flags it), a script that says nothing falls back to the next description, and the engine restores the seed afterwards, so describing never changes the game. |
59
+ | `exits` | `Record<label, room id or Exit>` | What the player can type, and where it goes. Several labels per destination is normal. A label of `wait` or `sit` is taken by WAIT/SIT. See [Exit](#exit). |
60
+ | `listExits?` | label[] | What the exit line shows, in order; each gets the compass direction that leads the same way. Omit to list every label. |
61
+ | `items` | item ID[] | Items in the room at the start. |
62
+ | `npcs` | NPC ID[] | |
63
+ | `onEnter` | `{ if?, then, repeat? }[]` | Events fired on arrival when the condition holds: once per game, or every time with `repeat: true`. Leave out `if` and it always fires. |
64
+ | `requires?` | condition | Must hold to enter. |
65
+ | `denial?` | string | Shown when `requires` fails. |
66
+ | `scenery?` | item ID[] | Items present here without being in the room: a door shared by two rooms, a window, the sky. Examinable and usable, never listed or taken. |
67
+ | `instead?`, `after?` | `Record<verb, Rule[]>` | Rules for verbs used in this room. See [Rules](#rules). |
68
+ | `tags?` | string[] | Free-form labels for scripts to read (`maze`, `sacred`). The engine doesn’t. |
69
+ | `capture?` | `Capture` | Takes input here before it’s parsed. See [Capture](#capture). |
70
+ | `water?` | boolean or condition | Water (Zork’s NONLANDBIT): only a water vehicle goes here. A condition for a room that changes (a reservoir that drains: `'!flag:low_tide'`). See [Vehicles](#vehicles). |
71
+ | `air?` | boolean or condition | Air (Zork II’s balloon): shorthand for `terrain: 'air'`. Same shape as `water`. |
72
+ | `terrain?` | string | What kind of ground this is, for vehicles. Default `'land'` (or `'water'` / `'air'` where those shorthands hold); a set `terrain` wins over them. Any name works (`'sand'`). See [Vehicles](#vehicles). |
73
+ | `onEnd?` | `{ if, then }[]` | Run at the end of every command here, after the action and before the clock (Zork’s M-END). After WAIT’s turns when `wait` is set. |
74
+
75
+ ### Exit
76
+
77
+ | Field | Type | |
78
+ |---|---|---|
79
+ | `to?` | room ID | Where it goes. Omit for an exit that only prints `denial` (“The door is boarded.”). |
80
+ | `if?` | condition | Must hold for this exit alone. |
81
+ | `denial?` | string | Shown when `if` fails, or always if there’s no `to`. Default: “You can’t go that way.” |
82
+ | `door?` | item ID | An item with `door: true` that must be open. A closed door says “The *name* is closed.” |
83
+ | `denials?` | `{ if, text }[]` | Refusals with their own reasons, checked first; the first whose `if` holds refuses with `text` (Zork’s chimney). |
84
+ | `then?` | event | Runs as the player goes through, before arriving (Zork’s exit routines). |
85
+
86
+ Message-only exits aren’t listed unless `listExits` names them.
87
+
88
+ ## Item
89
+
90
+ | Field | Type | |
91
+ |---|---|---|
92
+ | `name` | string | Display name, and what event lines like `[Added to inventory: …]` match. Keep it unique. |
93
+ | `aliases?` | string[] | Other words players might use. Matched, never shown. Every word of three or more letters a player types must be in the name, an alias or the ID, so add the descriptive words players will reach for: `aliases: ['red ball']`. See [How names are matched](https://mrballistic.github.io/brass-lantern/guide/building-worlds/#how-names-are-matched). |
94
+ | `descriptionScript?` | script name | A script whose `say` lines are EXAMINE’s text, ahead of `description` (same rules as a room’s). See [Descriptions from state](#descriptions-from-state). |
95
+ | `description` | string | EXAMINE. Leave it empty (`''`) and EXAMINE does what Zork does for an object with no text: a container lists what’s in it or says “The *name* is empty.”; anything else is “There’s nothing special about the *name*.” |
96
+ | `portable` | boolean | Can it be taken? |
97
+ | `refusal?` | string | Reply to taking a non-portable item. |
98
+ | `tags` | string[] | Free-form; the engine doesn’t read them. |
99
+ | `onTake?` | event | Fires the first time it’s taken. |
100
+ | `onUse?` | `UseRule[]` | See below. |
101
+ | `onWear?` | event | Fires the first time it’s worn; WEAR again says “already wearing”. |
102
+ | `onSmash?` | event | Fires once, then the item is gone. |
103
+ | `contains?` | item ID[] | Items that start inside or on this one. |
104
+ | `container?` | `Container` | Makes it a container. See below. |
105
+ | `surface?` | boolean | Things can be put on it; what’s on it is always visible and reachable. |
106
+ | `scenery?` | boolean | Present but never listed (the house, the forest). |
107
+ | `door?` | boolean | A door between rooms; exits name it. Uses `container` for openable/open/locked/key. |
108
+ | `size?` | number | Its weight, in worlds with `carry` (Zork’s SIZE). Default 5. |
109
+ | `weapon?` | boolean | Something to fight with. |
110
+ | `vehicle?` | `{ travels, restsOn?, landing?, leave?, arrive?, lookScript? }` | Something the player can get into and travel in. See [Vehicles](#vehicles). |
111
+ | `onEnd?` | `{ if, then }[]` | A vehicle’s end routines: while the player is aboard they run instead of the room’s. |
112
+ | `burnable?` | boolean | BURN can set it alight (Zork’s BURNBIT). |
113
+ | `flaming?` | boolean | It can set things alight: always, or while it’s on if it switches (Zork’s FLAMEBIT). LIGHT or TURN ON on one that isn’t `switchable` says “It’s already lit.” It gives light only if it also has `light` and is on. |
114
+ | `treasure?` | number | What it’s worth (Zork’s TVALUE). The engine doesn’t read it; scripts and scoring can. |
115
+ | `text?` | string | What READ shows. Default: the description. |
116
+ | `initialDescription?` | string | Its own sentence in a room until first taken. |
117
+ | `roomDescription?` | string | Its own sentence in a room after that. Items with neither are gathered into “You can see: …”. |
118
+ | `roomDescriptionScript?` | script name | A world script whose `say` lines are its room sentence, ahead of both (Zork’s DESCFCN). It says everything itself: no “(outside the raft)” follows, and its contents aren’t listed. One that says nothing falls back to the others. |
119
+ | `climbRefusal?` | `{ if?, text }` | Infocom style: climbing it up or down where there’s no way that way says `text` (Zork’s tree: “There are no climbable trees here.”). |
120
+ | `switchable?` | boolean | TURN ON and TURN OFF work on it. |
121
+ | `light?` | boolean | Gives light while on: it lights a dark room it’s in, carried there, or inside something open or transparent there. |
122
+ | `home?` | room ID | Where it goes if the player dies carrying it. |
123
+ | `article?` | string | “a”, “an”, “some” or “” in listings. |
124
+ | `contentsHeading?` | string | The heading over its contents (“Your collection of treasures consists of:”). |
125
+ | `instead?`, `after?` | `Record<verb, Rule[]>` | See [Rules](#rules). |
126
+
127
+ ### Container
128
+
129
+ | Field | Type | |
130
+ |---|---|---|
131
+ | `openable?` | boolean | Has a lid or door. Containers that aren’t openable are always open. |
132
+ | `open?` | boolean | Starts open. |
133
+ | `locked?` | boolean | Starts locked. |
134
+ | `key?` | item ID | Locks and unlocks it. |
135
+ | `transparent?` | boolean | You can see inside even when it’s closed. |
136
+ | `capacity?` | number | How many items fit directly inside. |
137
+ | `weight?` | number | The total weight it holds (Zork’s CAPACITY), in worlds with `carry`. |
138
+ | `opened?`, `closed?` | string | Lines for opening and closing it, instead of the defaults. |
139
+
140
+ You can see into a surface, an open container or a transparent one; you can reach into a surface or an open container. The parser only matches what you can see, and taking something you can see but can’t reach says which container is closed.
141
+
142
+ ### UseRule
143
+
144
+ Rules are tried in order and the **first** whose conditions hold runs. For two-object commands, rules on both items are checked, so one rule covers “put the disk in the drive” and “use the drive with the disk”.
145
+
146
+ | Field | Type | |
147
+ |---|---|---|
148
+ | `if?` | condition | |
149
+ | `with?` | item ID | Another item that must be in the room or carried. If the player names a second object, it must be this one. `'number'` matches a command whose second object is a number typed by the player (TURN DIAL TO 4); test which with `number:`. |
150
+ | `then?` | event | |
151
+ | `say?` | string[] | Lines printed without changing anything. |
152
+
153
+ USE also covers PUSH, PULL, PRESS and ATTACH X TO Y. OPEN and PUT fall back to an item’s use rules when it isn’t a container (or isn’t in your hands), so OPEN DRAWER works on a drawer that has only a use rule. Using an item with `onWear` and no matching rule wears it. Verbs like SLEEP, UNPLUG or INSTALL are [world verbs](#world-verbs) now: declare them and give items `instead` rules.
154
+
155
+ ## Rules
156
+
157
+ Items and rooms can carry rules for any verb, built-in or declared by the world:
158
+
159
+ ```ts
160
+ instead: { move: [{ if: 'flag:rug_moved', say: ['It won’t budge again.'] }, { then: 'rug_moved' }] },
161
+ after: { take: [{ if: '!flag:took_egg', then: 'took_egg' }] },
162
+ ```
163
+
164
+ - **`instead`** rules run in place of the verb’s default.
165
+ - **`after`** rules run after the default succeeds and changes something.
166
+ - **Lookup order:** the target item’s rules, then the indirect item’s, then the room’s. The first rule whose `if` holds (and whose `with` matches the other object, when given) wins.
167
+ - A rule is the same shape as a UseRule (above).
168
+ - **`as: 'target'` or `'indirect'`** limits a rule to its item’s role in the command (Zork’s PRSO and PRSI): a tube that refuses things put *into* it, not itself put somewhere.
169
+ - **`prep`** limits it to a preposition: `prep: 'in'` answers PUT … IN, not PUT … ON.
170
+ - **In Infocom style** the second object’s rules are asked before the first’s, as Zork’s PERFORM does (POUR WATER ON BELL asks the bell first).
171
+ - **`continue: true`** on an `instead` rule runs it and then lets the verb’s default go on as well (Zork’s “print, then RFALSE”): a line before the TAKE that still happens.
172
+ - **On a character**, rules answer verbs aimed at it, including `order` (see [NPC](#npc)).
173
+ - **The older hooks still work:** `onUse` is `instead.use`, and `onTake` is a one-shot `after.take`.
174
+
175
+ ## World verbs
176
+
177
+ A world can add verbs without touching the engine or the intent server:
178
+
179
+ ```ts
180
+ verbs: {
181
+ pray: { words: ['pray'], target: 'none', reply: 'If you pray enough, your prayers may be answered.' },
182
+ move: { words: ['move', 'shift'], target: 'required' },
183
+ drive: { words: ['drive', 'drive to'], target: 'optional', go: true },
184
+ },
185
+ ```
186
+
187
+ | Field | Type | |
188
+ |---|---|---|
189
+ | `words` | string[] | Words and phrases that mean it. Phrases are matched before built-in verbs, single words after. |
190
+ | `target` | `'none'`, `'optional'`, `'required'` or `'text'` | `'text'` takes the rest of the line as typed words (SAY, ANSWER), read by `said:`. |
191
+ | `indirect?` | string[] | Prepositions that introduce a second object (`with`, `on`). |
192
+ | `reply?` | string | When no rule applies. Default: “Nothing happens.” `{target}` is replaced by the object’s name, `{a target}` by its name with an article. |
193
+ | `held?` | boolean | The object must be something you hold, or can see inside something you hold (Zork’s HELD): POUR WATER means the water in your bottle. |
194
+ | `go?` | boolean | Treat it as GO: through the target exit, or the exit labeled with the verb’s ID. `instead.go` rules (the vehicle’s, the room’s) answer it as they answer GO. |
195
+ | `afterBuiltIns?` | boolean | Its words may be built-in words: it reads only lines no built-in verb reads, after them (Zork III’s bare TURN DIAL, where the engine’s TURN wants TO or WITH). |
196
+
197
+ - A world verb does nothing by itself: give items or rooms `instead` rules for it.
198
+ - With no target, it looks for a rule on the room, then on anything in reach (SNOOZE finds the alarm clock).
199
+ - A word a built-in verb already uses (`take`, `open`, …) is ignored, unless the verb is `afterBuiltIns`; `auditWorld` reports any (“verb word … is a built-in”). Since 2.1.0 that includes `follow`: a world that declared its own FOLLOW should drop it, and its `orders.follow` rules keep working (see [Orders](#orders)).
200
+ - A `go: true` verb meets the same `instead.go` rules as GO, so a rule that refuses to leave refuses walking and driving alike:
201
+
202
+ ```ts
203
+ verbs: { drive: { words: ['drive', 'drive to'], target: 'optional', go: true } },
204
+ rooms: {
205
+ garage: {
206
+ // …
207
+ instead: { go: [{ if: '!flag:gate_open', say: ['The gate is shut.'] }] },
208
+ },
209
+ },
210
+ ```
211
+
212
+ ```
213
+ > drive north
214
+ The gate is shut.
215
+ > north
216
+ The gate is shut.
217
+ ```
218
+
219
+ ## Style
220
+
221
+ - **`'brass'`** (the default) lists items as “You can see: …”, says “Taken: lamp.”, and shows an exit line.
222
+ - **`'infocom'`** follows Zork’s conventions:
223
+ - “There is a sword here.” and “Taken.”;
224
+ - no exit line;
225
+ - a room you’ve visited shows just its name and contents unless you LOOK (SUPERBRIEF shows only the name, in either style);
226
+ - lists newest first;
227
+ - SCORE says “Your score is 15 (total of 350 points), in 40 moves.”, and (like VERBOSE, BRIEF and SUPERBRIEF) runs no clock: no move, no timers, though the room’s end routine still runs;
228
+ - the header shows the room, score and moves, like Zork’s status line;
229
+ - questions, TAKE ALL and transcripts use Zork’s wording;
230
+ - bookkeeping lines like `[Flag set: …]` act without being shown;
231
+ - each thing’s contents are listed right after it;
232
+ - READ takes the thing first (“(Taken)”), EXAMINE of a thing with no description reads it, and opening a container whose one untouched thing has a first-seen sentence says “The coffin opens.” and that sentence;
233
+ - a door with no lines of its own “opens” and “is now closed”, and OPEN and CLOSE say “It is already open.” and “It is already closed.”;
234
+ - PUT … ON something that isn’t a surface says “There’s no good surface on the …”;
235
+ - a room’s `scenery` (Zork’s local globals) only answers to a word when nothing else in reach does;
236
+ - the second object’s rules come before the first’s;
237
+ - untouched things’ first-seen sentences are listed before everything else;
238
+ - EXAMINE of a closed box says “The box is closed.”
239
+
240
+ ## Time
241
+
242
+ After every turn the engine acts on (never after a misunderstood command), these happen in order:
243
+
244
+ 1. **The room’s end routines** (`onEnd`, Zork’s M-END).
245
+ 2. **Fuses** count down, and those reaching zero run. A fuse is set by the `schedule` effect and removed by `cancel`; one set during a turn starts counting the next turn.
246
+ 3. **Daemons** run, in order, each while its `if` holds.
247
+ 4. **Ambient lines** print.
248
+
249
+ A lamp that burns down is a variable and a few daemons:
250
+
251
+ ```ts
252
+ vars: { lamp_fuel: 185 },
253
+ daemons: [
254
+ { if: 'on:lamp', then: [{ add: 'lamp_fuel', by: -1 }] },
255
+ { if: 'on:lamp & var:lamp_fuel=85 & here:lamp', then: ['The lamp appears a bit dimmer.'] },
256
+ { if: 'on:lamp & var:lamp_fuel<0', then: 'lamp_dies' },
257
+ ],
258
+ ```
259
+
260
+ With the world’s `wait` set, WAIT runs steps 1 to 3 up to that many times, stopping after one where something happened (a line printed or a fuse fired), and the room’s end routines run after them.
261
+
262
+ A **move** is one of these turns: MOVES in the header and SCORE count them. Commands that take no game time (VERBOSE, BRIEF, SUPERBRIEF, UNDO, SAVE, RESTORE, SCRIPT, VERSION, a question back to the player) don’t count, and nothing runs after them.
263
+
264
+ ## Darkness
265
+
266
+ | Field | Type | |
267
+ |---|---|---|
268
+ | `look?` | string | LOOK and arriving in an unlit dark room. Default: “It is pitch black.” |
269
+ | `tooDark?` | string | Acting on something you can’t see. Default: “It’s too dark to see.” |
270
+ | `fall?` | string | When the room goes dark around you. Default: “It is now pitch black.” |
271
+ | `arrive?` | string | Said on arriving in an unlit room, before its darkness line (Zork’s “You have moved into a dark place.”). |
272
+ | `stumble?` | `{ chance, then, aboard? }` | Walking from an unlit dark room into another: `chance`% of `then` instead (Zork’s grue, 80), or `aboard` in a vehicle. |
273
+ | `litIf?` | condition | While it holds, every room is lit (Zork’s ALWAYS-LIT, for a spirit). Mustn’t use `lit:`. |
274
+ | `blunder?` | `EventStep[]` | Run when the player tries a direction with no exit in the dark. Zork’s grue: `[{ chance: 80, then: [{ die: '…' }], else: ['You can’t go that way.'] }]`. |
275
+
276
+ In an unlit dark room you can only find what you’re carrying. Trying to act on anything else gets `tooDark`: an understood refusal, so the intent server isn’t asked to re-guess. EXAMINE of a character who’s there gets it too (“It’s too dark to see.”); talking to a character, giving it things, ordering it and fighting it still work in the dark. Turning a light on or off says so (`fall`, or the room’s description).
277
+
278
+ Only an item with `light` that is on lights a room. A `flaming` thing with no switch (a torch, a lit match) answers LIGHT with “It’s already lit.”, and gives no light by itself. If it should also light the room, give it `light: true` and switch it on with a `{ switch: 'torch', on: true }` effect.
279
+
280
+ ## Death
281
+
282
+ | Field | Type | |
283
+ |---|---|---|
284
+ | `message?` | `(string or { if, text })[]` | Printed after the cause; an entry with `if` only while it holds (Zork’s “Bad luck, huh?”). |
285
+ | `penalty?` | number | Added to the score. |
286
+ | `lives?` | number | Deaths survived before the final one. |
287
+ | `respawn?` | room ID | Where the player wakes. |
288
+ | `resurrection?` | string[] | |
289
+ | `scatter?` | room ID[] | Carried things are spread over these, at random (seeded). Things with a `home` go there instead; with no scatter rooms, they stay where the player fell. |
290
+ | `treasures?` | `'dark'` or `{ to }` | `'dark'`: treasures go to an unlit land room instead, walking the rooms in order at even odds each (Zork’s RANDOMIZE-OBJECTS). `{ to: 'case' }`: treasures without a `home` all go to that room, item or character (a trophy case), drawing no randomness. |
291
+ | `keepTimers?` | event[] | Timers a death leaves running, with their counts. Every other timer is cleared. |
292
+ | `final?` | string[] | The last death, which ends the game. |
293
+ | `then?` | event | Runs after a resurrection, to reset things (Zork’s trap door, unbarred). |
294
+ | `variants?` | `{ if, resurrection?, respawn?, then?, before? }[]` | The first whose `if` holds (decided as you die) replaces those fields; `before` runs ahead of the respawn. Zork sends you to Hades as a spirit once you’ve seen the Altar. |
295
+ | `instead?` | `{ if, lines }[]` | Checked first: the first that holds prints its lines (not the cause) and ends the game. Dying while already dead. |
296
+
297
+ The `die` effect uses it. Without a `death` block, dying prints the cause and ends the game. Pending fuses are canceled on death, except those in `keepTimers`.
298
+
299
+ ## Endings
300
+
301
+ `endings: { victory: { lines: [...], score: true, footer: ['Type RESTART to play again.'] } }`. The `end` effect plays one: its lines, the score and rank if `score` is set, then the footer, and the game is over. The `finale` is an ending too, reached by smashing.
302
+
303
+ ## NPC
304
+
305
+ | Field | Type | |
306
+ |---|---|---|
307
+ | `name` | string | |
308
+ | `article?` | string | Brass style: the article in the engine’s lines that start with the character’s name (“The robot can’t go that way.”, FOLLOW’s reply). Default `'the'`; `''` for a proper name, so Floyd isn’t “The Floyd”. Mid-sentence it’s lowercased: “You’d rather the robot came to you.” See [Orders](#orders). |
309
+ | `description` | string | EXAMINE. In an unlit dark room, EXAMINE of a character gets the [darkness](#darkness) reply. |
310
+ | `onGive?` | `Record<item id, event>` | GIVE hands the item over and fires the event. |
311
+ | `refuse?` | `Record<item id, string>` | Declines that item; the player keeps it. |
312
+ | `refuseGift?` | string | Declines anything else. |
313
+ | `holds?` | item ID[] | What it carries at the start. Things a character holds can’t be seen or taken. |
314
+ | `descriptions?` | `{ if, text }[]` | Its line in the room and its EXAMINE reply, by state; the first whose condition holds wins. |
315
+ | `descriptionScript?` | script name | A script whose `say` lines are that description, ahead of `descriptions`. |
316
+ | `instead?`, `after?` | `Record<verb, Rule[]>` | Rules for verbs aimed at it: THROW X AT it, GIVE, TAKE, a world verb. |
317
+ | `combat?` | `Combatant` | Makes it someone you can fight. See [Combat](#combat). |
318
+ | `aliases?` | string[] | Other words for it (“robber”, “man”). Matched like an item’s: every word of three or more letters typed must be in the name, an alias or the ID. |
319
+ | `scenery?` | boolean | Present but not listed: the room’s own description mentions it (Zork’s cyclops). `{ npcState, scenery }` changes it in play. |
320
+ | `hidden?` | boolean | Starts hidden: in its room for scripts, but not seen, listed, matched or fought until revealed. |
321
+ | `topics?` | `Record<topic, string or { if?, text }[]>` | ASK or TELL it ABOUT a topic. A list is tried in order; the first entry whose `if` holds answers. Text that names an event runs it. |
322
+ | `topicAliases?` | `Record<topic, string[]>` | Other words for a topic. A topic keyed or aliased `me` or `myself` answers ASK *X* ABOUT ME and ABOUT MYSELF (either word reaches it). |
323
+ | `noTopic?` | string | For a topic it has nothing on. Default: its TALK TO line. |
324
+ | `refuseOrder?` | string | Its answer to an order. Default: “*Name* ignores you.” |
325
+ | `orders?` | `Record<verb, Rule[]>` | Rules for orders, by the inner command’s verb. See [Orders](#orders). |
326
+ | `obeys?` | `('go' \| 'take' \| 'drop' \| 'give')[]` | Built-in orders it carries out itself when no order rule answers. |
327
+ | `obeyReplies?` | `Record<verb, string>` | Its acknowledgement when it obeys one of those. Default: “Okay.” |
328
+ | `follows?` | condition | While it holds, the character goes where the player goes. See [Followers](#followers). |
329
+ | `followLine?` | string | What it says on arriving after you. Unset: brass style prints “*Name* follows you.”; Infocom style prints nothing. |
330
+ | `heardFrom?` | room ID[] | Rooms from which the player can give it orders while it’s elsewhere (Zork III’s dungeon master on the parapet, ordered from the cell); its orders are carried out where it stands. |
331
+
332
+ Characters’ places and states live in the game state (`npcs`), starting from the rooms that list them. In brass style the room shows “Present: …”; in Infocom style each character prints its own line.
333
+
334
+ **Hidden characters.** A character that’s `hidden` (from the start, or by the `npcState` effect with `hidden: true`) is still in its room: `ctx.npcIn` and conditions like `with:` see it, but the player doesn’t, and it doesn’t fight. `{ npcState: 'thief', hidden: false }` reveals it. The `seen:` condition is true only when it’s in the player’s room and not hidden.
335
+
336
+ ### Orders
337
+
338
+ “*name*, *command*”, “tell *name* to *command*” and a bare “tell *name*” are orders. What happens, in order:
339
+
340
+ 1. **`instead.order`** rules on the character answer first, as for any verb. In one, `ctx.command.words.indirect` is the order as typed. A character with only `instead.order` (no `orders`, no `obeys`) answers, or says `refuseOrder`, and the rest of the line carries on.
341
+ 2. Otherwise the order is read as a command of its own, with its objects resolved among what the character can reach in **its** room (ME or MYSELF is the player, YOURSELF the character itself; a number typed as an object counts). A word that names nothing there is a miss that changes nothing, so the intent server may still read it; one that matches several things asks which, taking no time. A character can’t refer to what the player carries.
342
+ 3. **`orders`** rules are keyed by the inner command’s verb: `orders: { push: [...], take: [...] }`. They are the same shape as other [rules](#rules), and their conditions see the inner command (`target:`, `indirect:`, `number:`, `said:`, `direction:`). **The first word typed is tried first**, when it has a table (PUSH reads as USE, so “robot, push the button” runs `orders.push` even when `orders.use` exists); otherwise the parsed verb’s table (“robot, press the button” runs `orders.use`). A rule with `continue: true` runs and then lets step 4 go on.
343
+ 4. If no rule answered and the verb is in `obeys`, the character performs it, saying `obeyReplies[verb]` (default “Okay.”):
344
+ - **`go`**: it walks that exit of its room. Nothing is printed but its reply: no leaving or arriving lines. No exit, or one that refuses, is a miss that says why (“The robot can’t go that way.”). In Infocom style it walks as the player does: no exit says “You can’t go that way.”, and an exit that refuses says its own refusal.
345
+ - **`take`** and **`drop`**: the thing moves to or from the character (weight limits apply only to the player). One it can’t take or drop is a miss that says why, by name: “The robot can’t take the dial.”, “The robot doesn’t have the sock.” In Infocom style a take it can’t do says the player’s own take line.
346
+ - **`give`**: “give me the key” moves it from the character to the player (the second object is the player).
347
+ 5. Otherwise `refuseOrder`, or “*Name* ignores you.”
348
+
349
+ In brass style the why-lines in step 4 start with the character’s `article` (default “the”): “The robot can’t go that way.” Give a proper name `article: ''` and it reads “Floyd can’t go that way.”
350
+
351
+ **An order to a character with `orders` or `obeys` ends the rest of the line** (Zork clears the typed-ahead commands), unless it was a miss. That includes an obeying character’s refusal, which takes a turn like any understood command. A character with neither answers orders only through `instead.order` and `refuseOrder`.
352
+
353
+ **`heardFrom`** lists rooms from which the player can give a character orders while it’s somewhere else. Its orders are carried out where it stands (Zork III’s dungeon master, ordered from the cell while he stands on the parapet).
354
+
355
+ ```ts
356
+ robot: {
357
+ name: 'robot',
358
+ obeys: ['go', 'take'],
359
+ obeyReplies: { go: 'Whirr, buzz, click!' },
360
+ orders: {
361
+ push: [{ if: 'target:red_button', then: 'lift_cage' }],
362
+ use: [{ say: ['The robot ignores that.'] }], // “robot, press the button”; “robot, push the button” runs orders.push
363
+ },
364
+ },
365
+ ```
366
+
367
+ ### FOLLOW
368
+
369
+ FOLLOW is a built-in verb. With no rule of the world’s:
370
+
371
+ - bare FOLLOW asks “What do you want to follow?”;
372
+ - FOLLOW *character* points the player at the order form: “You’d rather Floyd came to you. Try FLOYD, FOLLOW ME.” (with `article: ''`);
373
+ - FOLLOW *thing* says “You can’t follow that.”;
374
+ - FOLLOW *something not here* is a miss, so the intent server may read it.
375
+
376
+ An `instead.follow` rule on the character (or the room) answers FOLLOW *character* instead. **“*X*, FOLLOW ME”** is an order: it runs `orders.follow`, where ME is the player (`target:player`). The engine keeps no following state of its own, so a rule that agrees sets it with the `follow` effect, and the character then goes where the player goes ([Followers](#followers)):
377
+
378
+ ```ts
379
+ floyd: {
380
+ name: 'Floyd',
381
+ article: '',
382
+ description: 'Floyd, a cheerful robot.',
383
+ orders: { follow: [{ if: 'target:player', then: 'floyd_tags_along' }] },
384
+ },
385
+ // events:
386
+ floyd_tags_along: ['“Floyd go too!” he squeaks.', { follow: 'floyd' }],
387
+ ```
388
+
389
+ ```
390
+ > follow floyd
391
+ You’d rather Floyd came to you. Try FLOYD, FOLLOW ME.
392
+ > floyd, follow me
393
+ “Floyd go too!” he squeaks.
394
+ > north
395
+ 📍 Study
396
+ …
397
+ Floyd follows you.
398
+ ```
399
+
400
+ A character with no `orders.follow` rule answers “floyd, follow me” as any other order it has no rule for (step 5 above).
401
+
402
+ ### Followers
403
+
404
+ `follows` is a condition on the character; while it holds, after each move the **player** makes (GO, doors, ENTER, CLIMB; not a script’s `goTo` or a `moveVehicle`), the character moves into the player’s new room if it was in the room the player left, and is awake and not hidden. It’s checked after the move, so `in:ROOM` sees the new room. Its `followLine` prints as it arrives. The `follow` and `unfollow` effects set and clear the character’s `following` state (an optional field of its game state, not a flag), which `following:NPC` reads, for a switch rather than a condition. Characters who arrive take the engine’s placing order, so room listings keep it.
405
+
406
+ ## Weight
407
+
408
+ ```ts
409
+ carry: { limit: 100, self: 5, fumble: { over: 7, chance: 8 } },
410
+ ```
411
+
412
+ | Field | Type | |
413
+ |---|---|---|
414
+ | `limit` | number | The total weight the player can carry when healthy. |
415
+ | `self?` | number | The player’s own weight, counted in (Zork’s ADVENTURER is 5). |
416
+ | `fumble?` | `{ over, chance }` | Carrying more than `over` things, each TAKE has `count × chance` percent to fumble. |
417
+ | `tooHeavy?`, `tooHeavyHurt?`, `fumbled?` | string | The refusals. |
418
+
419
+ An item weighs its `size` plus everything inside it. Wounds lower the limit and healing restores it.
420
+
421
+ ## Combat
422
+
423
+ A character with `combat` can be fought: ATTACK *it* WITH *a weapon*. The engine owns the mechanics, ported from Zork I: strength against strength picks one of six blow tables, and a seeded roll picks the result (missed, staggered, wounded, knocked out, killed, disarmed). After every turn the engine acts on, characters in the room who are fighting swing back.
424
+
425
+ | `Combatant` field | Type | |
426
+ |---|---|---|
427
+ | `strength` | number | The troll: 2. |
428
+ | `weapon?` | item ID | What it fights with, while it holds it. |
429
+ | `fears?` | `{ item, by }` | The player’s weapon that weakens it (the troll fears the sword, by 1). |
430
+ | `wake?` | number | Percent added each turn to its chance of waking while out cold. Default 25. |
431
+ | `firstStrike?` | number | Percent chance each turn to start a fight while you’re in the room. |
432
+ | `messages?` | `Partial<Record<BlowResult, string[]>>` | Its blows at the player, one picked at random. `{weapon}` and `{defender}` are filled in. |
433
+ | `onDeath?`, `onUnconscious?`, `onWake?` | event | When it dies, is knocked out, comes round. |
434
+ | `onBusy?` | event | Instead of swinging when its weapon is on the floor (the troll recovers his axe). |
435
+
436
+ | `CombatRules` field | Type | |
437
+ |---|---|---|
438
+ | `messages?` | same | The player’s blows. |
439
+ | `strength?` | `{ min, max }` | The player’s strength, from `min` at no score to `max` at `maxScore` (Zork: 2 to 7). |
440
+ | `cureWait?` | number | Turns for one wound to heal (Zork: 30). |
441
+ | `texts?` | `Partial<Record<CombatText, string>>` | Fixed lines: refusals, the death line, the fog. Defaults: Zork’s words in Infocom style, short ones in brass. |
442
+
443
+ **In Infocom style** ATTACK works the way Zork’s parser does: with one weapon in hand, `kill troll` picks it (“(with the sword)”); otherwise it asks what to attack with; a weapon you aren’t holding is refused without taking a turn. DIAGNOSE reports your wounds. A [recipe](https://mrballistic.github.io/brass-lantern/guide/building-worlds/recipes#a-guard-to-fight) shows a whole fight.
444
+
445
+ ## Numbers, typed words and prepositions
446
+
447
+ **Numbers.** A number in an object slot is the player’s typed number (Zork’s INTNUM): `turn dial to 4`, `set year to 776`. It’s digits up to 1000, or H:MM as minutes (hours under 8 count as afternoon); a bigger number is just an unknown word. Both forms are built-in and go through rules as the verb `turn` with the second object `number`. With no rule, Infocom style says Zork’s “This has no effect.”; brass style misses, so the intent server may still read the input. A bare TURN X keeps its own meaning. The command keeps the digits as typed, so a number no rule wants misses with them (“take 5”: “You don’t see a “5” here.”); `number` is a reserved ID, like `player`. Digits that name a thing in sight or a character here (an item named “locker 12” or aliased `5`; whole words only, so “1” is not locker 12) are that thing, as Zork’s parser reads a word it knows before trying a number: `turn dial to 12` with locker 12 present is TURN DIAL WITH the locker, its rules fire, and `number:12` still holds. Only when nothing is called that are the digits `number`. TURN X WITH a thing, with no rule, says “This has no effect.” in every style.
448
+
449
+ ```ts
450
+ dial: {
451
+ instead: { turn: [
452
+ { with: 'number', if: 'number:4', then: 'door_opens' },
453
+ { with: 'number', say: ['The dial clicks and nothing happens.'] },
454
+ ] },
455
+ },
456
+ ```
457
+
458
+ - **`number:N`** and `number<op>N` read it in conditions; **`{ setVar: 'year', from: 'number' }`** stores it; **`{number}`** in a line prints it, and `ctx.number` has it in a script.
459
+ - A rule with `with: 'number'` matches only a command whose second object is a number.
460
+ - An order can carry one (“robot, turn the dial to 4” runs `orders.turn` with `number:4`).
461
+
462
+ **Typed words.** A world verb with `target: 'text'` (SAY, INCANT, ANSWER) takes the rest of the line as typed words. They are never resolved to things, so naming nothing is no miss. Outer quotes are dropped and whitespace collapsed. **`said:WORDS`** matches them as lowercase whole words, with punctuation and quotes ignored (`said:a well` matches `answer “A well.”`), and `ctx.text` has them. The verb consumes the whole rest of the line, so it ends the line: `say "well". west` drops WEST, as in Zork. A quoted phrase isn’t split at its full stops or commas. Only double quotes (straight or curly) quote; a single quote is an apostrophe (`answer 'don't know'` keeps every word). This applies to every world: a quoted “. ” doesn’t end a command. Unlike Zork, where only a quoted phrase is typed words, **unquoted text counts too**: `answer well` solves a riddle that wants “a well”.
463
+
464
+ **Prepositions.** These forms go through the ordinary rules. With no rule, Infocom style gives Zork’s reply below; brass style misses with the same line (all but READ, which reads), so the intent server can still read the input. A rule answers in both styles.
465
+
466
+ | Form | Rule verb | Built-in reply |
467
+ |---|---|---|
468
+ | PUT/PUSH/SLIDE X UNDER Y | `put`, `prep: 'under'` | “You can’t do that.” |
469
+ | PUT X BEHIND Y | `put`, `prep: 'behind'` | “That hiding place is too obvious.” |
470
+ | THROW X OFF/OVER Y | `throw`, `prep: 'off'` or `'over'` | “You can’t throw anything off of that!” |
471
+ | READ X THROUGH/WITH Y | `read`, with the second object | reads X |
472
+ | PUSH X *direction*, PUSH X TO Y | `push`, `direction:DIR` / the second object | “You can’t push things to that.” |
473
+
474
+ **ME.** ME and MYSELF in an object slot name the player; so do SELF and YOURSELF, unless something in sight (or someone here) is named or aliased that. Rules match them with `target:player` or `indirect:player` (and `with: 'player'`). The engine adds no replies of its own for ME: a verb aimed at it with no rule misses with the word typed (“You don’t see a “me” here.”), so a world says what Zork says (“You can’t tie anything to yourself.”) in a rule. The reserved ID `player` is never matched against a thing’s or character’s name (a “record player” is safe from TAKE ME); typed, “player” is an ordinary word. In an order, ME is the speaker (“robot, give me the key”) and YOURSELF the character (“robot, push yourself”). ASK *X* ABOUT ME (or MYSELF) reaches a topic keyed or aliased `me` or `myself`, and nothing else: `topicAliases: { you: ['me'] }` answers both.
475
+
476
+ ## Descriptions from state
477
+
478
+ - **Templates.** `{var:NAME}` (0 when unset) and `{number}` fill in from the game in any room, item or character description, a `descriptions` entry, an event line, and an item’s `initialDescription` and `roomDescription` wherever they list it (the room, a container’s contents, a surface, opening a container). A `{number}` with no number typed stays as written. Expanding reads the game and changes nothing.
479
+
480
+ ```ts
481
+ tally: { name: 'tally board', description: 'Chalk marks.', portable: false, tags: [], roomDescription: 'A tally board on the wall reads {var:tally}.' },
482
+ ```
483
+
484
+ With `tally` at 3, LOOK lists “A tally board on the wall reads 3.”
485
+ - **`descriptionScript`** on a room, item or character names a [script](#scripts) that builds the description from the game: its `say` lines, joined with newlines. It comes first, ahead of `descriptions`, `firstDescription` and `description`. It’s for descriptions a condition list can’t say: a grid of cells, a dial’s setting, a room that lists its own exits.
486
+ - **`roomDescriptionScript`** on an item is its sentence in a room listing, ahead of its other sentences; it says everything itself, so no “(outside the boat)” follows and its contents aren’t listed (Zork’s DESCFCN).
487
+ - **`vehicle.lookScript`** describes a vehicle from inside. See [Vehicles](#vehicles).
488
+ - A description script may use `ctx.roll`, but the engine puts the seed back afterwards, so looking never changes the game. A script that says nothing falls back to the next description. The audit runs each description script once on a new game and flags a step that isn’t a `say`, or a script name that doesn’t exist.
489
+
490
+ ## Scripts
491
+
492
+ The code hatch, for behavior data can’t express (a thief’s mind, a sword that glows near monsters):
493
+
494
+ ```ts
495
+ scripts: {
496
+ fortune: (ctx) => [ctx.roll(3) === 1 ? '“Beware of geese.”' : '“Soup is coming.”'],
497
+ },
498
+ events: { reading: [{ script: 'fortune' }] },
499
+ ```
500
+
501
+ A script gets a read-only view of the game and returns ordinary steps, which the engine runs. `ctx` has:
502
+ - `state` (frozen in development and tests), `world`;
503
+ - `random()` and `roll(n)`, from the game’s seeded generator, so saves and UNDO replay exactly;
504
+ - `here(id)`, `carried(id)`, `holder(id)`, `room()`, `npc(id)`;
505
+ - `npcIn(id, room)` (hidden or not), `hidden(id)`;
506
+ - `aboard()`, the vehicle the player is in, `water(room?)` and `terrain(room?)` (the room’s terrain name);
507
+ - `rooms()` in the world’s order, `visited(room)`, `tags(room)`, `lit(room?)`;
508
+ - `children(place)`, what’s directly in a room, item or character, in listing order;
509
+ - `test(condition)`, whether a [condition](https://mrballistic.github.io/brass-lantern/reference/conditions-and-events#conditions) holds now, through the engine’s own parser (scripts never parse conditions themselves);
510
+ - `exits(room)`, the exits the player could take from that room now (`{ direction, to }[]`): no entry in its `denials` holds, its `if` holds and its door is open. It’s the same check the player’s moves and characters’ orders use, so a script never offers an exit GO would refuse;
511
+ - `resolve(words, scope?)`, words read as an item the way the parser would: `'here'` (within reach, the default), `'held'` or `'all'`; null if none matches;
512
+ - `number` and `text`, the number or the typed words in the command being run (TURN DIAL TO 4, SAY HELLO);
513
+ - `treasure(id)`, an item’s `treasure` value or 0;
514
+ - `playerStrength()`, the player’s fight strength now;
515
+ - `line`, the raw input, and `action`, the parsed command, when a [capture](#capture) runs the script; `parse(text)`, which reads a command with the world’s verbs as the parser would;
516
+ - `arg`, from `{ script, arg }`;
517
+ - `command`, the command being run with its objects resolved to IDs, when a rule ran the script. `command.words` keeps the words typed for objects that didn’t resolve.
518
+
519
+ Scripts run only where events run, so a command the engine didn’t understand still changes nothing. The world audit fails on a `script` effect naming no script. See the [scripts recipe](https://mrballistic.github.io/brass-lantern/guide/building-worlds/recipes#scripts).
520
+
521
+ ## Vehicles
522
+
523
+ ```ts
524
+ raft: { name: 'raft', vehicle: { travels: 'water' }, container: { open: true }, … },
525
+ pond: { name: 'Pond', water: true, … },
526
+ ```
527
+
528
+ - **BOARD** (GET IN, CLIMB IN) gets in a vehicle that’s on the ground here; **DISEMBARK** (GET OUT, GET OFF, STAND) gets out, except where the room’s terrain isn’t one the player walks (water, air, sand unless `onFoot` lists it): “You realize that getting out here would be fatal.” While aboard, EXIT on its own is DISEMBARK too (Zork’s V-EXIT); otherwise it’s the direction out. In Infocom style, DISEMBARK with no object names the one vehicle in sight: “(raft)”.
529
+ - **Terrains:** every room has one: `terrain`, or `'water'` / `'air'` from those shorthands, else `'land'`. The world’s `onFoot` (default `['land']`) lists those the player walks on. A vehicle’s `travels` is a terrain name or list (`['land', 'sand']`); the old `'water'`, `'air'` and `'none'` still work (`'none'` is `[]`: it never moves). Its `restsOn` (default `['land']`, unless it travels on land) are the terrains it comes to rest on from one it travels, and it can’t cross from one to another.
530
+ - **Moving:** on foot, a room’s terrain must be in `onFoot`, else “You can’t go there without a vehicle.” Aboard, the destination’s terrain must be in `travels`, or in `restsOn` while you’re on a terrain in `travels`, else “You can’t go there in a raft.” (a vehicle won’t go overland). The vehicle goes wherever you go, scripted moves included, and you stay aboard.
531
+ - **Landing:** `landing` (the same forms as `leave`) is said as the vehicle comes onto a `restsOn` terrain from a traveled one you can’t walk on (`landing: 'The balloon lands.'`). Unset, a water vehicle says Zork’s “The raft comes to a rest on the shore.” and a blank line; others say nothing.
532
+ - **`leave` / `arrive`:** said as it leaves with you aboard (before the new room) and as it arrives (after), and by `{ moveVehicle }`. `leave` is worked out in the room being left (a script’s `ctx.room()` is the origin, and its draw comes before any grue draw), once the move is known to be allowed. `arrive` and `landing` are worked out in the new room. Each of the three is a string, a list (one line picked with the seeded generator; a plain string draws nothing), or `{ script: 'name' }` (that script’s `say` lines print; its other steps don’t run). A script’s draws from the generator are kept, unlike a description script, which restores the seed. The audit runs each script once and flags any step that isn’t a `say`. Example: a dune buggy over a sand maze is `vehicle: { travels: ['land', 'sand'], leave: ['The engine roars.', 'Sand sprays.'] }` with the maze rooms `terrain: 'sand'`.
533
+ - **Aboard:** DROP puts things in the vehicle, TAKE *vehicle* says “You’re inside of it!”, and the room’s things stay in reach. The vehicle’s rules are asked before the room’s (Zork’s M-BEG): an `instead.go` on it can refuse directions. Its `onEnd` runs in place of the room’s. GO goes through rules too, so a room can have `instead.go` rules.
534
+ - **Looking:** the header names the vehicle (“Pond, in the raft”); the vehicle isn’t listed, its contents are; in Infocom style the room’s things are “(outside the raft)”, as Zork’s PRINT-CONT does. A vehicle’s `lookScript` (`vehicle: { travels: 'air', lookScript: 'basketLook' }`) describes it from inside: its `say` lines follow the room’s description, and the name on a brief arrival, but not a room whose own `descriptionScript` described it in full (Zork’s vehicle M-LOOK, which a room’s M-LOOK cuts off).
535
+ - Dying takes you out of the vehicle, which stays where you died. Conditions `aboard`, `aboard:ITEM`, `water:here|ROOM` and `terrain:NAME[:here|ROOM]`; effects `{ board }` and `{ disembark }`; script helpers `ctx.aboard()`, `ctx.water(room?)` and `ctx.terrain(room?)`.
536
+
537
+ See the [raft recipe](https://mrballistic.github.io/brass-lantern/guide/building-worlds/recipes#a-raft-on-a-pond).
538
+
539
+ ## Capture
540
+
541
+ ```ts
542
+ capture: { if: '!flag:quiet', script: 'echo' },
543
+ ```
544
+
545
+ A room’s capture, then the world’s, sees each command of a line before it’s parsed, while its `if` holds. The script reads `ctx.line` and returns steps to take it, or nothing to let it parse as usual. Add `{ free: true }` for a reply that takes no time.
546
+
547
+ It also sees commands that arrive already parsed (AGAIN, OOPS, an answer to a question, the intent server’s reading), with `ctx.line` unset and `ctx.action` holding the command, so a limit like a spirit’s can’t be slipped past. A capture that only cares about raw words returns nothing when `ctx.line` is unset.
548
+
549
+ - Taking a command ends the line: the rest is dropped, as Zork’s Loud Room drops it.
550
+ - It runs ahead of the intent server, so captured input is never sent to the LLM.
551
+ - SAVE, RESTORE, UNDO and the other store commands come first, so a capture can’t trap the player; it never runs once the game is over.
552
+ - A capture that declines changes nothing, even if it rolled the dice.
553
+
554
+ Zork uses it twice: the Loud Room, which hears everything as noise until you say ECHO, and a spirit’s limits (DEAD-FUNCTION) after death. See the [listening room recipe](https://mrballistic.github.io/brass-lantern/guide/building-worlds/recipes#a-room-that-listens).
555
+
556
+ ## Dialogue
557
+
558
+ ```ts
559
+ dialogue: {
560
+ gary: {
561
+ default: '“Has anyone seen my mug?”',
562
+ 'flag:gary_happy': '“Thanks for the mug.”',
563
+ 'flag:gary_happy & has:stapler': '“Careful with that stapler.”',
564
+ },
565
+ }
566
+ ```
567
+
568
+ `default` plus any number of condition keys. TALK TO uses the **last** key whose condition holds, so order them from least to most specific.
569
+
570
+ ## Ambient
571
+
572
+ | Field | Type | |
573
+ |---|---|---|
574
+ | `if` | condition | |
575
+ | `every` | number | Turns between interruptions. A turn is a command the engine acted on. |
576
+ | `lines` | string[] | Printed in rotation. |
577
+
578
+ ## Finale
579
+
580
+ Smashing `item` in `room` while carrying `with` ends the game: `event`, then each matching `epilogue` event in order, then the score and rank, then `footer`.
581
+
582
+ | Field | Type | |
583
+ |---|---|---|
584
+ | `room` | room ID | |
585
+ | `item` | item ID | |
586
+ | `with` | item ID | The tool. |
587
+ | `event` | event | |
588
+ | `epilogue` | `{ if, then }[]` | Checked after `event` runs, so flags it sets count. |
589
+ | `footer` | event | |
590
+ | `bareHanded?` | event | One-shot, for smashing `item` without the tool. |
591
+ | `bareHandedAgain?` | string | Later bare-handed attempts. |
592
+ | `wrongRoom?` | string | Smashing it with the tool somewhere else. |