16 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.
| 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.
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 | 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:
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).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.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 choice columns, exactly as in the prototype's createChoiceContainer. Registered in module-registry.js.
- Listens for a
story:choicesevent (dispatched bygame-loop-module.jsfrom theTurnResult.choicesarray). - Groups choices by
categoryfield 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:
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
- Extract
server-base.ts; verify YAML engine still works. - Define
TurnResult+StoryTaginterfaces. - Write
tag-parser.ts; unit test it. - Rewrite
GameRunnerto produceTurnResult; updateserver.tsto emit it. - Update
game-loop-module.jsto consumeTurnResultand dispatch tag events. - Update
audio-manager-module.jsandui-display-handler-module.jsto listen for tag events. - Remove tag parsing from
markup-parser-module.js. - Build
choice-display-module.jsandui-input-handlermode switching. - Build
InkEngineandserver-ink.ts; smoke-test with a compiled.ink.json. - Add Z-Code server (see
zcode_inclusion.md).