Dialog and narrative systems transform a game from a mechanical exercise into a story. Whether it’s a simple shopkeeper conversation or a branching quest with consequences, the underlying system needs to be data-driven, extensible, and easy for non-programmers to author content for.
Store dialog as data, not code. A simple JSON format that covers most needs:
{
"npc_mayor": {
"greeting": {
"text": "Welcome to Willowbrook, farmer!",
"speaker": "Mayor Oakley",
"portrait": "mayor_happy",
"choices": [
{ "text": "What can I do here?", "next": "tutorial_intro" },
{ "text": "I'm just passing through.", "next": "farewell" }
]
},
"tutorial_intro": {
"text": "This town needs a good farmer. The old fields are overgrown, but with some work they'll yield fine crops.",
"speaker": "Mayor Oakley",
"portrait": "mayor_serious",
"next": "tutorial_quest",
"setFlag": "met_mayor"
},
"farewell": {
"text": "Safe travels, friend.",
"speaker": "Mayor Oakley",
"portrait": "mayor_wave"
}
}
}
class DialogEngine {
constructor(dialogData) {
this.data = dialogData;
this.current = null;
this.flags = new Set();
}
start(npcId, nodeId = 'greeting') {
this.current = this.data[npcId]?.[nodeId];
if (!this.current) return null;
if (this.current.setFlag) this.flags.add(this.current.setFlag);
return this.current;
}
choose(choiceIndex) {
if (!this.current?.choices) return null;
const choice = this.current.choices[choiceIndex];
if (!choice?.next) return null;
// Find the next node in the same NPC's dialog
return this.start(this.currentNpc, choice.next);
}
advance() {
if (this.current?.next) {
return this.start(this.currentNpc, this.current.next);
}
return null; // dialog ended
}
}
Real games need dialog that changes based on player state:
"quest_check": {
"branches": [
{
"condition": { "flag": "has_golden_pumpkin" },
"text": "You found it! The Golden Pumpkin!",
"next": "quest_complete"
},
{
"condition": { "item": "pumpkin", "min": 5 },
"text": "Those are nice pumpkins, but not THE pumpkin...",
"next": "quest_hint"
},
{
"default": true,
"text": "Still looking for that Golden Pumpkin?",
"next": "quest_reminder"
}
]
}
The engine evaluates branches top-to-bottom and uses the first matching condition. The default branch catches everything else.
Characters appearing one at a time is a classic game dialog feel:
class Typewriter {
constructor(element, speed = 30) {
this.el = element;
this.speed = speed;
this.text = '';
this.index = 0;
this.timer = null;
this.done = false;
}
start(text) {
this.text = text;
this.index = 0;
this.done = false;
this.el.textContent = '';
this.timer = setInterval(() => {
if (this.index >= this.text.length) {
clearInterval(this.timer);
this.done = true;
return;
}
this.el.textContent += this.text[this.index++];
}, this.speed);
}
skip() {
clearInterval(this.timer);
this.el.textContent = this.text;
this.done = true;
}
}
Quests tie dialog to gameplay objectives:
If you plan to translate your game, separate text from dialog logic from the start:
"text": "dialog.mayor.greeting"en.json, es.json, ja.jsondirection: rtl