# 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. | 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 | ### 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` (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 | `gameIntroduction` | `{ title, author, inputMode }` | | 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 { 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 } ``` The old `{ text }` field is removed. For backward compatibility during transition, the server may also emit a flattened `text` field containing all paragraph texts joined with newlines. ### 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 { ... } ``` 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(); async function handleGameApi(socket, method, args): Promise { 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.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 choice columns, exactly as in the prototype's `createChoiceContainer`. Registered in `module-registry.js`. - Listens for a `story:choices` event (dispatched by `game-loop-module.js` from the `TurnResult.choices` array). - Groups choices by `category` field into named columns with localised headers. - Registers keyboard shortcuts (A/B/C... or 1/2/3... depending on whether choices are categorised). - On click/keypress: calls `socketClient.sendChoice(index)` → `gameApi { method: 'chooseChoice', args: [index] }`. ### 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. Extract `server-base.ts`; verify YAML engine still works. 2. Define `TurnResult` + `StoryTag` interfaces. 3. Write `tag-parser.ts`; unit test it. 4. Rewrite `GameRunner` to produce `TurnResult`; update `server.ts` to emit it. 5. Update `game-loop-module.js` to consume `TurnResult` and dispatch tag events. 6. Update `audio-manager-module.js` and `ui-display-handler-module.js` to listen for tag events. 7. Remove tag parsing from `markup-parser-module.js`. 8. Build `choice-display-module.js` and `ui-input-handler` mode switching. 9. Build `InkEngine` and `server-ink.ts`; smoke-test with a compiled `.ink.json`. 10. Add Z-Code server (see `zcode_inclusion.md`).