| name | godot-dialogue-system |
| description | Expert patterns for branching dialogue systems including dialogue graphs (Resource-based), character portraits, player choices, conditional dialogue (flags/quests), typewriter effects, localization support, and voice acting integration. Use for narrative games, RPGs, or visual novels. Trigger keywords: DialogueLine, DialogueChoice, DialogueGraph, dialogue_manager, typewriter_effect, branching_dialogue, dialogue_flags, localization, voice_acting. |
Dialogue System
Expert guidance for building flexible, data-driven dialogue systems.
Available Scripts
Data-driven conversation tree container using Resources for modular, branching narrative paths.
Serialized data structure for a single line of dialogue, including speaker metadata and portraits.
Interactive player choice definition with branching logic and scriptable availability conditions.
Centralized AutoLoad orchestrator for traversing dialogue trees and broadcasting state signals.
Reactive UI bridge that maps dialogue data to visual labels and dynamic choice buttons.
Polished "Character-by-character" text reveal effect using Godot's built-in Tweens.
Bridge node for triggering external game events (e.g. starting a quest) from conversation nodes.
Expert logic for evaluating player stats or global flags to toggle dialogue choices.
Advanced strategy for supporting multi-language conversation text via translation keys.
Visual controller for managing character expressions and entry animations during dialogue.
NEVER Do in Dialogue Systems
- NEVER hardcode dialogue text directly in your GDScript files — This makes translation impossible. Store text in Resources or external JSON/CSV files [12].
- NEVER display choices that the player hasn't met the criteria for — Hidden choices should stay hidden unless they are "grayed out" intentionally to show a missed path [13].
- NEVER use loose strings for node transitions without validation — Typos in
next_node_id will crash the dialogue mid-convo. Use assert() or a central ID registry [14].
- NEVER force a typewriter effect without a "Skip" option — Forcing players to read at a fixed speed leads to frustration. Always allow clicking to finish the line [15].
- NEVER store the current dialogue state inside a UI node — If the UI is closed or the scene changes, the player loses their place. Use an AutoLoad
DialogueManager [16].
- NEVER use
get_node() to find dialogue UI from the NPC script — Use signals like DialogueManager.start_dialogue(res) to maintain a decoupled architecture.
- NEVER use complex regex for simple text tags — Godot's
RichTextLabel supports BBCode tags natively. Use [b], [i], and [url] for formatting.
- NEVER perform save/load operations inside a dialogue node — Conversation nodes should be pure data. Delegate persistence to a dedicated
SaveSystem.
- NEVER block the main thread for text reveal timing — Never use
OS.delay_msec(). Use create_timer() or Tween to maintain smooth 60fps performance.
- NEVER hardcode portrait paths — Assign textures directly to the
DialogueNode resource in the inspector or use a central PortraitDatabase.
Available Scripts
MANDATORY: Read the appropriate script before implementing the corresponding pattern.
Graph-based dialogue with BBCode signal tags. Parses [trigger:event_id] tags from text, fires signals, and loads external JSON dialogue graphs.
Data-driven dialogue engine with branching, variable storage, and conditional choices.
Dialogue Data
# dialogue_line.gd
class_name DialogueLine
extends Resource
@export var speaker: String
@export_multiline var text: String
@export var portrait: Texture2D
@export var choices: Array[DialogueChoice] = []
@export var conditions: Array[String] = [] # Quest flags, etc.
@export var next_line_id: String = ""
# dialogue_choice.gd
class_name DialogueChoice
extends Resource
@export var choice_text: String
@export var next_line_id: String
@export var conditions: Array[String] = []
@export var effects: Array[String] = [] # Set flags, give items
Dialogue Manager
# dialogue_manager.gd (AutoLoad)
extends Node
signal dialogue_started
signal dialogue_ended
signal line_displayed(line: DialogueLine)
signal choice_selected(choice: DialogueChoice)
var dialogues: Dictionary = {}
var flags: Dictionary = {}
func load_dialogue(path: String) -> void:
var data := load(path)
dialogues[path] = data
func start_dialogue(dialogue_id: String, start_line: String = "start") -> void:
dialogue_started.emit()
display_line(dialogue_id, start_line)
func display_line(dialogue_id: String, line_id: String) -> void:
var line: DialogueLine = dialogues[dialogue_id].lines[line_id]
# Check conditions
if not check_conditions(line.conditions):
# Skip to next
if line.next_line_id:
display_line(dialogue_id, line.next_line_id)
else:
end_dialogue()
return
line_displayed.emit(line)
# Auto-advance or wait for player
if line.choices.is_empty() and line.next_line_id:
# Wait for player to click
await get_tree().create_timer(0.1).timeout
elif line.choices.is_empty():
end_dialogue()
func select_choice(dialogue_id: String, choice: DialogueChoice) -> void:
choice_selected.emit(choice)
# Apply effects
for effect in choice.effects:
apply_effect(effect)
# Continue to next line
if choice.next_line_id:
display_line(dialogue_id, choice.next_line_id)
else:
end_dialogue()
func end_dialogue() -> void:
dialogue_ended.emit()
func check_conditions(conditions: Array[String]) -> bool:
for condition in conditions:
if not flags.get(condition, false):
return false
return true
func apply_effect(effect: String) -> void:
# Parse effect string, e.g., "set_flag:met_npc"
var parts := effect.split(":")
match parts[0]:
"set_flag":
flags[parts[1]] = true
"give_item":
# Integration with inventory
pass
Dialogue UI
# dialogue_ui.gd
extends Control
@onready var speaker_label := $Panel/Speaker
@onready var text_label := $Panel/Text
@onready var portrait := $Panel/Portrait
@onready var choices_container := $Panel/Choices
var current_dialogue: String
var current_line: DialogueLine
func _ready() -> void:
DialogueManager.line_displayed.connect(_on_line_displayed)
DialogueManager.dialogue_ended.connect(_on_dialogue_ended)
visible = false
func _on_line_displayed(line: DialogueLine) -> void:
visible = true
current_line = line
speaker_label.text = line.speaker
portrait.texture = line.portrait
# Typewriter effect
text_label.text = ""
for char in line.text:
text_label.text += char
await get_tree().create_timer(0.03).timeout
# Show choices
if line.choices.is_empty():
# Wait for input to continue
pass
else:
show_choices(line.choices)
func show_choices(choices: Array[DialogueChoice]) -> void:
# Clear existing
for child in choices_container.get_children():
child.queue_free()
# Add choice buttons
for choice in choices:
if not DialogueManager.check_conditions(choice.conditions):
continue
var button := Button.new()
button.text = choice.choice_text
button.pressed.connect(func(): _on_choice_selected(choice))
choices_container.add_child(button)
func _on_choice_selected(choice: DialogueChoice) -> void:
DialogueManager.select_choice(current_dialogue, choice)
func _on_dialogue_ended() -> void:
visible = false
NPC Interaction
# npc.gd
extends CharacterBody2D
@export var dialogue_path: String = "res://dialogues/npc_1.tres"
@export var start_line: String = "start"
func interact() -> void:
DialogueManager.start_dialogue(dialogue_path, start_line)
Dialogue Graph (Resource)
# dialogue_graph.gd
class_name DialogueGraph
extends Resource
@export var lines: Dictionary = {} # line_id → DialogueLine
func _init() -> void:
# Example structure
lines["start"] = create_line("Hero", "Hello!")
lines["response"] = create_line("NPC", "Greetings, traveler!")
func create_line(speaker: String, text: String) -> DialogueLine:
var line := DialogueLine.new()
line.speaker = speaker
line.text = text
return line
Localization
# Use Godot's built-in CSV import
# dialogue_en.csv:
# dialogue_id,speaker,text
# npc_1_start,Hero,"Hello!"
# npc_1_response,NPC,"Greetings!"
func get_localized_line(line_id: String) -> String:
return tr(line_id)
Advanced: Voice Acting
@onready var voice_player := $AudioStreamPlayer
func play_voice_line(line_id: String) -> void:
var audio := load("res://voice/" + line_id + ".mp3")
if audio:
voice_player.stream = audio
voice_player.play()
Best Practices
- Resource-Based - Store dialogues as resources
- Flag System - Track player choices
- Typewriter Effect - Adds polish
- Skip Button - Let players skip
Elite Godot 4.x Patterns
1. Custom Dialogue Graph Editor
Leverage GraphEdit and GraphNode to build visual authoring tools for complex branching narratives.
@tool
class_name DialogueGraphEditor extends GraphEdit
func link_nodes(from: StringName, from_port: int, to: StringName, to_port: int) -> void:
# Programmatic connection of visual dialogue blocks
var err := connect_node(from, from_port, to, to_port)
if err == OK:
print_rich("[color=green]Branch linked.[/color]")
2. Audio-Driven Dialogue (TTS & Lipsync)
Use DisplayServer for asynchronous Text-to-Speech and register utterance callbacks to drive mouth animations or viseme changes in real-time.
# dialogue_lipsync.gd
func start_speaking(text: String) -> void:
# 1. Register boundary callback
var cb := Callable(self, "_on_tts_boundary")
DisplayServer.tts_set_utterance_callback(DisplayServer.TTS_UTTERANCE_BOUNDARY, cb)
# 2. Speak asynchronously
var voices := DisplayServer.tts_get_voices_for_language("en")
DisplayServer.tts_speak(text, voices[0])
func _on_tts_boundary(char_idx: int, _id: int) -> void:
# Drive lipsync/animation based on current character index
_update_mouth_shape(char_idx)
3. Dialogue Analytics Logger
Implement a custom Logger to intercept and record player choices without cluttering conversation logic with I/O calls.
# dialogue_stat_logger.gd
class_name DialogueStatLogger extends Logger
func _log_message(msg: String, is_error: bool) -> void:
if not is_error and msg.begins_with("[CHOICE]"):
# Process and record choice analytics (e.g., save to file or send to server)
_record_analytics(msg)
# Register in an AutoLoad's _init()
static func initialize() -> void:
OS.add_logger(DialogueStatLogger.new())
Reference