melissa kate
<< Doppelgänger

Game Engine TypeScript Port — Implementation Plan

Context

We're extracting a generic narrative game engine from the Doppelganger Python backend and rebuilding it in TypeScript. The concept: a modern Twine with LLM character support, distributed as an npm package. Runs in the browser (enabling itch.io, Netlify, static hosting). The real validation: Doppelganger itself gets rebuilt on top of the engine.

Architecture Overview

@doppelganger/engine  (core, headless, zero dependencies)
@doppelganger/react   (React hooks + context + default renderer)
@doppelganger/phone-skin  (premium phone UI plugin — later)

The engine is headless — it produces data, never DOM. UI is handled by skins/renderers.

Project Structure

engine/
  package.json
  tsconfig.json
  vitest.config.ts
  src/
    index.ts                     # Public API exports
    core/
      Engine.ts                  # Main class — processCommand() orchestrator
      TimelineManager.ts         # Event system (port of timeline_manager.py)
      ConditionEvaluator.ts      # Shared all/any/none logic (extracted from 4+ places in Python)
      NotificationService.ts     # Trigger-based notifications
      ConversationService.ts     # NPC conversation threads
    state/
      GameState.ts               # State factory + helpers
      StateManager.ts            # Immutable state transitions, save/load
    types/
      index.ts
      timeline.ts                # TimelineEvent, Choice, EventContent, ContentVariant
      state.ts                   # GameState, PhoneState, ConversationMode
      engine.ts                  # EngineConfig, EngineResponse, PresentationMode
      character.ts               # CharacterProfile, ConversationThread, Message
      notification.ts            # NotificationEvent, NotificationTrigger
      plugin.ts                  # SkinPlugin, AppPlugin, LLMProvider interfaces
      content.ts                 # GameDefinition (the top-level config creators provide)
    llm/
      LLMProvider.ts             # Abstract interface
      CharacterEngine.ts         # LLM-powered character conversations
      providers/
        ClaudeProvider.ts
        OpenAIProvider.ts
        OllamaProvider.ts
        NoopProvider.ts          # Scripted-only fallback
    plugins/
      PluginManager.ts
    content/
      ContentLoader.ts
      ContentValidator.ts
    react/                       # Eventually separate package
      DoppelgangerProvider.tsx
      useEngine.ts
      useTimeline.ts
      useConversation.ts
      components/
        NarrativeView.tsx
        ChoiceList.tsx
  tests/
    core/
      ConditionEvaluator.test.ts
      TimelineManager.test.ts
      Engine.test.ts
    integration/
      SimpleGame.test.ts

Key Design Decisions

  1. ConditionEvaluator extracted — Python duplicates {all, any, none} checking in prerequisites, show_if (choices), show_if (variants), and excluded_by. One function, one set of tests.
  1. Engine is NOT a singleton — instantiated per game. State is explicit, not global.
  1. State is immutable — every operation returns a new state object (critical for React and save/load).
  1. Command map is configurable — Python hardcodes "pick up phone" → "UNLOCK_PHONE". In the engine, this comes from GameDefinition.commandMap. Most games will use click-based choices anyway.
  1. PhoneEngine is a plugin, not core — a game can run in pure narrative mode. Phone UI is Phase 4.
  1. LLM is optional — no provider configured = scripted-only game, zero API costs, pure static site.

Core Types (derived from existing Python + JSON)

// The shared condition pattern (used in 4+ places in Python code)
interface Condition {
  all?: string[];
  any?: string[];
  none?: string[];
}

// What creators provide
interface GameDefinition {
  schemaVersion: string;
  metadata: { title: string; author?: string; version?: string };
  startEvent: string;
  timeline: { events: Record<string, TimelineEvent> };
  characters?: Record<string, CharacterProfile>;
  conversations?: Record<string, ConversationThread>;
  notifications?: NotificationEvent[];
  commandMap?: Record<string, string>;
  contextMaps?: Record<string, Record<string, string>>;
  apps?: AppDefinition[];
}

// What the engine returns
interface EngineResponse {
  eventId: string;
  presentation: { mode: "narrative" | "phone" | "hybrid" };
  content: { text: string; chapter?: string };
  gameState: GameState;
  choices: string[];
  discoveries: string[];
  notifications: NotificationEvent[];
}

Build Order

Phase 1: Core engine (START HERE)

  1. Types — all interfaces. Formalizes the contracts.
  2. ConditionEvaluator — smallest testable unit. Port all/any/none logic.
  3. TimelineManager — port from timeline_manager.py (533 lines). Accept object, not file path.
  4. StateManager — immutable state transitions, save/load.
  5. Engine — wire together. processCommand() mirrors presentation_service.process_command().
  6. ContentValidator — validate GameDefinition structure.
  7. Tests — use timeline_basic.json as fixture.

Phase 2: React bindings

  1. Context provider + hooks (useEngine, useTimeline).
  2. Default narrative renderer components.
  3. Build "hello world" game and render it.

Phase 3: Conversations + LLM

  1. ConversationService (scripted).
  2. LLMProvider interface + NoopProvider + ClaudeProvider.
  3. CharacterEngine for LLM-powered dialogue.

Phase 4: Phone UI + Plugins

  1. PluginManager + plugin interfaces.
  2. Port PhoneEngine as a plugin.
  3. Build phone skin package.

Phase 5: Migrate Doppelganger (THE REAL TEST)

  1. Convert timeline JSONs to GameDefinition format.
  2. Build Doppelganger-specific app plugins (Spacebook, etc.).
  3. Wire frontend to use engine instead of API calls.
  4. Python backend becomes optional LLM proxy only.

Source Files to Reference

Verification

  1. Unit tests pass for ConditionEvaluator, TimelineManager, StateManager
  2. Integration test: "Locked Room" 3-event game runs start-to-finish
  3. timeline_basic.json loads and validates without errors
  4. Save/load round-trips GameState correctly
  5. React demo renders narrative + choices and processes commands
  6. Ultimate test: Doppelganger story runs on the engine (Phase 5)