【Godot】Building a Simple Dialogue System (Text, Choices, and Branching)

Created: 2025-12-08Last updated: 2025-12-16

Learn how to build an extensible dialogue system in Godot Engine featuring text display, typewriter effects, choices, branching, and data-driven design.

Introduction: Why Dialogue Systems Matter

In games, conversations with characters are one of the most important elements for conveying worldview and drawing players into the story. Especially in RPGs and adventure games, the dialogue system significantly affects game experience quality.

A common pitfall for beginners is hardcoding logic and content into scripts, losing extensibility and maintainability.

This article explains step-by-step how to build a data-driven dialogue system in Godot Engine with the following features—going beyond simple text display:

  • Readable typewriter effect
  • Conversation branching based on player choices
  • Maintainable data-driven design (JSON support)
  • Event-based integration for extensibility

1. Design Philosophy: Why "Data-Driven" Matters

The key to an excellent dialogue system is separating "logic" from "content." This is achieved through data-driven design. "Content" like conversation text, speakers, and choices is separated from "logic" in GDScript code and managed as data structures like Dictionary or JSON.

This approach provides tremendous benefits:

  • Improved Maintainability: Scenario writers can add/modify conversations by editing JSON files without touching code.
  • Ensured Extensibility: When adding new features (e.g., character expression changes, audio playback), just add new keys to the data structure and write corresponding logic.
  • Improved Reusability: The same dialogue system can easily be reused for different NPCs and events.

Let's start by defining this data structure using GDScript's Dictionary and Array.

# dialogue_data.gd
# Initially define data as GDScript file, later migrate to JSON.
const DIALOGUE_DATA = {
    # Each conversation entry is managed as Dictionary with unique ID as key
    "start": {
        "speaker": "Old Sage",
        "text": "Welcome, young traveler. Do you have business with me?",
        "choices": [
            {"text": "Tell me about this world's history.", "next_id": "history_1"},
            {"text": "Where is the legendary sword?", "next_id": "sword_location"},
            {"text": "No, I have no particular business.", "next_id": "farewell"}
        ]
    },
    "history_1": {
        "speaker": "Old Sage",
        "text": "This world was shaped by battles between ancient dragons and giants...",
        "next_id": "history_2" # Automatically continues to next dialogue
    },
    # ... other dialogue data
}

The key point is using a Dictionary with IDs as keys rather than an array. This allows freely connecting conversation flows via next_id without worrying about conversation order.


2. Building UI and Basic Script

First, create a UI scene for displaying conversations. Use a Control node as root and arrange nodes as follows:

  • DialogueUI (Control)
    • PanelContainer (Background panel)
      • VBoxContainer (Vertical layout)
        • SpeakerLabel (Label - Speaker name)
        • TextLabel (Label - Conversation text)
    • ChoicesBox (VBoxContainer - Choice button container)

Next, create the basic script to attach to DialogueUI. This script becomes the heart of the dialogue system.

# DialogueUI.gd
extends Control

# Signal to notify external systems when dialogue completes
signal dialogue_finished

@onready var speaker_label: Label = $PanelContainer/VBoxContainer/SpeakerLabel
@onready var text_label: Label = $PanelContainer/VBoxContainer/TextLabel
@onready var choices_box: VBoxContainer = $ChoicesBox

# Timer for typewriter effect
var typing_timer: Timer = Timer.new()
var current_text: String = ""
var typing_speed: float = 0.05

var dialogue_data: Dictionary = {}
var current_dialogue_id: String

func _ready():
    typing_timer.timeout.connect(_on_typing_timer_timeout)
    add_child(typing_timer)
    # Hidden initially
    hide()

# --- Public API ---
func start(data: Dictionary, start_id: String):
    """Start the dialogue"""
    self.dialogue_data = data
    show()
    _show_dialogue(start_id)

# --- Private Methods ---
func _show_dialogue(id: String):
    if not dialogue_data.has(id):
        push_error("Dialogue ID not found: " + id)
        end_dialogue()
        return

    current_dialogue_id = id
    var entry = dialogue_data[id]

    speaker_label.text = entry.get("speaker", "")
    current_text = entry.get("text", "...")

    # Start typewriter effect
    text_label.text = current_text
    text_label.visible_characters = 0
    typing_timer.start(typing_speed)

    # Clear choices
    for child in choices_box.get_children():
        child.queue_free()

func _on_typing_timer_timeout():
    if text_label.visible_characters < current_text.length():
        text_label.visible_characters += 1
    else:
        typing_timer.stop()
        # After typing completes, show choices if present
        var entry = dialogue_data[current_dialogue_id]
        if entry.has("choices"):
            _display_choices(entry["choices"])
        # If next_id exists and no choices, auto-advance
        # Note: Long consecutive text will advance quickly.
        # Consider adding a "wait_for_input": true option
        # to data and checking here if needed
        elif entry.has("next_id"):
            _show_dialogue(entry["next_id"])
        else:
            # Terminal node. Wait for click to close
            pass

func _display_choices(choices: Array):
    for choice_data in choices:
        var button = Button.new()
        button.text = choice_data["text"]
        # Pass argument via lambda
        button.pressed.connect(func(): _on_choice_selected(choice_data["next_id"]))
        choices_box.add_child(button)

func _on_choice_selected(next_id: String):
    _show_dialogue(next_id)

func end_dialogue():
    """End dialogue and emit signal"""
    hide()
    dialogue_finished.emit()

# Control skip and advance via input
func _unhandled_input(event: InputEvent):
    if not is_visible():
        return

    # Process on mouse click or confirm key
    if event.is_action_pressed("ui_accept"):
        if typing_timer.is_stopped():
            # If typing complete and no choices
            var entry = dialogue_data[current_dialogue_id]
            if not entry.has("choices") and not entry.has("next_id"):
                end_dialogue()
        else:
            # If typing, show all text (skip)
            typing_timer.stop()
            text_label.visible_characters = current_text.length()
            _on_typing_timer_timeout() # Execute choice display immediately
        get_viewport().set_input_as_handled()

3. Common Mistakes and Best Practices

Several pitfalls exist when implementing dialogue systems. Let's compare common beginner mistakes with best practices to avoid them.

Common MistakeBest Practice
Hardcoding conversation data in scriptsExternalize conversation data as JSON or Resource files. This enables scenario editing without changing code.
Creating tight coupling with heavy get_node() usageUse signal for loosely coupled design. For example, emit dialogue_finished signal when dialogue ends to resume player control.
Not implementing input handlingProvide text skip and conversation advance via _unhandled_input. Essential for comfortable player experience.
State management becoming complexManage with simple state (e.g., is_typing) and data (current_dialogue_id). Avoid complex state machines; control flow through data-driven approach.
Not considering extensibilityAdopt Dictionary-based data structure from the start. Easily extend features by just adding keys for "speaker expression", "sound effects", etc.

4. Performance and Alternative Patterns

Performance Considerations

The method of updating visible_characters with a Timer used in this article is a highly efficient approach optimized for Godot Engine. It performs much better than manipulating String per frame and updating the Label's text property.

Alternative Pattern: Custom vs Dialogic Addon

Besides building your own dialogue system, you can use the feature-rich Dialogic addon available from Godot Asset Library.

ItemCustom System (This Article)Dialogic Addon
CustomizabilityVery high. Freely add custom logic and special features.Limited. Must implement within the addon's framework.
Learning CostModerate. Requires understanding Godot basics and GDScript, but deeply learn system structure.Low. Intuitive visual editor operation; can start with minimal programming knowledge.
Development SpeedSlow. Need to build basic features from scratch.Fast. Rich preset features; can start creating conversations immediately.
Best CasesLearning purposes, small projects, or when completely custom UI/UX is required.Large projects, non-programmers editing scenarios, rapid prototyping needed.

5. Final Step: Externalizing Data Management

Finally, to make this system more practical, let's move conversation data from GDScript to a JSON file.

  1. Create a file called dialogue_data.json in your project folder.
{
    "start": {
        "speaker": "Old Sage",
        "text": "Welcome, young traveler. Do you have business with me?",
        "choices": [
            {"text": "Tell me about this world's history.", "next_id": "history_1"},
            {"text": "Where is the legendary sword?", "next_id": "sword_location"},
            {"text": "No, I have no particular business.", "next_id": "farewell"}
        ]
    },
    "history_1": {
        "speaker": "Old Sage",
        "text": "This world was shaped by battles between ancient dragons and giants...",
        "next_id": "history_2"
    },
    "farewell": {
        "speaker": "Old Sage",
        "text": "I see. Come back anytime if you need anything."
    }
}
  1. Load this JSON from NPC or event trigger scripts to start dialogue.
# NPCScript.gd
extends Area2D

@onready var dialogue_ui = $DialogueUI # Instance in scene

var dialogue_data: Dictionary

func _ready():
    # Load and parse JSON file
    var file = FileAccess.open("res://dialogue_data.json", FileAccess.READ)
    if file == null:
        push_error("Failed to open dialogue file")
        return
    var content = file.get_as_text()
    dialogue_data = JSON.parse_string(content)

    # Connect dialogue finished signal
    dialogue_ui.dialogue_finished.connect(_on_dialogue_finished)

func _on_body_entered(body):
    # Start dialogue when player enters range
    if body.is_in_group("player"):
        # Temporarily disable player input
        body.set_process_unhandled_input(false)
        dialogue_ui.start(dialogue_data, "start")

func _on_dialogue_finished():
    # Re-enable player input
    var player = get_tree().get_first_node_in_group("player")
    if player:
        player.set_process_unhandled_input(true)

Summary

The dialogue system introduced in this article achieves all core features—text display, typewriter effect, and choice-based branching—using only Godot Engine's basic UI nodes and GDScript features.

Using this system as a foundation, you can provide richer conversation experiences with extensions like:

  • Externalizing Data Management: Convert DIALOGUE_DATA to external JSON or CSV files
  • Adding Effects: Display portraits per speaker, change SE/BGM during conversations
  • Conditional Branching: Dynamic changes based on player status or in-game flags