# Multi-Engine Architecture & Ink Engine Integration Analysis

## Overview

This document describes the redesign of the server architecture to support **three interchangeable game engine servers**, all serving the **identical client UI** over a shared Socket.IO protocol. It also covers the Ink engine specifically, the unified tag-driven event pipeline that all engines must implement, and the required changes to the YAML/LLM engine to conform to the same protocol.

The three engines are:

| Engine | Entry point | `npm run` command | Story format |
|---|---|---|---|
| **YAML + LLM** | `src/server.ts` | `dev:yaml` | `.yml` world model + OpenRouter LLM |
| **Ink** | `src/server-ink.ts` | `dev:ink` | Compiled `.ink.json` |
| **Z-Code** | `src/server-zcode.ts` | `dev:zcode` | `.z5` / `.z8` / `.zblorb` (see `zcode_inclusion.md`) |

All three servers:
- Serve the same `public/` directory (static files, no change).
- Speak the same Socket.IO event protocol (see §3).
- Are selectable by starting a different npm script; no client code changes needed.

---

## How the Prototype Worked

The prototype (`prototype/game.js`) ran **entirely in the browser**:

1. Fetched a compiled `.ink.json` file directly (e.g. `Herrenhaus.ink.json`).
2. Created an `inkjs.Story` instance from the JSON.
3. Called `story.Continue()` in a loop to collect paragraphs and `story.currentTags` for each.
4. Dispatched choices via `story.ChooseChoiceIndex(index)`.
5. Tags drove all media and layout:
   - `CHAPTER: Title` → drop-cap heading
   - `SEPARATOR` → ornamental divider
   - `AUDIO: src` / `AUDIOLOOP: src` → sound effects / music
   - `IMAGE: src` → inline image
   - `CLASS: name` → custom CSS on a paragraph
   - `CLEAR` / `RESTART` → wipe display
   - Choice tags `ACTION: examine` → sorted into categorised choice columns
6. Save/load used `story.state.toJson()` / `story.state.LoadJson()` stored in `localStorage`.

The current server/client architecture replaces all of this with a Socket.IO loop: the server processes commands and emits `narrativeResponse`; the client renders.

---

## 1. The Unified Tag-Driven Event Pipeline

### 1.1 Core Principle

All text paragraph content emitted by any engine server contains **only prose text with inline Markdown and SmartyPants punctuation**. No media, layout, or structure information is embedded in the text strings. All such information is communicated exclusively via **structured tag objects** attached to paragraphs, choices, or the turn envelope.

This means:
- The client's `markup-parser-module.js` no longer needs to detect and strip custom inline markup from text.
- `text-processor-module.js` receives clean text and applies only SmartyPants + basic Markdown (bold, italic, inline code).
- `audio-manager-module.js`, `ui-display-handler-module.js`, and any future media module listen for tag events dispatched alongside each paragraph.

### 1.2 Tag Syntax (Authoring Convention)

All engines author tags using the same `key[value]` bracket convention. For Ink this maps directly to native `# key[value]` tags. For YAML and Z-Code the server synthesises equivalent tag objects.

Ink choice text uses square brackets for its own display/control syntax, but choice-local tags still use the normal tag syntax on their own tag lines below the choice. The currently implemented parser accepts `# letter[o]` and `# action[examine]`; the earlier prototype-style `# key: value` notation is not part of the active syntax.

| Tag | Scope | Meaning |
|---|---|---|
| `music[filename]` | paragraph or global | Start looping music track |
| `music[]` | paragraph | Stop music |
| `sfx[filename]` | paragraph | Play one-shot sound effect |
| `image[filename](caption)` | paragraph | Insert inline image |
| `chapter[Title]` | paragraph | Begin chapter: heading + drop-cap on next paragraph |
| `section` | paragraph | Ornamental section break (fleuron/divider) |
| `class[name]` | paragraph | Add CSS class to this paragraph element |
| `background[filename]` | paragraph | Change full-page background image |
| `clear` | paragraph | Wipe all story text from the display |
| `title[text]` | global (first turn) | Set document/story title |
| `author[text]` | global (first turn) | Set byline |
| `action[category]` | choice | Sort this choice into a named column |
| `letter[x]` | choice | Reserve keyboard letter `x` for this choice |

### 1.3 Parsed Tag Object Shape

The server parses raw tags into structured objects before sending. The client never parses tag strings.

```ts
interface StoryTag {
  key: string;     // e.g. "music", "chapter", "action"
  value?: string;  // e.g. "forest.mp3", "Part Two", "examine"
  param?: string;  // optional second param, e.g. image caption
}
```

### 1.4 YAML Engine Rewrite for Tag Support

**Effort: Medium**

The current `TextAdventureEngine` + `GameRunner` + LLM pipeline does not emit any tag objects. To conform to the unified protocol it must be extended:

- **Room transitions**: When the player moves to a new room, the engine emits a `music[roomTrack]` tag if the room YAML defines a `music:` property, and a `background[image]` tag if it defines `background:`.
- **Chapter/section**: When a new room is entered for the first time, optionally emit a `chapter[roomName]` tag to trigger a drop-cap opening paragraph.
- **SFX**: Object interaction handlers in `game-engine.ts` can emit an `sfx[filename]` tag alongside the result text when the world YAML defines `sfx:` on an object's action.
- **YAML world-model additions** (`world-model.ts`):
  - `Room`: add optional `music?: string`, `background?: string`, `entryTag?: string` fields.
  - `GameObject`: add optional `sfx?: Record<string, string>` (action → sound file) field.
- **`GameRunner.processCommand()`**: collect tags generated during the action, include them in the turn result alongside the narrative text.
- **LLM narrative text**: instruct the prompt to return only clean prose with Markdown. Strip any stray bracket-tag syntax from LLM output before sending.

---

## 2. The Unified Socket.IO Protocol

### 2.1 Session Lifecycle Events (unchanged)

These work identically on all three servers:

| Direction | Event | Payload |
|---|---|---|
| client → server | `gameApi` | `{ method, args }` → `respond(result)` |
| server → client | `narrativeResponse` | `TurnResult` |
| server → client | `gameSaved` | `{ slot }` |
| server → client | `gameLoaded` | `{ slot }` |
| server → client | `error` | `{ message }` |

`gameApi` methods (all engines): `newGame`, `loadGame`, `saveGame`, `hasSaveGame`, `getSaveGames`, `isGameRunning`.

**New `gameApi` method (Ink + Z-Code only):**

| Method | Args | Description |
|---|---|---|
| `chooseChoice` | `[choiceIndex: number]` | Select a choice by index; server runs the next turn and emits `narrativeResponse` |

### 2.2 The `narrativeResponse` Event (Extended)

This is the core change. Previously `narrativeResponse` carried `{ text, gameState, suggestions }`. It is replaced with a richer `TurnResult` shape that all engines must produce:

```ts
interface TurnResult {
  turnId: number;                  // Unique ascending id within the game session
  paragraphs: ParagraphResult[];   // Ordered list of story paragraphs this turn
  choices: ChoiceResult[];         // Available choices (empty in text-input mode)
  inputMode: 'choice' | 'text' | 'end';
  globalTags?: StoryTag[];         // Only on first turn: title, author, etc.
  gameState?: {                    // Optional, engines may omit unused fields
    currentRoomId?: string;
    score?: number;
    moves?: number;
  };
}

interface ParagraphResult {
  text: string;       // Clean prose: Markdown inline + SmartyPants only
  tags: StoryTag[];   // Structural/media tags for this paragraph
}

interface ChoiceResult {
  index: number;      // Engine-specific choice index
  text: string;       // Display text (SmartyPants applied)
  tags: StoryTag[];   // Per-choice tags, e.g. action[examine]
  category?: string;  // Derived from action[...] tag for column grouping
  letter?: string;    // Optional reserved keyboard letter, derived from letter[x]
}
```

The old flattened `{ text }` field is removed. Servers must emit `TurnResult` only.

### 2.3 `playerCommand` vs `chooseChoice`

- **YAML/LLM engine**: always uses `playerCommand` (free text). `inputMode` is always `'text'` unless the game ends.
- **Ink engine**: uses `chooseChoice` when choices are available (`inputMode: 'choice'`), `playerCommand` if an external function expects text input (`inputMode: 'text'`).
- **Z-Code engine**: uses `playerCommand` for standard line input (`inputMode: 'text'`), character input requests translate to `chooseChoice` with a synthetic choice list (see `zcode_inclusion.md`).

---

## 3. Three-Server NPM Configuration

### 3.1 `package.json` additions

```json
"scripts": {
  "dev:yaml":   "nodemon --watch src --ext ts,json --exec \"ts-node src/server.ts\"",
  "dev:ink":    "nodemon --watch src --ext ts,json --exec \"ts-node src/server-ink.ts\"",
  "dev:zcode":  "nodemon --watch src --ext ts,json --exec \"ts-node src/server-zcode.ts\"",
  "start:yaml":  "node dist/server.js",
  "start:ink":   "node dist/server-ink.js",
  "start:zcode": "node dist/server-zcode.js"
}
```

Each server file contains only the engine-specific `handleGameApi` switch and session management. The static file serving, port-finding logic, and Socket.IO setup are extracted to a shared `src/server-base.ts` that all three import.

### 3.2 Shared `server-base.ts`

```ts
// src/server-base.ts
export function createServer(handleGameApi: GameApiHandler): http.Server { ... }
export function startServer(server: http.Server, port: number, range: number): Promise<void> { ... }
```

Approximately 80 lines, extracted from the current `server.ts`. No functional change.

---

## 4. Ink Engine Server

### 4.1 `src/engine/ink-engine.ts`

**Effort: ~200 lines new**

- `npm install inkjs` (same library as in the prototype).
- Load `.ink.json` file at `newGame()` / on construction.
- `continueStory(): TurnResult`:
  1. Loop `story.canContinue`: call `story.Continue()`, parse `story.currentTags` into `StoryTag[]`, collect `ParagraphResult`.
  2. After loop: map `story.currentChoices` into `ChoiceResult[]`, derive `category` from `action[...]` tags.
  3. Set `inputMode`: `'choice'` if choices present, `'end'` if story finished, `'text'` otherwise (external function waiting).
- `chooseChoice(index)`: `story.ChooseChoiceIndex(index)` then `continueStory()`.
- `saveGame()`: `story.state.toJson()` → stored as string in session map.
- `loadGame(json)`: `story.state.LoadJson(json)` then `continueStory()` to reconstruct current state display.

### 4.2 Tag Parser

```ts
// src/utils/tag-parser.ts (~30 lines)
export function parseTag(raw: string): StoryTag | null {
  // Matches: key[value](param)  or  key[value]  or  key
  const m = raw.match(/^(\w+)(?:\[([^\]]*)\])?(?:\(([^)]*)\))?$/);
  if (!m) return null;
  return { key: m[1], value: m[2], param: m[3] };
}
```

Used by all three engines.

### 4.3 `src/server-ink.ts`

**Effort: ~60 lines new**

```ts
import { createServer, startServer } from './server-base';
import { InkEngine } from './engine/ink-engine';

const sessions = new Map<string, InkEngine>();

async function handleGameApi(socket, method, args): Promise<object> {
  switch (method) {
    case 'newGame': { /* create InkEngine, continueStory(), emit narrativeResponse */ }
    case 'chooseChoice': { /* session.chooseChoice(args[0]), emit narrativeResponse */ }
    case 'saveGame': { /* session.saveGame() → slot map */ }
    // ... isGameRunning, hasSaveGame, loadGame, getSaveGames
  }
}
```

---

## 5. Client-Side Changes

### 5.0 Choice UI Architecture

The first Ink integration should ship with the simplest useful choice UI:

- Display all available choices in one ordered list.
- Ignore choice grouping tags visually for now.
- Assign every visible choice a keyboard letter.
- Allow at most 26 visible choices (`A` through `Z`), which is enough for the current interaction model.
- Choices with explicit `letter[x]` tags reserve that letter first.
- Remaining choices receive letters in ascending screen order, skipping letters already reserved by tags.
- The chosen letter is shown in the UI and pressing that letter selects the same choice as clicking it.
- Letter matching is case-insensitive.

The architecture must still be ready for later choice templates. The choice renderer should normalize choices into a presentation model:

```ts
interface ChoicePresentation {
  index: number;
  text: string;
  tags: StoryTag[];
  category?: string;
  letter: string;
  templateCell: string; // initially always "default"
}
```

The first implemented template has exactly one full-size cell named `default`. It receives every choice whose tags do not match a registered template cell. A later template can register cells such as `examine`, `ask`, `inventory`, or `reflect`, and route choices by `action[...]`, `category`, or a future `cell[...]` tag without changing the server protocol.

### 5.1 Tag Event Dispatch

**Modified: `game-loop-module.js`** (or `socket-client-module.js`)

On every `narrativeResponse`, after feeding text into the pipeline, iterate `paragraphs[i].tags` and dispatch a DOM `CustomEvent('story:tag', { detail: tag })` for each. Global tags dispatch once as `CustomEvent('story:global-tags', { detail: globalTags })`.

### 5.2 `audio-manager-module.js`

**Effort: ~20 lines added**

Replace the current ad-hoc inline-markup scan with a listener:
```js
document.addEventListener('story:tag', ({ detail: tag }) => {
  if (tag.key === 'music') tag.value ? this.playMusic(tag.value) : this.stopMusic();
  if (tag.key === 'sfx') this.playSfx(tag.value);
});
```

### 5.3 `ui-display-handler-module.js`

**Effort: ~50 lines added**

```js
document.addEventListener('story:tag', ({ detail: tag }) => {
  if (tag.key === 'chapter') this.beginChapter(tag.value);   // emit heading, set drop-cap flag
  if (tag.key === 'section') this.insertSectionBreak();
  if (tag.key === 'image') this.insertImage(tag.value, tag.param);
  if (tag.key === 'background') this.setBackground(tag.value);
  if (tag.key === 'clear') this.clearDisplay();
  if (tag.key === 'class') this.pendingClass = tag.value;    // applied to next paragraph
});
```

### 5.4 `markup-parser-module.js`

**Effort: ~10 lines removed, ~10 lines changed**

Remove all tag-detection regex from the text stream. The module now only applies SmartyPants and Markdown inline formatting to the paragraph `text` field. No structural parsing of text content.

### 5.5 New: `choice-display-module.js`

**Effort: ~150 lines new**

Renders available choices from the canonical `TurnResult.choices` array. Registered in `module-registry.js`.

- Listens for a `story:choices` event (dispatched by `game-loop-module.js` from the `TurnResult.choices` array).
- Uses a template object with one initial full-size `default` cell.
- Assigns keyboard letters from `letter[x]` tags first, then fills remaining choices with `A` through `Z` in visible order.
- On click/keypress: calls `socketClient.sendChoice(index)` → `gameApi { method: 'chooseChoice', args: [index] }`.
- Future grouped or column layouts should be implemented by adding more template cells and routing rules, not by changing the server protocol.

### 5.6 `ui-input-handler-module.js`

**Effort: ~25 lines modified**

Extend `setInputAvailability()` to accept a mode:
```js
setMode(mode) { // 'text' | 'choice' | 'end'
  this.inputArea.style.display = mode === 'text' ? '' : 'none';
  document.dispatchEvent(new CustomEvent('story:input-mode', { detail: mode }));
}
```
`choice-display-module.js` listens on `story:input-mode` to show/hide choice columns.

### 5.7 `game-loop-module.js`

**Effort: ~40 lines modified**

On `narrativeResponse`:
1. Iterate `paragraphs` → feed each `text` into `text-buffer` pipeline → dispatch `story:tag` for each tag array.
2. Dispatch `story:choices` with `choices` array.
3. Call `uiInputHandler.setMode(inputMode)`.
4. Update `gameState` from `result.gameState` if present.

---

## 6. What Does NOT Need to Change

| Component | Status |
|---|---|
| All TTS modules | Unchanged |
| `text-buffer-module.js`, `sentence-queue-module.js`, `animation-queue-module.js` | Unchanged |
| `paragraph-layout-module.js`, `layout-renderer-module.js` | Unchanged (drop-cap invoked by tag event) |
| `persistence-manager-module.js` | Unchanged |
| `options-ui-module.js` | Unchanged |
| `playback-coordinator-module.js` | Unchanged |
| `ui-controller-module.js` | Minor: reads `inputMode` from game state instead of deriving it |
| Socket.IO infrastructure, `module-registry.js`, `loader.js` | Unchanged (new module registered) |
| `public/index.html`, CSS | Unchanged (choice columns need ~30 lines new CSS) |

---

## 7. Effort Summary

| Area | Effort |
|---|---|
| `src/server-base.ts` — shared server setup | ~80 lines extracted |
| `src/server-ink.ts` — Ink server entry | ~60 lines new |
| `src/engine/ink-engine.ts` — Ink story runner | ~200 lines new |
| `src/utils/tag-parser.ts` — tag parser | ~30 lines new |
| `TurnResult` / `StoryTag` interfaces | ~30 lines new |
| YAML world model additions (`world-model.ts`, `game-engine.ts`, `game-runner.ts`) | ~80 lines modified |
| `game-loop-module.js` — turn result routing + tag dispatch | ~40 lines modified |
| `audio-manager-module.js` — tag event listener | ~20 lines added |
| `ui-display-handler-module.js` — tag event listener | ~50 lines added |
| `markup-parser-module.js` — remove inline tag parsing | ~10 lines removed |
| New `choice-display-module.js` | ~150 lines new |
| `ui-input-handler-module.js` — mode switching | ~25 lines modified |
| CSS — choice column layout | ~30 lines added |
| `package.json` — new npm scripts | ~8 lines |

**Total: ~830 lines** across ~6 new files and ~8 modified files.

---

## 8. Recommended Implementation Order

1. Define `TurnResult` + `StoryTag` interfaces.
2. Write `tag-parser.ts`; parse the single canonical tag shape `key[value](param)`.
3. Update the client socket pipeline to consume canonical `TurnResult` objects only.
4. Dispatch structured `story:tag`, `story:choices`, and `story:input-mode` events from the socket adapter.
5. Add `choice-display-module.js` with the single-cell default template and letter assignment described in §5.0.
6. Update `audio-manager-module.js` and `ui-display-handler-module.js` to listen for `story:tag` events by translating them into the current media/display paths.
7. Convert Zork server emission from flattened text to canonical `TurnResult`.
8. Convert YAML server introduction/responses to canonical `TurnResult`.
9. Extract `server-base.ts`; verify YAML and Zork still work.
10. Build `InkEngine` and `server-ink.ts`; smoke-test with a compiled `.ink.json`.
11. Once all engines emit structured tags, remove structural tag parsing from `markup-parser-module.js`.
