Implementation guide · Branching RPG dialogue
A headless TypeScript dialogue system for choices, conditions, and effects.
Keep conversation flow as serializable data and game-specific behavior in registries. The same dialogue state machine can then drive React UI, a Three.js scene, automated tests, or a server without importing any of them.
The dialogue runtime should not own presentation.
A dialogue system decides which node is active, which responses are currently available, what happens after a choice, and when a conversation ends. It should not decide whether text appears in a speech bubble, a visual-novel panel, subtitles, or an accessibility transcript.
This boundary matters in React Three Fiber games because conversation state often outlives scene components. A camera cut or route transition should not destroy relationship values, completed conversations, or the rules that filter a response. Keep those facts in a vanilla store and let UI subscribe through narrow selectors.
- Content owns: speakers, text keys, nodes, choices, and references.
- Runtime owns: transitions, filtering, completion, and dialogue history.
- Game owns: condition checks, effects, localization, audio, and rendering.
Represent branching conversations as data.
Serializable trees are reviewable in source control, validatable in CI, editable by tools, and safe to ship over a content pipeline. Conditions and effects remain named references rather than embedded functions.
const guideIntro = {
id: 'guide-intro',
startNodeId: 'welcome',
nodes: [
{
id: 'welcome',
speaker: 'guide',
text: 'dialogue.guide.welcome',
responses: [
{
id: 'ask-market',
text: 'dialogue.guide.askMarket',
conditions: [
{ type: 'quest.completed', params: { id: 'first-steps' } },
],
effects: [
{ type: 'map.reveal', params: { markerId: 'market' } },
],
next: 'market-directions',
},
{
id: 'leave',
text: 'dialogue.common.goodbye',
},
],
},
{
id: 'market-directions',
speaker: 'guide',
text: 'dialogue.guide.marketDirections',
endsDialogue: true,
},
],
}Linear nodes may use a single next transition and advance on player input. Choice nodes expose only responses whose conditions all pass. A response with no next node ends the conversation.
Register behavior once at the composition root.
The dialogue package does not import a quest store, wallet, map, or reputation system. Your application registers those meanings and supplies a context when the runtime evaluates them.
import {
createConditionRegistry,
createEffectRegistry,
} from '@overworld-engine/core'
import { createDialogueEngine } from '@overworld-engine/dialogue'
const conditions = createConditionRegistry<GameContext>()
const effects = createEffectRegistry<GameContext>()
conditions.register('quest.completed', ({ id }, ctx) =>
ctx.quests.isCompleted(String(id))
)
effects.register('map.reveal', ({ markerId }, ctx) =>
ctx.map.reveal(String(markerId))
)
const dialogue = createDialogueEngine({
dialogues: [guideIntro],
conditions,
effects,
context: () => gameContext,
persist: true,
})
dialogue.start('guide-intro', 'guide')
dialogue.choose('ask-market')The runtime emits dialogue:started and dialogue:ended. Quests, analytics, audio, and achievements can observe those facts without either package importing the other.
Render the current node as a projection.
A React component only needs the active node and filtered responses. It does not need to duplicate transition rules or evaluate conditions during render.
import { useStore } from 'zustand'
function DialoguePanel() {
const node = useStore(dialogue.store, (state) => state.currentNode)
const responses = useStore(
dialogue.store,
(state) => state.availableResponses
)
if (!node) return null
return (
<section aria-label="Dialogue">
<p>{translate(node.text)}</p>
{responses.map((response) => (
<button
key={response.id}
onClick={() => dialogue.choose(response.id)}
>
{translate(response.text)}
</button>
))}
</section>
)
}Store localization keys rather than final copy when dialogue ships in multiple languages. The runtime treats text as opaque, so the UI may select locale, typography, voice-over, and accessibility behavior independently.
Persist history, not an interrupted presentation state.
Overworld persists relationships, seen dialogue IDs, and completed dialogue IDs when persistence is enabled. An in-progress conversation is deliberately not saved. On restore, the game returns to an intentional entry point instead of resuming halfway through an effect or animation.
Test the runtime with an isolated condition registry, effect spies, and a fresh store. Verify hidden choices stay hidden, effects run once, terminal nodes complete the tree, and the emitted end event contains the final node and NPC identifiers.
Connect dialogue to quest progression
A quest objective can listen for dialogue:ended and advance without a direct dialogue dependency.