# 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 1–8 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 ### 2.1 `ifvms.js` (Recommended) - **Repo**: [github.com/curiousdannii/ifvms.js](https://github.com/curiousdannii/ifvms.js) - **npm**: `npm install ifvms` (package name: `ifvms`) - **License**: MIT - **Versions supported**: Z-machine v1–8 (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](https://github.com/garglk/garglk) (Bocfel) + [curiousdannii/emglken](https://github.com/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](https://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: ```json { "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: ```json { "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: ```ts 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 { ... } async sendCommand(text: string): Promise { ... } async sendCharInput(charCode: number): Promise { ... } async undo(): Promise { ... } 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: - `playerCommand` → `engine.sendCommand(text)` → emit `narrativeResponse` - `chooseChoice` → `engine.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: Low** — `ifvms.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`). 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 `text` 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 `
` 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: Low–Medium** 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 Knuth–Plass line breaking is incompatible with fixed-width layout. Options: - Render fixed spans as `
` 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.**

### 6.8 Hyperlinks (Extended Glk)

**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:

```ts
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 |

---

## 11. Recommended Implementation Order

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.ts` → `ZorkProcess` | 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:

```ts
interface ZorkSession {
  characterDescription: string;       // Generated at game start; LLM can update
  notes: string[];                     // Persistent notes the LLM adds/removes
  roomHistory: Record; // 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 (300–500 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:

```jsonc
// 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:

```jsonc
// 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`.

---

## 12. Recommended Test Games

| 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 |