Files
ai.interactive.fiction/zcode_inclusion.md
T

32 KiB
Raw Blame History

Z-Code Engine Integration Analysis

Overview

This document analyses what would be required to add a Z-Code engine server (src/server-zcode.ts) to the multi-engine architecture described in ink_inclusion.md. The Z-Code server would allow classic and modern Inform-compiled .z5, .z8, and .zblorb story files to run in the same book-style UI, over the same unified Socket.IO protocol.


1. What Is the Z-Machine?

The Z-machine is a virtual machine created by Infocom in 1979 for running interactive fiction. Versions 18 exist; almost all modern Inform 6/7-compiled games target version 5 or 8. Key properties:

  • Text-based I/O via a simple line-input / text-output model.
  • Multi-window layout: a fixed "status bar" window (room name + score/moves) plus a scrolling story window.
  • Formatting: bold, italic, fixed-pitch, colours (optional).
  • Sound (v5+): simple beeps in older games; sampled audio via Blorb archives in modern ones.
  • Graphics (v6 only): a rarely-used version with arbitrary bitmap graphics windows. Almost no modern games use v6.
  • UNDO: built-in opcode; many games support multiple undo levels.
  • Save/restore: binary save files (Quetzal format).

The Z-machine does not have a tag system like Ink. All structural and media information must be inferred from the Glk/GlkOte output stream.


2. Available JavaScript Z-Machine Interpreters

  • Repo: github.com/curiousdannii/ifvms.js
  • npm: npm install ifvms (package name: ifvms)
  • License: MIT
  • Versions supported: Z-machine v18 (full), Glulx (not yet)
  • Architecture: JIT disassembler/compiler targeting JS. Generates an AST from Z-machine bytecode, then emits JS. Uses the GlkOte I/O protocol (JSON update objects).
  • Node.js support: Yes — used by Parchment, also ships a terminal CLI (zvm story.z5).
  • Active maintenance: Yes; last commit 10 months ago fixing edge cases in the spec.
  • Used by: Parchment (iplayif.com), Lectrote desktop interpreter.

2.2 Bocfel via Emglken (WebAssembly)

  • Repo: github.com/garglk/garglk (Bocfel) + curiousdannii/emglken
  • Architecture: Bocfel is a C interpreter compiled to WebAssembly via Emscripten. More complete Z-machine support (all versions, including V6 graphics to some extent), but WASM adds ~2 MB overhead and Node.js integration is more complex.
  • Recommendation: Overkill for this use case. ifvms.js is simpler to integrate in Node.js.

2.3 Quixe

  • Repo: github.com/erkyrath/quixe
  • Implements Glulx (the successor to Z-machine), not Z-machine. Not directly applicable unless you want to run Glulx games.

Verdict: Use ifvms.js for Z-machine. If Glulx support is wanted later, add Quixe as a fourth engine type.


3. The GlkOte Protocol — The Key to Integration

Both ifvms.js and the browser Parchment front-end communicate via the GlkOte JSON protocol. Understanding this is essential because it is the seam where we integrate with our own pipeline.

GlkOte sends structured JSON content updates from the interpreter to the display layer:

{
  "type": "update",
  "windows": [
    { "id": 1, "type": "grid",   "gridheight": 1, "gridwidth": 80 },
    { "id": 2, "type": "buffer", "rock": 201 }
  ],
  "content": [
    {
      "id": 2,
      "text": [
        { "content": [{ "style": "normal", "text": "You are in a dark room." }] },
        { "content": [{ "style": "em", "text": "Something moves." }] }
      ]
    },
    {
      "id": 1,
      "lines": [
        { "line": 0, "content": [{ "style": "normal", "text": "Dark Room         Score: 12  Moves: 7" }] }
      ]
    }
  ],
  "input": [
    { "id": 2, "type": "line", "gen": 5 }
  ]
}

The interpreter waits after sending an update. The display sends back input events:

{ "type": "line", "window": 2, "value": "go north", "gen": 5 }

This is already very close to our TurnResult shape. Our Z-Code server translates GlkOte updates into TurnResult objects, forwarding them to the client via Socket.IO.


4. Architecture: Z-Code Server

4.1 src/engine/zcode-engine.ts

Effort: ~300 lines new — the hardest piece

The engine runs ifvms.js in a Node.js worker thread (or the main thread with async I/O), intercepts GlkOte updates, and translates them:

import { ZMachine } from 'ifvms'; // ifvms npm package

class ZCodeEngine {
  private vm: ZMachine;
  private pendingResolve: ((input: GlkInput) => void) | null = null;
  private statusLine: string = '';

  async newGame(storyPath: string): Promise<TurnResult> { ... }
  async sendCommand(text: string): Promise<TurnResult> { ... }
  async sendCharInput(charCode: number): Promise<TurnResult> { ... }
  async undo(): Promise<TurnResult> { ... }

  private onGlkUpdate(update: GlkUpdate): TurnResult { ... }  // The translator
}

The ifvms.js Glk layer is designed to be replaceable. Instead of plugging in the browser GlkOte display, we plug in a custom Glk backend that captures output into our TurnResult structure.

4.2 GlkOte Update → TurnResult Translation

This is the core of the Z-Code server. The translation rules:

GlkOte concept TurnResult mapping
Window 2 (buffer) text spans paragraphs[] — one entry per text[] item
style: "header" or bold span at paragraph start tags: [{ key: 'chapter', value: text }]
Window 1 (grid/status bar) line 0 gameState.statusLine, parsed for room/score/moves
input: [{ type: 'line' }] inputMode: 'text'
input: [{ type: 'char' }] inputMode: 'char' (see §5.4)
No input (game waiting for timer/end) inputMode: 'end'
Sound channel open (Blorb) tags: [{ key: 'sfx', value: soundId }]
Background colour set tags: [{ key: 'background', value: cssColor }]

4.3 src/server-zcode.ts

Effort: ~70 lines new

Identical structure to server-ink.ts. handleGameApi creates a ZCodeEngine per socket session, delegates newGame / saveGame / loadGame to it, and also handles:

  • playerCommandengine.sendCommand(text) → emit narrativeResponse
  • chooseChoiceengine.sendCharInput(charCode) for char-input mode (see §5.4)
  • undo (new optional API method) → engine.undo()

5. What Works Easily

5.1 Text Output (Buffer Window)

Effort: Low — maps directly to paragraphs[]. The GlkOte buffer window content arrives as an array of styled text spans per paragraph, which translates cleanly into { text, tags } pairs.

Inline styling (style: 'em', style: 'strong') maps to Markdown _..._ and **...** in the text field, or to an inline HTML wrapper. SmartyPants is applied server-side.

5.2 Line Input (Standard Command Mode)

Effort: None — already matches the existing playerCommand event and inputMode: 'text'. The Z-machine's line input request (input: [{ type: 'line' }]) maps directly.

5.3 Save and Restore

Effort: Lowifvms.js handles Quetzal-format save files internally via its Glk Dialog layer. We intercept Glk file-open/write calls and redirect to Node.js Buffer objects stored in the session slot map (Map<number, Buffer>). No Quetzal parsing needed on our side.

5.4 Character Input (Menu Mode)

Effort: Medium

Some Z-machine games use read_char for yes/no prompts, menu selections, or "press any key" pauses. When GlkOte reports input: [{ type: 'char' }], we set inputMode: 'char'.

The client already has a 'choice' mode concept. For char input we can synthesise a minimal choice list:

  • For yes/no prompts: choices: [{ index: 89, text: 'Yes' }, { index: 78, text: 'No' }]
  • For "press any key": choices: [{ index: 32, text: 'Continue' }]

Detecting which char input a game expects requires heuristics (checking the game's current output context). A simpler fallback: always show a freetext input accepting single characters, passed as engine.sendCharInput(text.charCodeAt(0)).

5.5 UNDO

Effort: Low

The Z-machine save_undo / restore_undo opcodes are handled internally by ifvms.js when the game calls them. We can also expose an explicit undo API method in handleGameApi that the client's toolbar restart button can call. The game then emits a new turn result showing the result of undoing.

5.6 Sound Effects (V5+ with Blorb)

Effort: Medium

Blorb archives embed sounds alongside the story file. ifvms.js (via GlkOte's Blorb support) signals sound playback via glk_schannel_play. We intercept these Glk sound calls and translate them to sfx[soundId] tags in the turn result. The client's audio-manager-module.js then fetches and plays the audio. Sound files from the Blorb archive would need to be extracted to public/sounds/ at server startup.

5.7 Text Styling (Bold, Italic, Fixed-Pitch)

Effort: Low

GlkOte styles (em, strong, fixed, preformatted) map to Markdown or HTML in the paragraph text field:

  • em_text_
  • strong / header**text**
  • fixed / preformatted`text` or <code>text</code>

The existing text-processor-module.js already handles Markdown inline formatting.


6. What Is Difficult or Unsupported

6.1 Status Bar / Split Windows

Effort: Medium — biggest conceptual mismatch

The Z-machine has a top "status bar" (window 1, type grid) showing room name and score/moves. Our UI currently has no equivalent space.

Options:

  1. Parse and emit as gameState: Extract room name, score, and move count from the grid window content and send them as gameState.statusLine in TurnResult. The client ui-controller-module.js would display them in the existing toolbar area (room label, score). This works for standard Inform games.
  2. Ignore entirely: Many games function fine without a visible status bar.
  3. Add a status bar element: Add a <div id="status-bar"> to index.html and update it from gameState. Medium CSS/JS effort.

The grid window parsing is fragile for non-standard layouts, but for Inform 6/7 games it is reliably structured as Room Name Score: N Moves: M.

6.2 Multiple Text Windows

Effort: High — not practical for most games

A few Z-machine v5+ games use multiple buffer windows (e.g. Border Zone with split-screen displays). Our UI has a single story column. Supporting arbitrary multi-window layout would require significant UI restructuring and is not recommended. These games represent a small minority.

6.3 V6 Graphics Windows

Effort: Very High — not recommended

Z-machine version 6 (Shogun, Journey, Arthur) uses arbitrary graphics windows with bitmap drawing commands. ifvms.js has limited V6 support. The GlkOte graphics API (pixel buffer, image blitting) has no mapping to our canvas-less UI. V6 games should be considered out of scope.

6.4 Colours

Effort: LowMedium

Z-machine games can set foreground/background colours (16 named colours + true colour in V5+). GlkOte reports these as CSS-compatible strings. We can translate background-colour changes to background[#rrggbb] tags, but mapping arbitrary text colours to our typography-focused style is aesthetically problematic. Recommended: honour background colour changes, ignore foreground colour (always use theme typography).

6.5 Fixed-Width / Pre-formatted Text

Effort: Medium

Some Z-machine games use fixed-pitch text for ASCII-art maps, inventory tables, or status displays (e.g. Anchorhead's newspaper). These arrive in style: 'fixed' or style: 'preformatted' spans. The current book-layout renderer with KnuthPlass line breaking is incompatible with fixed-width layout. Options:

  • Render fixed spans as <pre> elements, outside the book column layout.
  • Tag the paragraph with class[fixed] and use monospace CSS.

Neither option will look as good as a native terminal display, but both are functional.

6.6 Timed Input

Effort: High

The Z-machine supports timed line input: the interpreter can interrupt an in-progress input request after N/10 seconds to fire a timer routine. This is used by games like Border Zone for real-time events. ifvms.js supports this, but over Socket.IO it would require sending a "timer tick" event from server to client and receiving it mid-input. This is complex and rarely needed. Not recommended for initial implementation.

6.7 Mouse Input (V5+, V6)

Effort: High

Mouse clicks are used by very few V5 games and extensively by V6 games. V5 mouse support maps to a grid window coordinate — not applicable in our single-column layout. Out of scope.

Effort: Medium

Some modern Glk-aware Z-machine games (compiled with extensions) use hyperlinks in the story text. GlkOte reports these as spans with href or hyperlink values. We could translate them to [text](choice://N) Markdown links that the client renders as clickable choices. Nice to have, but not essential.

6.9 Transcript / Recording

Effort: Low

The Z-machine's script_on / script_off opcodes open a transcript file via Glk. We can intercept these and append to a server-side text file per session. Straightforward but low priority.


7. The Paragraph Boundary Problem

This is the most subtle technical challenge.

The Z-machine does not have an explicit "paragraph" concept. Games print text character by character (or string by string) via print opcodes. GlkOte batches output between input requests into content[].text[] arrays, where each array element is one glk_put_string call. A single "paragraph" may be split across many such calls.

The rule we apply for translation:

  • A \n newline within output → end current paragraph, start new.
  • Two consecutive \n → additional visual space (paragraph break).
  • No trailing \n → append to current paragraph buffer.

This is exactly what text-buffer-module.js already does on the client for the LLM narrative stream. Applying the same logic server-side before building ParagraphResult[] works correctly for standard Inform games.

Edge cases:

  • Games that use glk_put_char('\n') to create specific whitespace (e.g. centred headings) may produce unexpected splits. These are rare.
  • The status bar (window 1 grid) output does not follow this rule and must be handled separately (see §6.1).

8. Save/Restore Deep Dive

ifvms.js implements Glk file operations via a pluggable Dialog object. We replace the default browser-localStorage Dialog with a Node.js implementation that stores Quetzal save data in Buffer objects:

const customDialog = {
  open: (usage, mode, rock, callback) => { /* return file reference */ },
  read: (ref, callback) => callback(saveSlots.get(currentSlot)), // Buffer
  write: (ref, data, callback) => { saveSlots.set(currentSlot, data); callback(); },
};

This is ~50 lines and gives us full save/restore with no changes to ifvms.js.


9. Effort Summary

Area Effort Notes
src/engine/zcode-engine.ts — GlkOte translator ~300 lines new Hardest piece
Custom Glk Dialog (save/restore) ~50 lines new Replaces localStorage
src/server-zcode.ts — server entry ~70 lines new Same pattern as ink server
Blorb sound extraction utility ~60 lines new Run at startup
Status bar parsing + gameState ~30 lines In zcode-engine.ts
Client: story:char-input mode handling ~20 lines Extend choice-display-module.js
CSS: fixed-pitch paragraph style ~15 lines style.css
ifvms.js npm dependency npm install ifvms

Total new Z-Code specific code: ~530 lines across 4 new files. All client-side changes reuse the infrastructure built for the Ink engine (tag events, choice display, input mode switching).


10. Feature Support Matrix

Z-Machine Feature Support Level Notes
Text output (buffer window) Full Core functionality
Line input Full Standard command prompt
Character input ⚠️ Partial Synthesised choice list or single-char text field
UNDO Full Via Z-machine internal opcode
Save / Restore Full Custom Glk Dialog with server-side Buffers
Status bar (room/score) ⚠️ Partial Parsed and shown in toolbar, not as a true grid window
Text styles (bold/italic/fixed) ⚠️ Partial Mapped to Markdown/CSS; fixed-width not book-formatted
Colours ⚠️ Partial Background colour only; foreground ignored
Sound effects (Blorb v5+) ⚠️ Partial Requires Blorb extraction at startup
Simple beeps (v3) Ignored No concept in our audio system
Multiple buffer windows Not supported UI has single story column
V6 graphics Out of scope No canvas rendering
Timed input Not supported Complex; rarely needed
Mouse input Out of scope No pointing concept in UI
Hyperlinks ⚠️ Optional Could map to clickable choice spans
Transcript ⚠️ Optional Server-side file append

  1. npm install ifvms
  2. Write ZCodeEngine with a minimal custom Glk backend, returning raw GlkOte updates.
  3. Implement the GlkOte → TurnResult translator for buffer window only (ignoring status bar and styles).
  4. Build server-zcode.ts; test with a simple .z5 file (e.g. Zork I) via playerCommand.
  5. Add Quetzal save/restore via custom Dialog.
  6. Add status bar parsing → gameState.
  7. Add Blorb sound extraction + sfx tag emission.
  8. Add fixed-pitch / bold text style mapping.
  9. Handle char input mode (yes/no, press-any-key).
  10. Test with a range of Inform 6 and Inform 7 games.


Section 2: LLM-Enhanced Zork Engine ("Zork Narrator")

2.1 Concept

This engine runs a specific Z-machine game — Zork I — inside ifvms.js as a headless backend, while an LLM acts as a narrative layer between the Z-machine and the player. The player never sees raw Z-machine output; they read a continuously re-voiced prose adaptation of it. Conversely, the player never types parser commands; they write in natural language, and the LLM translates their intent into the terse syntax Zork's parser understands.

The central premise is that Zork's world state is authoritative — only the Z-machine determines what actually exists, what has been taken, what doors are unlocked — while everything the player reads and writes is mediated by an LLM that maintains a consistent narrative voice, a distinct player character, and persistent memory across the session.

This engine is a separate npm server (dev:zork) that shares the same client UI over the same Socket.IO protocol. No client changes are required.


2.2 Architecture Overview

Player ──(free text)──► [Command Translator LLM]
                                │
                     ┌──────────┴───────────┐
                     │ tool call            │ Zork command
                     ▼                      ▼
              [Session Manager]    [Z-Machine (ifvms.js)]
              (char, notes)               │
                                   raw Zork output
                                          │
                                          ▼
                               [Output Evaluator LLM]
                                ┌─────────┴──────────┐
                                │ retry              │ accept
                                ▼                    ▼
                        new command          [Text Rewriter LLM]
                                                     │
                                              prose output
                                                     ▼
                                                  Player

2.3 Component Inventory

Component File Role
Z-machine subprocess src/engine/zork-llm-engine.tsZorkProcess Runs zork1.bin via ifvms CLI; captures text I/O
Session state ZorkSession (in-memory) Character description, notes, room history
LLM client Inline in engine (axios + OpenRouter) Four distinct prompt invocations per turn
Prompt files data/zork-prompts/*.yml YAML templates; easily refined without code changes
Engine ZorkLlmEngine Orchestrates the three-step loop
Server src/server-zork.ts Express + Socket.IO, same pattern as YAML server
Story file data/z-code/zork1.bin Provided by the operator; not in version control

2.4 Session State

The engine maintains one ZorkSession object per connected socket:

interface ZorkSession {
  characterDescription: string;       // Generated at game start; LLM can update
  notes: string[];                     // Persistent notes the LLM adds/removes
  roomHistory: Record<string, string[]>; // roomName → up to 5 recent player-facing outputs
  currentRoom: string;                 // Latest known room name
  running: boolean;
}

When saved, the session is serialised to JSON and stored in a server-side slot map (same pattern as the YAML engine). The Zork process state is saved by sending the SAVE command to the running Z-machine and capturing the resulting Quetzal binary, stored as Base64 inside the session JSON.


2.5 Prompt Files

All four prompts live in data/zork-prompts/ as YAML files with two fields:

  • system — the system message, used verbatim.
  • user_template — the user message, with {{variable}} placeholders substituted at call time.

Available variables in every prompt:

Variable Contents
{{characterDescription}} Current player character prose
{{notes}} Numbered list of persistent notes, or "(none)"
{{roomHistory}} Up to 5 most recent player-facing outputs for the current room
{{currentRoom}} Current room name

2.5.1 character-generation.yml

Called once at the start of a new game, before any Z-machine output is processed.

  • Input: none (no user message template needed; the system message is the full prompt).
  • Expected output: A single block of vivid prose (300500 words) describing the player character — name, personality, history, voice, quirks, motivations.
  • Used in: All subsequent prompts as {{characterDescription}}.

2.5.2 text-rewriter.yml

Called only for the game's opening text (before the player has made any input) and for any Z-machine output produced when re-entering a previously visited room that has no recent history yet.

Additional template variables:

Variable Contents
{{zorkOutput}} Raw Z-machine text
  • Expected output: Plain prose. No JSON. No Z-machine parser vocabulary. Written in second person (or first if the character's voice demands it), in the established narrative style.

2.5.3 command-translator.yml

Called each time the player submits input. Translates free natural-language text into a Zork parser command — or decides that no game command is appropriate.

Additional template variables:

Variable Contents
{{userInput}} Raw player input
  • Expected output: A JSON object with one of these shapes:
// Player input maps to a Zork command
{ "type": "command", "command": "open mailbox" }

// Player input has no in-game equivalent; reply narratively
{ "type": "reply", "text": "You pause and take a steadying breath..." }

// LLM wants to update session state; may also include a command
{
  "type": "tools",
  "tools": [
    { "name": "add_note",           "args": { "note": "Player is afraid of the dark." } },
    { "name": "update_character",   "args": { "description": "Updated character prose..." } },
    { "name": "remove_note",        "args": { "index": 2 } }
  ],
  "command": "examine lantern"   // optional — also try this command
}

The command-translator prompt must include the full list of available tools with descriptions, so that the LLM knows what actions it can take independently of the Z-machine.

2.5.4 output-evaluator.yml

Called after each Z-machine response. Decides whether to accept and rewrite the output, or to discard it and try a different command.

Additional template variables:

Variable Contents
{{userIntent}} Original player input (natural language)
{{commandTried}} The Zork command that was sent
{{zorkOutput}} Raw Z-machine text
{{attempt}} Current retry attempt number (1-based)
{{maxAttempts}} Maximum allowed retry attempts
  • Expected output: A JSON object with one of two shapes:
// Accept: rewrite the output for the player
{
  "decision": "accept",
  "text": "The heavy lid of the mailbox swings open..."
}

// Retry: discard this output, try a different command instead
{
  "decision": "retry",
  "command": "open mailbox with hands"
}

When the evaluator returns retry, the engine sends the new command to the Z-machine and calls the evaluator again on the result. If the maximum number of retries is reached without acceptance, the engine falls back to rewriting the last Z-machine output regardless.

The evaluator should return retry when:

  • The Z-machine says it does not understand the command ("I don't understand that" / "That's not a verb I recognise").
  • The Z-machine says the action is not possible in a way that suggests a different phrasing would work ("You can't go that way" when the player clearly wants to go somewhere).
  • The output is mechanically correct but logically inconsistent with the established narrative.

The evaluator should return accept when:

  • The Z-machine performed an observable world-state change (picked up an object, moved to a new room, unlocked something).
  • The action failed in a meaningful, story-relevant way ("The troll blocks your path").
  • The maximum retry count has been reached (soft-forced accept on the last attempt).

2.6 The Game Loop in Detail

Start-Up

  1. Spawn the Z-machine subprocess with zork1.bin.
  2. Collect all output until the first > input prompt.
  3. Call character-generation LLM → store result as session.characterDescription.
  4. Call text-rewriter LLM with the Zork intro text → send rewritten output to client as TurnResult.
  5. Set inputMode: 'text'; await player input.

Per-Turn Loop

user input
    │
    ▼
command-translator LLM
    │
    ├─── type: 'reply'    → send LLM text to client; await next input (no Zork involved)
    │
    ├─── type: 'tools'    → execute tool actions (update character / add or remove notes)
    │         │               then, if a command is included, fall through to ↓
    │         └─────────────────────────────────────────────────────────────────────────┐
    │                                                                                   │
    └─── type: 'command'  ──────────────────────────────────────────────────────────────┘
              │
              ▼
         send command to Z-machine
              │
              ▼
         raw Zork output
              │
              ▼
         extract room name → update currentRoom → update roomHistory
              │
              ▼
         output-evaluator LLM (attempt N of maxRetries)
              │
              ├─── decision: 'retry' AND attempt < maxRetries
              │         │
              │         └──→ send new command to Z-machine (loop back)
              │
              └─── decision: 'accept' (or maxRetries exceeded)
                        │
                        ▼
                   store accepted text in roomHistory[currentRoom]
                        │
                        ▼
                   send TurnResult to client; await next input

Room History

Each accepted player-facing output is stored in session.roomHistory[roomName]. Only the five most recent entries per room are kept (older entries are dropped). This rolling history is injected into all subsequent LLM prompts via {{roomHistory}}, ensuring that descriptions added to a room on previous visits can be referenced again when the player returns.

Room names are extracted from Z-machine output using the status bar (window 1 in the GlkOte protocol), which Zork reliably populates with the current room name. As a fallback, the first line of each Z-machine response is used if it matches the pattern for a room title (capitalised, fewer than 60 characters, no trailing punctuation).


2.7 LLM Tool Actions

The command-translator prompt informs the LLM of three tool actions it can invoke instead of — or in addition to — a Zork command:

Tool Arguments Effect
update_character description: string Replaces session.characterDescription with new prose
add_note note: string Appends a note to session.notes
remove_note index: number Removes the note at the given zero-based index

These tools exist to handle player inputs that have no in-game equivalent but do have a character-state equivalent. Examples:

  • Player writes: "I decide I'm no longer scared of trolls after that encounter." → LLM calls update_character to amend the character's personality note, then replies narratively.
  • Player writes: "Remember that the sword glows near enemies." → LLM calls add_note with that fact so it appears in all future evaluator and rewriter prompts.
  • Player writes: "Forget about that note about the axe." → LLM calls remove_note referencing the relevant index.

2.8 Configuration

All runtime configuration is provided via environment variables (.env):

Variable Default Description
OPENROUTER_API_KEY Required
OPENROUTER_MODEL Required (e.g. anthropic/claude-3-5-sonnet)
ZORK_STORY_FILE ./data/z-code/zork1.bin Path to the Z-machine story file
ZORK_MAX_RETRIES 3 Maximum retry attempts before forced accept
ZORK_HISTORY_SIZE 5 Number of past outputs stored per room
PORT 3002 HTTP port for the Zork server

2.9 Save and Restore

Saving a game involves two steps:

  1. Send the SAVE command to the running Z-machine subprocess; respond to its filename prompt with a temporary file path; read the resulting Quetzal binary and encode it as Base64.
  2. Serialise ZorkSession (character, notes, room history, current room) to JSON. Embed the Base64 Quetzal data as zorkSave.

Loading reverses this: decode and write the Quetzal file to a temp path, start a fresh Z-machine process, send RESTORE and provide that path, then run continueUntilPrompt() to reach the restored state.


2.10 Effort Estimate

Component Effort
ZorkProcess class (subprocess management) ~100 lines
ZorkLlmEngine class (session + loop logic) ~280 lines
src/server-zork.ts ~80 lines
4 × YAML prompt files ~200 lines total
package.json script additions 4 lines
Total ~660 lines

No client-side changes are required. The engine reuses axios and js-yaml, which are already in the project's dependency tree. The only new dependency is ifvms.


Game Format Tests
Zork I z3 Basic text, status bar, save/restore
Anchorhead z8 Fixed-width text, complex parser
Lost Pig z8 Modern Inform 6, clean output
Counterfeit Monkey zblorb Blorb, sounds, complex Inform 7
Anchorhead (Glulx re-release) Out of scope (Glulx, not Z-machine)
Any V6 game z6 Expect graceful failure / fallback