Skip to content

Porting Zork ​

Brass Lantern ships Zork I twice: the original story file, run by the Z-machine interpreter, and ZORK I · NATIVE, a rebuild as an ordinary Brass Lantern world (packages/engine/src/worlds/zork1.ts). The native version is how the engine proves it can carry an Infocom-class game. A test plays both side by side and fails if they disagree.

How ZIL maps to a world ​

Zork was written in ZIL, Infocom’s language. Its source is MIT licensed (historicalsource/zork1), and most of it translates directly:

ZILBrass Lantern
<ROOM …> with LDESCa room with description
<ROOM …> with an M-LOOK routinedescriptions: [{ if, text }], the first that holds
(NORTH TO X)exits: { north: 'x' }
(EAST "The door is boarded…")exits: { east: { denial: '…' } }
(WEST TO KITCHEN IF KITCHEN-WINDOW IS OPEN)exits: { west: { to: 'kitchen', door: 'kitchen_window' } }
<OBJECT …>an item
CONTBIT, OPENBIT, TRANSBITcontainer: { openable, open, transparent }
SURFACEBITsurface: true
NDESCBITscenery: true
LOCAL-GLOBALS and a room’s GLOBAL listroom.scenery: [...]
FDESC / LDESC on an objectinitialDescription / roomDescription
TEXTtext (READ)
LIGHTBITswitchable: true, light: true
an object’s ACTION routineinstead and after rules
a room’s VALUEan onEnter event that sets a scored flag
a verb only some objects understand (MOVE, COUNT)a world verb
a room without ONBITdark: true
LIT?the engine’s light check (a light item switched on, in sight)
an interrupt (QUEUE, ENABLE)a fuse (schedule) or a daemon. Zork’s clock counts a QUEUE X n from a command down that same turn and the engine’s from the next, so it becomes in: n-1; an interrupt requeuing itself stays in: n
I-LANTERN and LAMP-TABLEa lamp_fuel variable and daemons that warn at its thresholds
JIGS-UPthe die effect and world.death
PROBthe chance effect
a room’s M-ENTERonEnter, which Infocom style runs before the description
a PER exit routine with several refusals (the chimney)an exit’s denials
the parser’s ORPHAN and WHICH-PRINT (“Which door do you mean, the wooden door or the trap door?”)built in: a question, answered by the next line
the parser’s OOPS and AGAINbuilt in
an object with no TEXT (EXAMINE lists a container’s contents)description: ''
ACTORBIT objectscharacters (npcs), with places, things they hold (holds) and descriptions by state
VILLAINS, HERO-BLOW, VILLAIN-BLOW, the melee tablesa character’s combat block and world.combat (the tables are the engine’s)
a villain’s ACTION modes (F-DEAD, F-UNCONSCIOUS, F-CONSCIOUS, F-BUSY?, F-FIRST?)onDeath, onUnconscious, onWake, onBusy, firstStrike
SIZE, CAPACITY, LOAD-ALLOWED, FUMBLE-NUMBERsize, container.weight, carry
V-DIAGNOSE, I-CUREDIAGNOSE and healing, built in
an ACTION routine data can’t express (I-SWORD, the troll catching what you throw)a script
a PER exit routine with a side effect (UP-CHIMNEY-FUNCTION)an exit’s then event
the parser’s GWIM for ATTACK (“(with the sword)”)built in, Infocom style
TVALUE (points while a treasure is in the case)a score entry with a condition: { if: 'inside:painting:trophy_case', points: 6 }
TVALUE as the thief reads it (what’s worth stealing)treasure: 6 on the item, read by ctx.treasure
INVISIBLE on a character (the thief, lurking)hidden: true, or { npcState, hidden } to change it
INVISIBLE on an object (the stiletto until the thief dies)the hide and reveal effects
SACREDBIT, MAZEBITroom tags: ['sacred'], tags: ['maze'], read by ctx.tags
I-THIEF and ROBBER-FUNCTIONa daemon running a script (thief_turn), with combat hooks and rules on the thief
the cyclops’s daemon and CYCLOWRATHa daemon script and a variable
ASK/TELL … ABOUT and orders (“thief, give me the bag”)topics, refuseOrder and instead.order on a character
an object ACTION that prints and returns false, so the verb goes onan instead rule with continue: true
JIGS-UP resetting things (the trap door)death.then
a raw-input loop in a room’s M-ENTER (the Loud Room)a room’s capture, with { free: true } replies
a room’s M-ENDthe room’s onEnd
V-WAIT’s three turns of the clockworld.wait: { turns: 3 }
FCLEAR … TOUCHBIT on a room{ unvisit: 'room' }
FSET … NDESCBIT in play (the tied rope){ unlist } and { relist }
GOTO room <> (no description: the mirror){ go: 'room', quiet: true }
PICK-ONE (no repeats until all are used)a script: pickOne in packages/engine/src/worlds/zork1.ts
PROB n m (ZPROB: worse odds once LUCKY is false)a script checking the unlucky flag
PRSI’s routine before PRSO’sthe rule order in Infocom style
SACREDBIT on an object (the platinum bar)the item tag sacred, which the thief script respects
JIGS-UP’s branches: Hades once you’ve seen the Altar, dying while dead, “Bad luck, huh?”death.variants, death.instead, a conditional death.message line
ALWAYS-LIT for a spiritdarkness.litIf
DEAD-FUNCTIONa world capture while flag:dead
BURN (LIGHT … WITH) and FLAMEBIT/BURNBITthe BURN verb, flaming and burnable
VEHBIT and VTYPE (the magic boat)an item’s vehicle: { travels: 'water' }, BOARD and DISEMBARK
NONLANDBIT on a room (the river, the reservoir at high tide)the room’s water (a condition for the reservoir)
a vehicle’s M-BEG and M-ENDits rules (asked before the room’s) and its onEnd; Zork’s RBOAT M-BEG is in the world capture
I-RIVER, RIVER-SPEEDS, RIVER-LAUNCHa self-requeuing fuse and LAUNCH’s table, in scripts
GOTO’s grue from one dark room into anotherdarkness.stumble
PRINT-CONT’s “(outside the magic boat)” and first-seen-first orderbuilt in, Infocom style
EMPTY-HANDED and WEIGHT (the narrow passage: nothing heavier than 4)an exit’s if: 'heaviest<=4'
a room’s M-ENTER that acts before you look (the bat’s FLY-ME)onEnter with { look: true } first, then a script that picks a room
a room’s M-END that checks the turn’s command (BOOM-ROOM)the room’s onEnd running a script that reads ctx.command
two objects standing for one thing at two places (the basket on its chain)two items swapped by the RAISE and LOWER rules; things inside travel with the real one
NO-OBJS’ LIGHT-SHAFT score on the first lit turnthe room’s onEnd setting a flag that scoring counts
HACK-HACK’s random endings (V-WAVE, V-RAISE)a world verb’s fixed reply, naming its object with {target}
SCORE-UPD’s win at 350 (the whisper, the map, the secret path)a daemon on score>=350 & !flag:won
FINISH (the score, then RESTART/RESTORE/QUIT)an endings entry with score: true and a footer
GOTO’s “You have moved into a dark place.”darkness.arrive
TOUCHBIT set by OPEN, ROB and the likebuilt in for OPEN (Infocom style); the { touch } step elsewhere
the parser’s GWIM for a missing tool (“(with the shovel)”)a script picking the one TOOLBIT thing held
MOVE putting things first in a room (newest-first listings)built in for things; a character who arrived this turn is listed first

style: 'infocom' makes the engine follow Zork’s conventions:

  • “There is a sword here.”;
  • brief descriptions of rooms you’ve seen;
  • newest-first listings;
  • Zork’s SCORE line and status line (“West of House Score: 0 Moves: 0”);
  • Zork’s questions (“What do you want to take?”) and TAKE ALL (“lamp: Taken.”, including what can’t be taken, and why).

The differential test ​

packages/engine/tests/worlds/zork1-diff.test.ts runs a walkthrough through both versions and compares every reply, ignoring case, spacing and quote style. A mismatch prints both sides:

> open sack
  native:   Opening the brown sack reveals a lunch, and a clove of garlic.
  original: Opening the brown sack reveals a clove of garlic, and a lunch.
  • The walkthrough covers the early slices: the house and forest, the cellar and the gallery with the lamp lit, the parser (a bare TAKE and its answer, “Which door do you mean?”, AGAIN, OOPS, TAKE ALL and DROP ALL), the troll, the maze, and the cyclops (ULYSSES).
  • Scripted sessions (packages/engine/tests/worlds/zork1-sessions.test.ts) check every puzzle: each is a short command list played from the Round Room in both versions and compared reply by reply, its wrong orders and side branches included (the dam, the leak, the Loud Room, the mirrors, the rope, the exorcism, the candles, ghost mode). Both sides are seeded: a test-only option seeds the interpreter’s own generator (new ZMachineSession(…, { seed })), so each session plays the same way every time, with a seed pinned per side where the thief stays away.
  • Fights are random, and our dice aren’t Zork’s, so the walkthrough has sync points: walking into the Troll Room, each side retries until the troll doesn’t strike first, and at the fight each side attacks until its troll is dead. The original retries by starting its session again, the native port by trying another seed. The thief wanders at random from the first move, so either side starts over on the next seed whenever he shows up.
  • packages/engine/tests/worlds/zork1-fight.test.ts checks the fight itself: it fights the real troll 300 times, collects everything it prints, and requires every line of 200 native fights to be one the original prints. It found a difference between Zork’s source and its story file: the source gives a knocked-out player’s foes extra rounds, and Release 119 doesn’t. The story file wins.
  • packages/engine/tests/worlds/zork1-thief.test.ts does the same for the thief: 60 original sessions wait in the dark cellar with a treasure until he comes, and every line 100 native runs print must be one the original prints.
  • The whole game (packages/engine/tests/worlds/zork1-full.ts, adapted from the walkthrough Microsoft’s Jericho project ships for Zork I) runs natively from the first move to the barrow on a pinned seed, and must end with every treasure in the case, 350 points, the whisper, the map’s text and the closing text. packages/engine/tests/worlds/zork1-chapters.test.ts compares it with the original in nine chapters: for chapter k both sides replay chapters 1 to k and compare chapter k reply by reply, fights being sync points (@fight <foe> with <weapon>). Each chapter has a seed pair on which the two sides agree; none falls back to line sets.
  • packages/engine/tests/worlds/zork1-thief-timing.test.ts measures the thief: 40 seeds a side wait in the Round Room until he first shows, and the medians must agree within 25% (measured: 17 turns each).
  • The room order is read from the story file. packages/engine/tests/helpers/zobjects.ts decodes zork1.z3’s object tree, and the native world’s rooms must follow it, since the thief walks rooms in that order.
  • Expected differences go in packages/engine/tests/worlds/zork1-allowlist.ts, each with a reason, and the test fails if one stops being different. Two kinds: a misspelled word, where Zork says “I don’t know the word” and the native engine says it sees no such thing (and, in the app, asks the intent server; the OOPS that follows it matches); and replies after the fight that depend on how it went (DIAGNOSE, SCORE’s move count).
  • Random lines the original prints (the distant songbird) are filtered out: the engine’s generator is seeded and reproducible, but it can’t replay Zork’s own. Random outcomes (the grue, where things scatter when you die) are pinned by seeded unit tests instead, and the death texts are checked against a real death in the original.

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