Files
ai.interactive.fiction/ink_inclusion.md
T

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:

  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.

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:

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

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

// 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 (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:

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

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.


  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.