Implementation guide · TypeScript quest engine
A headless TypeScript quest system built on events, not imports.
Quest logic should not import combat, inventory, wallet, UI, or the renderer. Model objectives as data, progress them from typed events, and resolve prerequisites and rewards through registries owned by the game.
Direct imports turn quests into a dependency hub.
A typical first quest system switches on objective types and calls every gameplay store directly. It works for a prototype, but each new mechanic edits the central engine. The system becomes tied to one renderer, one save shape, and one game.
A reusable quest engine only needs to understand lifecycle and progress. The game supplies meaning at the boundary: events report what happened, conditions answer questions, and effects perform rewards.
- Objectives: target values plus the event that advances them.
- Prerequisites: completed quest IDs and named condition references.
- Rewards: named effect references with serializable parameters.
- State: active progress, completed IDs, and registered definitions.
Keep quest content serializable.
Functions inside content cannot be validated, sent over a network, edited safely, or compared in source control. Use references instead. They form a stable contract between authoring tools and runtime behavior.
const walkTheCity = {
id: 'walk-the-city',
prerequisites: {
conditions: [{ type: 'player.minLevel', params: { level: 2 } }],
},
objectives: [
{
id: 'distance',
target: 100,
trigger: { event: 'player:moved', amountFrom: 'distance' },
},
{
id: 'meet-guide',
target: 1,
trigger: {
event: 'dialogue:ended',
filter: { dialogueId: 'city-guide' },
},
},
],
rewards: [{ type: 'wallet.addGold', params: { amount: 100 } }],
chainNext: ['visit-market'],
}Filters match event payload fields, while amountFrom selects a numeric payload value. Without amountFrom, each matching event advances by one. This small vocabulary covers collection, travel, interaction, dialogue, combat, and many game-specific objectives without hard-coding their types.
Subscribe only to events required by active objectives.
When a quest starts, the engine can derive the unique event set needed by unfinished objectives. It subscribes once per event, dispatches progress to matching objectives, and removes subscriptions as objectives and quests complete.
const conditions = createConditionRegistry<GameContext>()
const effects = createEffectRegistry<GameContext>()
conditions.register('player.minLevel', (params, ctx) =>
ctx.player.level >= Number(params.level)
)
effects.register('wallet.addGold', (params, ctx) =>
ctx.wallet.add(Number(params.amount))
)
const quests = createQuestEngine({
quests: [walkTheCity],
conditions,
effects,
context: () => gameContext,
events: gameEvents,
})
quests.startQuest('walk-the-city')
gameEvents.emit('player:moved', { distance: 12, position })Completion follows an explicit order: clamp the objective to its target, emit objective completion, confirm all objectives, execute rewards, record the completed quest, emit quest completion, then evaluate chained quests. Documenting that order matters because UI, analytics, achievements, and replication may observe it.
Persist progress, not executable behavior.
Save active objective values and completed quest IDs. Reload definitions and registry behavior from the current build. This keeps save data small and allows content fixes without serializing functions.
Version the persisted shape and define migrations before release. On restore, validate unknown quest and objective IDs, clamp values to current targets, and rebuild event subscriptions from restored active state. A storage adapter should be injected so the same engine can use local storage, a desktop save file, a database, or an in-memory test implementation.
Drive tests with events and control every source of time.
Create a fresh event bus and in-memory storage for each test. Inject the clock when timestamps are recorded. Register spy effects, start a quest, emit facts, and assert both state and emitted lifecycle events.
- Verify irrelevant events and non-matching payloads do not advance progress.
- Verify progress clamps at the target and completion occurs exactly once.
- Verify rewards execute before observers receive quest completion if that is the contract.
- Verify restored quests re-subscribe and continue from saved progress.
- Verify disposal removes subscriptions for tests, hot reload, and room teardown.
Use it with an R3F game
Keep interaction and animation in the scene, then connect them to this quest engine through typed domain events.
Read the React Three Fiber architecture guide →