# Dialogue Engine — Architecture for Narrative Games

> **Purpose**: The plumbing under the dialogue. Conversation graphs, condition evaluation, state tracking, event bus integration, and the patterns that make branching dialogue not collapse into spaghetti. This is the engine layer — for presentation (portraits, text FX, box staging), see `dialogue_presence.md`.

---

## 1. Conversation Graph — Core Architecture

Every dialogue system is a directed graph. Nodes are moments, edges are transitions. The player walks the graph. Everything else is decoration.

### Node Types

Not every node speaks. A graph with only text nodes forces you to hack branching logic into text. Split by responsibility:

| Type | Role | Player sees | Example |
|---|---|---|---|
| **Say** | Display text + speaker | Text box with portrait | `"The bridge is out. We need another way."` |
| **Choice** | Present options, branch on pick | Choice buttons | `> Cross anyway / > Find another path` |
| **Condition** | Auto-branch on game state | Nothing (instant) | If `has_rope` → node A, else → node B |
| **Action** | Trigger side effect | Nothing (instant) | `emit("give_item", "rope")` |
| **Random** | Weighted pick among exits | Nothing (instant) | 60% sarcastic line, 40% serious line |

**Why Condition and Action are separate nodes**: Putting conditions ON Say nodes and actions IN Say nodes is the #1 architecture mistake. You end up with Say nodes that have 6 optional fields, half of which are empty. Separate nodes are composable — chain Action → Condition → Say in any order.

**Random nodes**: Hades uses weighted random for NPC greetings across 50+ hours. The node stores `[{weight: 0.6, target: "node_a"}, {weight: 0.4, target: "node_b"}]`. Weights don't need to sum to 1.0 — normalize at runtime.

### Data Structure: Resource vs JSON

Two viable approaches. Pick one, don't mix.

**Resource-based** (pure Godot):

```gdscript
class_name DialogueNode extends Resource

@export var id: StringName
@export var type: StringName  # "say", "choice", "condition", "action", "random"
@export var speaker: StringName
@export var text: String
@export var next: StringName  # default exit (Say, Action nodes)
@export var edges: Array[Dictionary]  # [{label, target, condition?}] for Choice/Condition/Random
@export var metadata: Dictionary  # expression, animation, action_name, params — varies by type
```

```gdscript
class_name Conversation extends Resource

@export var id: StringName
@export var start_node: StringName
@export var nodes: Array[DialogueNode]
```

**JSON-based** (external tooling friendly):

```json
{
  "id": "bridge_encounter",
  "start_node": "intro",
  "nodes": {
    "intro": {
      "type": "say",
      "speaker": "guide",
      "text": "The bridge is out. We can't cross here.",
      "next": "check_rope"
    },
    "check_rope": {
      "type": "condition",
      "edges": [
        {"condition": {"flag": "has_rope", "value": true}, "target": "offer_rope"},
        {"target": "no_rope_choice"}
      ]
    },
    "offer_rope": {
      "type": "choice",
      "edges": [
        {"label": "Use the rope to cross", "target": "rope_cross"},
        {"label": "Save the rope for later", "target": "no_rope_choice"}
      ]
    },
    "no_rope_choice": {
      "type": "choice",
      "edges": [
        {"label": "Jump across", "target": "jump_action"},
        {"label": "Go back", "target": "go_back"}
      ]
    },
    "jump_action": {
      "type": "action",
      "metadata": {"action": "set_flag", "params": {"flag": "attempted_jump"}},
      "next": "jump_result"
    },
    "jump_result": {
      "type": "say",
      "speaker": "guide",
      "text": "That was reckless. But we made it.",
      "next": "_end"
    },
    "rope_cross": {
      "type": "action",
      "metadata": {"action": "remove_item", "params": {"item": "rope"}},
      "next": "safe_cross"
    },
    "safe_cross": {
      "type": "say",
      "speaker": "guide",
      "text": "Good thinking. The rope holds.",
      "next": "_end"
    },
    "go_back": {
      "type": "say",
      "speaker": "guide",
      "text": "Wise choice. Let's find another way.",
      "next": "_end"
    }
  }
}
```

**Resource pros**: Inspector editing, typed, version control friendly (`.tres` is text). **JSON pros**: external dialogue editors (Yarn, Ink, custom web tools), easy to generate/parse, non-programmers can edit.

**Trap**: Don't store Resources in a Dictionary keyed by StringName. Godot serializes Dictionary keys as Variant — reloading can silently change key types. Use an Array and build the lookup dict at load time.

### Graph Traversal

The entire runtime is one pointer and one method:

```gdscript
class_name DialogueRunner extends RefCounted

signal say_requested(node: DialogueNode)
signal choice_requested(node: DialogueNode, options: Array[Dictionary])
signal action_requested(action_name: String, params: Dictionary)
signal conversation_ended

var _graph: Dictionary  # id → DialogueNode
var _current: StringName
var _game_state: GameStateInterface  # injected

func start(conversation: Conversation, game_state: GameStateInterface) -> void:
	_game_state = game_state
	_graph = {}
	for node: DialogueNode in conversation.nodes:
		_graph[node.id] = node
	_current = conversation.start_node
	_advance()

func _advance() -> void:
	if _current == &"_end" or _current == &"":
		conversation_ended.emit()
		return

	var node: DialogueNode = _graph.get(_current)
	if node == null:
		push_error("DialogueRunner: node '%s' not found" % _current)
		conversation_ended.emit()
		return

	match node.type:
		&"say":
			say_requested.emit(node)
			# UI calls continue_dialogue() when player advances
		&"choice":
			var options := _filter_choices(node.edges)
			if options.is_empty():
				push_error("DialogueRunner: all choices filtered out at '%s'" % _current)
				conversation_ended.emit()
				return
			choice_requested.emit(node, options)
			# UI calls select_choice(index) when player picks
		&"condition":
			_current = _evaluate_condition_edges(node.edges)
			_advance()  # instant, no UI
		&"action":
			action_requested.emit(
				node.metadata.get("action", ""),
				node.metadata.get("params", {})
			)
			_current = node.next
			_advance()  # instant, no UI
		&"random":
			_current = _pick_weighted(node.edges)
			_advance()  # instant, no UI

func continue_dialogue() -> void:
	var node: DialogueNode = _graph.get(_current)
	_current = node.next
	_advance()

func select_choice(index: int) -> void:
	var node: DialogueNode = _graph.get(_current)
	var options := _filter_choices(node.edges)
	_current = options[index].target
	_advance()
```

**Key insight**: Condition, Action, and Random nodes call `_advance()` recursively. The player never sees them — they resolve instantly. Only Say and Choice nodes pause for UI. This means you can chain `Action → Condition → Action → Say` and the player experiences one seamless beat.

**Trap**: Infinite recursion. If Condition A points to Condition B which points back to A, you stack overflow. Cap recursion depth (32 is generous) and error out.

---

## 2. Condition System

### Condition Structure

90% of conditions are simple flag checks. Design for that case, extend for the rest:

```gdscript
## Evaluate a single condition dict against game state.
## Returns true if the condition is met.
static func evaluate(condition: Dictionary, state: GameStateInterface) -> bool:
	if condition.is_empty():
		return true  # no condition = always pass

	var type: String = condition.get("type", "flag")

	match type:
		"flag":
			var current: Variant = state.get_flag(condition.flag)
			return current == condition.get("value", true)
		"stat":
			var current: float = state.get_stat(condition.stat)
			var op: String = condition.get("operator", ">=")
			var threshold: float = condition.value
			match op:
				">=": return current >= threshold
				">": return current > threshold
				"<=": return current <= threshold
				"<": return current < threshold
				"==": return is_equal_approx(current, threshold)
				_: return false
		"item":
			return state.has_item(condition.item) == condition.get("has", true)
		"visited":
			return state.has_visited_node(condition.node_id)
		"and":
			for sub: Dictionary in condition.conditions:
				if not evaluate(sub, state):
					return false
			return true
		"or":
			for sub: Dictionary in condition.conditions:
				if evaluate(sub, state):
					return true
			return false
		_:
			push_warning("Unknown condition type: %s" % type)
			return false
```

### Condition Examples

```json
// Simple flag
{"type": "flag", "flag": "met_elder", "value": true}

// Stat check (Disco Elysium style)
{"type": "stat", "stat": "charisma", "operator": ">=", "value": 5}

// Inventory
{"type": "item", "item": "ancient_key", "has": true}

// Node already visited (track replay)
{"type": "visited", "node_id": "asked_about_family"}

// Compound: met the elder AND have high charisma
{"type": "and", "conditions": [
  {"type": "flag", "flag": "met_elder"},
  {"type": "stat", "stat": "charisma", "operator": ">=", "value": 3}
]}
```

### GameState Interface

The condition system doesn't care where state lives. It talks to an interface:

```gdscript
class_name GameStateInterface extends RefCounted

## Override these in your concrete implementation.

func get_flag(flag_name: StringName) -> Variant:
	return false

func set_flag(flag_name: StringName, value: Variant) -> void:
	pass

func get_stat(stat_name: StringName) -> float:
	return 0.0

func modify_stat(stat_name: StringName, delta: float) -> void:
	pass

func has_item(item_id: StringName) -> bool:
	return false

func has_visited_node(node_id: StringName) -> bool:
	return false

func mark_node_visited(node_id: StringName) -> void:
	pass
```

**Trap**: Over-engineering the condition system. If you're adding date checks, time-of-day, weather conditions, moon phases — stop. Wrap those in flags that get set by their respective systems. `{"flag": "is_nighttime"}` beats `{"type": "time", "range": [20, 6]}`. The dialogue engine should not know what a clock is.

### Conditions on Choices vs Conditions on Nodes

These produce different player experiences:

**Condition on a Choice edge** — the choice is hidden entirely. Player never knows it existed. Use for: secret options unlocked by exploration, class-specific dialogue. Disco Elysium hides skill check options you can't attempt.

**Condition on a Choice with `show_locked: true`** — the choice is visible but greyed out, with a hint. Player sees what they're missing. Use for: stat gates where you want the player to know they can return later. Persona shows locked social link options.

```gdscript
func _filter_choices(edges: Array[Dictionary]) -> Array[Dictionary]:
	var result: Array[Dictionary] = []
	for edge: Dictionary in edges:
		var condition: Dictionary = edge.get("condition", {})
		if ConditionEvaluator.evaluate(condition, _game_state):
			result.append(edge)
		elif edge.get("show_locked", false):
			var locked := edge.duplicate()
			locked["_locked"] = true
			locked["_lock_reason"] = edge.get("lock_hint", "Requirements not met")
			result.append(locked)
	return result
```

**Trap**: Filtering all choices silently creates invisible gates. If every choice at a node is conditional and all fail, the player hits a dead end with no options. Always have one unconditional fallback choice, or handle the empty case explicitly.

---

## 3. Choice Types

### Basic Text Choice

The default. Label + target. No magic:

```json
{"label": "Tell me more about the ruins.", "target": "ruins_info"}
```

### Skill Check (Disco Elysium / Baldur's Gate 3)

A choice with a visible probability and success/failure branches:

```json
{
  "label": "[Charisma 5] Convince the guard to let you pass",
  "type": "skill_check",
  "stat": "charisma",
  "difficulty": 5,
  "success_target": "guard_lets_pass",
  "failure_target": "guard_refuses",
  "condition": {"type": "stat", "stat": "charisma", "operator": ">=", "value": 2}
}
```

The condition gates visibility (need at least 2 to even attempt), but the check itself can still fail. The UI shows `[Charisma 5 — 60%]` based on current stat vs difficulty.

**Disco Elysium insight**: Showing the probability makes the player feel smart when they beat the odds and unlucky when they fail high-probability checks. It turns a binary choice into a gambling moment. The probability display is the design, not just info.

### Timed Choice

Auto-selects a default after N seconds:

```json
{
  "type": "timed",
  "time_limit": 6.0,
  "default_index": 0,
  "edges": [
    {"label": "(Stay silent)", "target": "silence"},
    {"label": "Speak up", "target": "speak"}
  ]
}
```

**Tested timings** (from Telltale, Life is Strange):

| Context | Time | Why |
|---|---|---|
| Combat/pressure | 3-4s | Forces instinct, no overthinking |
| Standard tension | 5-6s | Time to read all options + pick |
| Moral dilemma | 8-10s | Time to agonize — the agony IS the experience |
| First time mechanic appears | 10-12s | Player is learning, don't punish |

**Trap**: The default on timeout must feel intentional, not random. Silence, inaction, or the "safe" option. Never auto-pick the most dramatic choice — the player will feel cheated.

**Trap**: The visual countdown (shrinking bar, fading opacity) MUST be visible the moment choices appear. Hidden timers are hostile UX. The player needs to know the clock is ticking before they start reading.

### Cost Choice

Requires a resource to pick:

```json
{
  "label": "Bribe the guard (50 gold)",
  "type": "cost",
  "cost": {"currency": "gold", "amount": 50},
  "target": "guard_bribed",
  "condition": {"type": "stat", "stat": "gold", "operator": ">=", "value": 50}
}
```

Show cost inline. Grey out if can't afford. The player weighing whether a dialogue option is "worth" 50 gold is a micro-economic decision — that's engagement.

### Choice Count

Hades uses 2-3 choices max per dialogue. Persona uses 3-4 for social links. Disco Elysium caps at 5-6 but that's extreme (and it's the entire game).

**Rule**: 3-4 choices visible. If you need 5+, your conversation node is doing too much. Split it into a hub pattern (see Flow Patterns below).

---

## 4. State Tracking & Persistence

### What to Track

```gdscript
class_name DialogueState extends Resource

## Which conversation nodes the player has visited (across all conversations).
@export var visited_nodes: Dictionary  # {StringName: true}

## Which choices the player made (key = "conversation_id:node_id", value = choice index).
@export var choices_made: Dictionary

## Global flags set by Action nodes or game systems.
@export var flags: Dictionary  # {StringName: Variant}

## Per-NPC relationship values.
@export var relationships: Dictionary  # {StringName: float}

## Journal entries (key moments logged for player reference).
@export var journal: Array[Dictionary]  # [{timestamp, speaker, summary}]
```

### Flag Naming Convention

Prefix with context. Bare names collide fast:

```
# Bad — "met" who? In which chapter? Will collide.
met_elder
found_key

# Good — scoped, greppable, unique.
ch1_met_elder_first_time
ch1_bridge_found_rope
ch2_guard_bribed
npc_elder_asked_about_past
quest_main_bridge_resolved
```

**Pattern**: `{scope}_{subject}_{event}`. Scope = chapter, quest, npc. Subject = who/what. Event = what happened.

### Relationship Values

Numeric scores, not binary. Modified by choices, read by conditions:

```gdscript
func modify_relationship(npc_id: StringName, delta: float) -> void:
	var current: float = relationships.get(npc_id, 0.0)
	relationships[npc_id] = clampf(current + delta, -100.0, 100.0)
```

**Shipped game ranges**:

| Game | Range | Granularity |
|---|---|---|
| Persona 5 | 0-10 (ranks) | Coarse, milestone-based |
| Hades | 0-∞ (hearts) | Incremental, gift-based |
| Fire Emblem | -100 to 100 | Fine-grained, per-conversation |
| Disco Elysium | Binary per thought | Flag-based, not numeric |

For most games: `-10 to 10` range with `+1/-1` per meaningful choice. Enough resolution for 4-5 relationship tiers without becoming an invisible spreadsheet.

### Save/Load

**Save at conversation end, not mid-conversation**:

```gdscript
func _on_conversation_ended() -> void:
	# Merge local conversation state into global state
	for node_id: StringName in _runner.visited_this_conversation:
		dialogue_state.visited_nodes[node_id] = true
	# Persist
	ResourceSaver.save(dialogue_state, "user://dialogue_state.tres")
```

**Trap**: Saving mid-conversation requires serializing the runner's current position, the filtered choice list, any pending actions — it's a state machine snapshot. Doable but complex. If you don't need it (most games don't), avoid it. Save the RESULT of conversations, not the position within them.

**Trap**: If you save visited_nodes per conversation, you can detect "first time talking to NPC" vs "returning to NPC." If you only save flags, you lose granularity. Track both.

---

## 5. Event Bus Integration

### Action Nodes Emit, Game Systems Listen

The dialogue engine emits events. It never calls game systems directly:

```gdscript
# In DialogueRunner
func _process_action(node: DialogueNode) -> void:
	var action_name: String = node.metadata.get("action", "")
	var params: Dictionary = node.metadata.get("params", {})
	action_requested.emit(action_name, params)
	_current = node.next
	_advance()
```

```gdscript
# In the game layer (not in the dialogue system)
func _on_dialogue_action(action_name: String, params: Dictionary) -> void:
	match action_name:
		"give_item":
			inventory.add_item(params.item_id, params.get("amount", 1))
		"remove_item":
			inventory.remove_item(params.item_id, params.get("amount", 1))
		"set_flag":
			game_state.set_flag(params.flag, params.get("value", true))
		"modify_stat":
			game_state.modify_stat(params.stat, params.delta)
		"modify_relationship":
			game_state.modify_relationship(params.npc_id, params.delta)
		"start_quest":
			quest_manager.start(params.quest_id)
		"play_animation":
			# Find the target node in the scene and play
			var target := get_node_or_null(params.node_path)
			if target and target.has_method("play_animation"):
				target.play_animation(params.animation)
		"play_sfx":
			AudioManager.play_sfx(params.sound)
		_:
			push_warning("Unhandled dialogue action: %s" % action_name)
```

**Why this matters**: The dialogue JSON/Resource can be authored by a writer who doesn't know GDScript. They write `"action": "give_item", "params": {"item_id": "rope"}`. A programmer wires the listener once. The two never need to coordinate on individual conversations.

### Action Timing

**Emit AFTER the preceding Say node is displayed, not before**. The player reads "Here, take this rope" and THEN the item appears in inventory. If the action fires before the text, the inventory notification pops up while the player is still reading the previous line — confusing.

For Action nodes that follow a Say node, the runner handles this naturally (the action fires when the player advances past the Say). For standalone Action sequences (Action → Action → Say), all actions fire instantly before the Say is displayed — which is usually fine since the player doesn't see intermediate steps.

**Trap**: If an Action removes an item the player is holding and the UI updates immediately, the player sees their item disappear before the dialogue explains why. For removals, prefer: Say (explain) → Action (remove) → Say (react). The sequence matters.

---

## 6. Speaker Identity & Portraits

### Speaker Resource

```gdscript
class_name SpeakerData extends Resource

@export var id: StringName
@export var display_name: String
@export var portrait_base: Texture2D
@export var expressions: Dictionary  # {"happy": Texture2D, "angry": Texture2D, ...}
@export var name_color: Color = Color.WHITE
@export var voice_profile: VoiceProfile  # text_speed, blip, etc. (see dialogue_presence.md)
```

### Expression in Dialogue Data

The Say node's metadata carries the expression key:

```json
{
  "type": "say",
  "speaker": "guide",
  "text": "I never thought we'd make it this far.",
  "metadata": {"expression": "sad"},
  "next": "next_node"
}
```

The UI layer reads `metadata.expression`, looks it up in `SpeakerData.expressions`, and swaps the portrait. Expression changes at node boundaries, not mid-text (see `dialogue_presence.md` for why).

**Crossfade timing**: 150-200ms between expressions. Instant swap for dramatic moments (anger, shock). The runner doesn't control this — the UI layer decides based on expression metadata.

### Multiple Speakers Per Conversation

The `speaker` field on each Say node handles this. The UI tracks the current speaker and animates portrait transitions when the speaker changes.

**Trap**: If speaker A says something, then speaker B says something, then A again — the UI must not re-animate A's entrance. Track "is this speaker already on screen?" and only animate entries, not returns.

---

## 7. Dialogue Flow Patterns

Real games use these patterns, not arbitrary graphs:

### Linear

`A → B → C → D → _end`

Narration, cutscenes, monologues. No choices. The player advances through beats. Works for: opening sequences, story beats between gameplay, NPC info dumps.

### Hub

```
        ┌→ Topic A → (return to hub)
Hub ────┼→ Topic B → (return to hub)
        ├→ Topic C → (return to hub)
        └→ "Goodbye" → _end
```

The player picks topics from a central node. Each topic returns to the hub. Elder Scrolls, Mass Effect, most RPG NPC conversations. The hub hides topics already visited (condition: `{"type": "visited", "node_id": "topic_a"}`).

**Trap**: If ALL topics are required for progression, the hub becomes a to-do list disguised as dialogue. Make 1-2 topics optional/discoverable.

### Tree

```
        ┌→ Branch A → A1 → _end (consequence 1)
Root ───┤
        └→ Branch B → B1 → _end (consequence 2)
```

No return. Choices have permanent consequences. Used for: major story decisions, quest resolutions. The Witcher uses this for quest outcomes.

**Trap**: Trees grow exponentially. 3 choices × 3 choices = 9 leaf nodes. Shipped games keep trees shallow (2-3 levels deep) and converge branches back together quickly. Full combinatorial trees are content nightmares.

### Loop with Variation

```
Greeting → (check flags) → Variation A / Variation B / Variation C → _end
```

The NPC says something different each time based on game state. Hades' entire NPC system is this pattern at scale (see `dialogue_presence.md` priority queue system).

### Interrupt

An external event breaks the current conversation:

```gdscript
func interrupt(reason: StringName) -> void:
	# Store where we were (optional, for resume)
	_interrupted_at = _current
	_current = &"_end"
	conversation_ended.emit()
	# The game layer handles what happens next
```

Used for: enemy attacks during dialogue, timer expiring, companion interrupting. Don't try to resume interrupted conversations — the game state has changed. Start a new contextual conversation instead.

---

## 8. Graph Validation

Run these checks at load time, not at runtime:

```gdscript
static func validate_graph(conversation: Conversation) -> Array[String]:
	var errors: Array[String] = []
	var node_ids: Dictionary = {}

	for node: DialogueNode in conversation.nodes:
		if node_ids.has(node.id):
			errors.append("Duplicate node id: %s" % node.id)
		node_ids[node.id] = true

	if not node_ids.has(conversation.start_node):
		errors.append("Start node '%s' not found" % conversation.start_node)

	for node: DialogueNode in conversation.nodes:
		# Check that next targets exist
		if node.next != &"" and node.next != &"_end":
			if not node_ids.has(node.next):
				errors.append("Node '%s' points to missing node '%s'" % [node.id, node.next])

		# Check that edge targets exist
		for edge: Dictionary in node.edges:
			var target: StringName = edge.get("target", &"")
			if target != &"_end" and not node_ids.has(target):
				errors.append("Node '%s' edge points to missing '%s'" % [node.id, target])

		# Check for dead ends (Say with no next and no edges)
		if node.type == &"say" and node.next == &"" and node.edges.is_empty():
			errors.append("Dead end at node '%s' (no next, no edges)" % node.id)

	# Cycle detection for non-player nodes (Condition/Action/Random chains)
	for node: DialogueNode in conversation.nodes:
		if node.type in [&"condition", &"action", &"random"]:
			var visited: Dictionary = {}
			var current_id: StringName = node.id
			while current_id != &"" and current_id != &"_end":
				if visited.has(current_id):
					errors.append("Infinite loop in auto-advance chain starting at '%s'" % node.id)
					break
				visited[current_id] = true
				var n: DialogueNode = _find_node(conversation, current_id)
				if n == null or n.type in [&"say", &"choice"]:
					break  # Reached a player-facing node, chain is safe
				current_id = n.next if n.next != &"" else &""

	return errors
```

**Why validate auto-advance chains specifically**: Say and Choice nodes wait for player input, breaking any cycle naturally. But Condition → Action → Condition loops execute in a single frame and stack overflow. Only non-player nodes can create infinite loops.

---

## 9. Design Traps

### Over-engineering the condition system

90% of shipped dialogue conditions are `{"flag": "x", "value": true}`. Build for that. The stat checks and compound logic are for 10% of cases. If you're building a full expression parser before you have 50 lines of dialogue written, you're procrastinating on writing.

### Putting game logic in dialogue data

```json
// BAD — dialogue data executing game code
{"action": "execute", "code": "player.health -= 20; player.add_status('poison')"}

// GOOD — dialogue data describing intent, game layer interprets
{"action": "apply_damage", "params": {"amount": 20, "type": "poison"}}
```

Dialogue data is authored content. Game logic is code. When they're mixed, you can't change game balance without editing every conversation file.

### Forgetting error states

- NPC with no conversation assigned → show nothing, don't crash
- Conversation references a deleted node → validation catches it at load
- Speaker ID not found in speaker database → use fallback ("???", default portrait)
- All choices filtered by conditions → at least one unconditional exit

### Text length assumptions

English is compact. German dialogue can be 30-40% longer. Finnish compound words are brutal. If your dialogue box is pixel-perfect for English text, it breaks in translation.

**Fix**: Design boxes for 150% of your longest English line. Or: auto-resize with a max height + scroll. The system should never assume a fixed text length.

### Coupling dialogue UI to dialogue data

The `DialogueRunner` should emit signals. The UI listens. They never share classes, never import each other. Why: you will redesign the UI 3 times. The data structure and traversal logic should survive all 3 redesigns untouched.

### No skip/fast-forward

Second playthrough players will mash through dialogue. Support:
- Single press = instant-complete current typewriter
- Second press = advance to next node
- Hold button = auto-advance at 3x speed
- `Condition(visited: node_id)` = skip already-seen branches entirely

---

## 10. The Minimal Viable Stack

### What you need to ship a narrative game

```
DialogueNode (Resource)          → ~30 lines
Conversation (Resource)          → ~10 lines
ConditionEvaluator (static)      → ~50 lines
DialogueRunner (RefCounted)      → ~80 lines
GameStateInterface (RefCounted)  → ~20 lines
DialogueState (Resource)         → ~15 lines
GraphValidator (static)          → ~40 lines
                                   ─────────
                          Total:   ~245 lines of core engine
```

The UI, portraits, text effects, sound — those are separate systems that listen to the runner's signals. See `dialogue_presence.md` for that layer.

### Complete Flow

```
Player approaches NPC
  → Game checks: does NPC have a conversation? (ConversationDB lookup)
  → Game checks: which conversation? (priority queue, flag-gated — see dialogue_presence.md Hades pattern)
  → DialogueRunner.start(conversation, game_state)

Runner hits Say node
  → say_requested signal → UI shows text box, portrait, typewriter
  → Player presses advance → Runner.continue_dialogue()

Runner hits Condition node
  → Evaluates against GameStateInterface → picks edge → advance (instant, no UI)

Runner hits Action node
  → action_requested signal → Game layer processes (give_item, set_flag, etc.)
  → advance (instant, no UI)

Runner hits Choice node
  → choice_requested signal → UI shows options (filtered, with locked hints)
  → Player picks → Runner.select_choice(index)
  → State tracks: choices_made["convo_id:node_id"] = index

Runner hits _end
  → conversation_ended signal
  → Game layer: merge visited nodes into DialogueState, save, restore world
```

### What Each Layer Owns

```
┌─────────────────────────────────────────────────┐
│ UI Layer (dialogue_presence.md)                  │
│  Portraits, text FX, box staging, sound, choices │
│  Listens to: say_requested, choice_requested     │
│  Calls: continue_dialogue(), select_choice()     │
├─────────────────────────────────────────────────┤
│ Engine Layer (this recipe)                        │
│  Graph traversal, conditions, state tracking     │
│  Emits: say/choice/action_requested, ended       │
│  Reads: GameStateInterface                       │
├─────────────────────────────────────────────────┤
│ Game Layer (your game)                            │
│  Inventory, quests, stats, flags                 │
│  Implements: GameStateInterface                  │
│  Handles: action_requested events                │
└─────────────────────────────────────────────────┘
```

Clean separation. The engine doesn't know what a portrait is. The UI doesn't know what a flag is. The game layer doesn't know what a graph is. When one layer changes, the others don't.

---

## Stack Final

```
1. Graph structure         → 5 node types (Say, Choice, Condition, Action, Random).
                              Resource or JSON. Nodes are moments, edges are transitions.
2. Traversal               → One pointer, one advance(). Say/Choice pause for UI.
                              Condition/Action/Random resolve instantly (watch recursion).
3. Conditions              → Flag checks (90%), stat/item/compound (10%).
                              Interface-based — engine doesn't know game internals.
4. Choice filtering        → Hidden (condition fails) or locked (show_locked: true).
                              Always one unconditional exit. Max 4 visible.
5. State tracking          → visited_nodes, choices_made, flags, relationships.
                              Save at conversation end, not mid-conversation.
6. Event bus               → Action nodes emit signals, game layer listens.
                              Timing: action AFTER player reads the preceding text.
7. Validation              → At load time: dead ends, missing targets, auto-advance loops.
                              Never at runtime — catch it before the player sees it.
8. Speaker identity        → SpeakerData resource per character. Expression in node metadata.
                              UI layer handles portrait swap (see dialogue_presence.md).
```

~245 lines of engine. The data files are where the writing lives. The UI is where the feeling lives. The engine is invisible plumbing that never breaks.

See also: `dialogue_presence.md` for the presentation layer, `.claude/skills/godot-composition.md` for Godot Control layout, `.claude/skills/godot-save.md` for persistence patterns, `.claude/skills/godot-architecture.md` for signal-driven systems.
