19 KiB
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:
- Fetched a compiled
.ink.jsonfile directly (e.g.Herrenhaus.ink.json). - Created an
inkjs.Storyinstance from the JSON. - Called
story.Continue()in a loop to collect paragraphs andstory.currentTagsfor each. - Dispatched choices via
story.ChooseChoiceIndex(index). - Tags drove all media and layout:
CHAPTER: Title→ drop-cap headingSEPARATOR→ ornamental dividerAUDIO: src/AUDIOLOOP: src→ sound effects / musicIMAGE: src→ inline imageCLASS: name→ custom CSS on a paragraphCLEAR/RESTART→ wipe display- Choice tags
ACTION: examine→ sorted into categorised choice columns
- Save/load used
story.state.toJson()/story.state.LoadJson()stored inlocalStorage.
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.jsno longer needs to detect and strip custom inline markup from text. text-processor-module.jsreceives 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.
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 amusic:property, and abackground[image]tag if it definesbackground:. - 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.tscan emit ansfx[filename]tag alongside the result text when the world YAML definessfx:on an object's action. - YAML world-model additions (
world-model.ts):Room: add optionalmusic?: string,background?: string,entryTag?: stringfields.GameObject: add optionalsfx?: 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:
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).inputModeis always'text'unless the game ends. - Ink engine: uses
chooseChoicewhen choices are available (inputMode: 'choice'),playerCommandif an external function expects text input (inputMode: 'text'). - Z-Code engine: uses
playerCommandfor standard line input (inputMode: 'text'), character input requests translate tochooseChoicewith a synthetic choice list (seezcode_inclusion.md).
3. Three-Server NPM Configuration
3.1 package.json additions
"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
// 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.jsonfile atnewGame()/ on construction. continueStory(): TurnResult:- Loop
story.canContinue: callstory.Continue(), parsestory.currentTagsintoStoryTag[], collectParagraphResult. - After loop: map
story.currentChoicesintoChoiceResult[], derivecategoryfromaction[...]tags. - Set
inputMode:'choice'if choices present,'end'if story finished,'text'otherwise (external function waiting).
- Loop
chooseChoice(index):story.ChooseChoiceIndex(index)thencontinueStory().saveGame():story.state.toJson()→ stored as string in session map.loadGame(json):story.state.LoadJson(json)thencontinueStory()to reconstruct current state display.
4.2 Tag Parser
// 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
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 (
AthroughZ), 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:
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:
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
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:choicesevent (dispatched bygame-loop-module.jsfrom theTurnResult.choicesarray). - Uses a template object with one initial full-size
defaultcell. - Assigns keyboard letters from
letter[x]tags first, then fills remaining choices withAthroughZin 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:
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:
- Iterate
paragraphs→ feed eachtextintotext-bufferpipeline → dispatchstory:tagfor each tag array. - Dispatch
story:choiceswithchoicesarray. - Call
uiInputHandler.setMode(inputMode). - Update
gameStatefromresult.gameStateif 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
- Define
TurnResult+StoryTaginterfaces. - Write
tag-parser.ts; parse the single canonical tag shapekey[value](param). - Update the client socket pipeline to consume canonical
TurnResultobjects only. - Dispatch structured
story:tag,story:choices, andstory:input-modeevents from the socket adapter. - Add
choice-display-module.jswith the single-cell default template and letter assignment described in §5.0. - Update
audio-manager-module.jsandui-display-handler-module.jsto listen forstory:tagevents by translating them into the current media/display paths. - Convert Zork server emission from flattened text to canonical
TurnResult. - Convert YAML server introduction/responses to canonical
TurnResult. - Extract
server-base.ts; verify YAML and Zork still work. - Build
InkEngineandserver-ink.ts; smoke-test with a compiled.ink.json. - Once all engines emit structured tags, remove structural tag parsing from
markup-parser-module.js.