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
- 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.
- Engine is NOT a singleton — instantiated per game. State is explicit, not global.
- State is immutable — every operation returns a new state object (critical for React and save/load).
- 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.
- PhoneEngine is a plugin, not core — a game can run in pure narrative mode. Phone UI is Phase 4.
- 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)
- Types — all interfaces. Formalizes the contracts.
- ConditionEvaluator — smallest testable unit. Port all/any/none logic.
- TimelineManager — port from
timeline_manager.py(533 lines). Accept object, not file path. - StateManager — immutable state transitions, save/load.
- Engine — wire together.
processCommand()mirrorspresentation_service.process_command(). - ContentValidator — validate GameDefinition structure.
- Tests — use
timeline_basic.jsonas fixture.
Phase 2: React bindings
- Context provider + hooks (
useEngine,useTimeline). - Default narrative renderer components.
- Build "hello world" game and render it.
Phase 3: Conversations + LLM
- ConversationService (scripted).
- LLMProvider interface + NoopProvider + ClaudeProvider.
- CharacterEngine for LLM-powered dialogue.
Phase 4: Phone UI + Plugins
- PluginManager + plugin interfaces.
- Port PhoneEngine as a plugin.
- Build phone skin package.
Phase 5: Migrate Doppelganger (THE REAL TEST)
- Convert timeline JSONs to GameDefinition format.
- Build Doppelganger-specific app plugins (Spacebook, etc.).
- Wire frontend to use engine instead of API calls.
- Python backend becomes optional LLM proxy only.
Source Files to Reference
app/services/timeline_manager.py— primary port target for TimelineManagerapp/services/story_service.py— command resolution logic for Engineapp/services/presentation_service.py— orchestration pattern for Engine.processCommand()app/services/phone_engine.py— PhoneState model (Phase 4)app/services/notification_service.py— notification trigger logicapp/services/conversation_service.py— conversation managementtimeline_basic.json— defines the data format / type systemfrontend/app/store/gameSlice.js— GameState shape (consumer contract)
Verification
- Unit tests pass for ConditionEvaluator, TimelineManager, StateManager
- Integration test: "Locked Room" 3-event game runs start-to-finish
timeline_basic.jsonloads and validates without errors- Save/load round-trips GameState correctly
- React demo renders narrative + choices and processes commands
- Ultimate test: Doppelganger story runs on the engine (Phase 5)