Doppelganger Engine
A narrative game engine with LLM character support — modern Twine for the AI era.
Project Structure
engine/
src/ Core engine (npm package)
tests/ Vitest tests
demo/ Vanilla HTML demo (no framework)
demo-react/ React demo app
story-maker/ Visual game editor
Prerequisites
- Node.js 18+
- npm
Quick Start
cd engine
# Install dependencies
npm install
# Run tests
npm test
# Build the engine
npm run build
Running the Demos
React Demo
Full React app with narrative, dialogue, LLM-powered chat, save/load.
cd skins/cinematic
npm install
# Optional: add your Anthropic API key for LLM conversations
echo 'VITE_ANTHROPIC_API_KEY=sk-ant-...' > .env.local
npm run dev
Open http://localhost:5173/ (or whatever port Vite assigns)
Story Maker
Visual game editor — create characters, build branching stories, preview in-browser.
cd story-maker
npm install
npm run dev
Open http://localhost:5173/ (or whatever port Vite assigns)
Features:
- Story Map: list view with tree nesting + graph view (React Flow)
- Characters: identity, personality, behavior, visuals, conversations
- Discoveries: central view of all game discoveries and where they're used
- World: game metadata, start event
- Preview: play through your game with debug sidebar
- Import/Export: load and save games as JSON
- Save Progress: persists to localStorage (Cmd+S shortcut)
Engine Development
# Type check
npm run typecheck
# Run tests in watch mode
npm run test:watch
# Build with watch mode (for demos)
npm run dev
Creating a Game
Minimal Example
import { Engine } from '@doppelganger/engine';
const game = {
schemaVersion: '1.0',
metadata: { title: 'My Game' },
startEvent: 'START',
timeline: {
events: {
START: {
id: 'START',
type: 'intro',
act: 1,
presentationHint: 'narrative',
prerequisites: {},
excluded_by: [],
unlocks: [],
trigger_type: 'manual',
content: { text: 'Your story begins here.' },
choices: [
{ text: 'Continue', leads_to: 'NEXT' },
],
grants_discoveries: [],
blocks_events: [],
},
NEXT: {
id: 'NEXT',
type: 'narrative',
act: 1,
presentationHint: 'narrative',
prerequisites: {},
excluded_by: [],
unlocks: [],
trigger_type: 'manual',
content: { text: 'The end.' },
choices: [],
grants_discoveries: ['game_complete'],
blocks_events: [],
},
},
},
};
const engine = new Engine({ game });
const response = engine.start();
console.log(response.content.text); // "Your story begins here."
console.log(response.choices); // ["Continue"]
React Integration
import { DoppelgangerProvider } from '@doppelganger/engine/react';
import { useTimeline } from '@doppelganger/engine/react';
function App() {
return (
<DoppelgangerProvider config={{ game: myGame }}>
<GameView />
</DoppelgangerProvider>
);
}
function GameView() {
const { text, chapter, choices, isDialogue, dialogue, processCommand } = useTimeline();
return (
<div>
{chapter && <h2>{chapter}</h2>}
<p>{text}</p>
{choices.map(choice => (
<button key={choice} onClick={() => processCommand(choice)}>
{choice}
</button>
))}
</div>
);
}
With LLM Characters
import { Engine, ClaudeProvider, CharacterEngine } from '@doppelganger/engine';
const engine = new Engine({ game: myGame });
const characterEngine = new CharacterEngine({
provider: new ClaudeProvider({ apiKey: 'sk-ant-...' }),
gameContext: 'A mystery set in Los Angeles.',
});
// Characters respond in-character based on their profile
const response = await characterEngine.generateResponse(
myGame.characters.maya, // CharacterProfile
'Have you seen anything?', // Player message
engine.getState(), // Current game state
);
Using the Story Maker
- Run the story maker (
cd story-maker && npm run dev) - Create characters and events
- Preview your game in the Preview tab
- Export as JSON
- Load the JSON in your game with
new Engine({ game: importedJson })
Or import the demo game: click Import and select public/the-vanishing.json.