Free Tutorial · Dialogue Systems

Godot 4 Dialogue System Tutorial
Build a Visual Novel-Style Text Box

SScriptSnap/March 2026/13 min read

Dialogue systems are everywhere — RPGs, visual novels, adventure games, even cozy farming sims. Yet building one from scratch feels daunting: typewriter text, character portraits, branching choices, keeping everything data-driven so designers can edit dialogue without touching code. Sound familiar?

In this Godot 4 dialogue system tutorial, you'll build a complete, reusable visual novel-style text box from scratch using pure GDScript. We'll cover RichTextLabel typewriter effects, storing dialogue in JSON and custom Resources, branching player choices, character portrait display, and a clean signal-driven architecture that keeps your dialogue box decoupled from the rest of your game.

If you've already built a state machine or used the signal bus pattern, this tutorial is the perfect next step — those patterns pair beautifully with dialogue flow.

Dialogue System
1

Scene Setup — The Dialogue Box UI

Every godot text box tutorial starts with the UI. Create a new scene with this node tree:

dialogue_box.tscn (node tree)
DialogueBox (CanvasLayer)
  Panel (PanelContainer)
    MarginContainer
      HBoxContainer
        Portrait (TextureRect)
        VBoxContainer
          NameLabel (Label)
          DialogueLabel (RichTextLabel)
      ChoicesContainer (VBoxContainer)

Anchor the PanelContainer to the bottom of the screen (Anchors: Left=0, Right=1, Top=0.75, Bottom=1) with some margin. The Portrait TextureRect sits to the left at roughly 96×96 pixels. The RichTextLabel uses bbcode_enabled = true and fit_content = true so it auto-sizes to the text. Hide ChoicesContainer by default — it only shows when the player has options.

Using a CanvasLayer as the root keeps your dialogue box above every other node in the scene, regardless of camera position or z-index.

2

Dialogue Data — JSON vs Custom Resources

A data-driven gdscript dialogue system separates content from code. Designers and writers can edit dialogue without touching scripts. Here's a simple JSON format:

dialogue/intro.json
{
  "dialogue_id": "intro",
  "lines": [
    {
      "speaker": "Elara",
      "portrait": "res://portraits/elara_neutral.png",
      "text": "Welcome, traveler. The forest has been quiet lately..."
    },
    {
      "speaker": "Elara",
      "portrait": "res://portraits/elara_worried.png",
      "text": "[b]Too[/b] quiet. Something is stirring in the ruins.",
      "choices": [
        { "label": "I'll investigate.", "next": "investigate_ruins" },
        { "label": "Not my problem.", "next": "refuse_quest" }
      ]
    }
  ]
}

Each line has a speaker, optional portrait path, the text (supporting BBCode tags), and optional choices that branch to other dialogue IDs. This is the same JSON approach we used in our Save System tutorial.

Prefer type safety? Use a custom Resource instead:

dialogue_line.gd
class_name DialogueLine
extends Resource

@export var speaker: String
@export var portrait: Texture2D
@export_multiline var text: String
@export var choices: Array[DialogueChoice] = []

Resources let you drag-and-drop portraits in the Inspector and get compile-time type checking. For larger projects with many dialogue files, the JSON route is easier for writers to edit externally. Many teams use both — JSON as the authoring format, parsed into Resources at load time.

3

Loading Dialogue — A Clean Data Manager

Create an autoload that loads and caches dialogue files. This keeps file I/O out of your UI scripts:

dialogue_manager.gd
extends Node

# Add as an Autoload in Project Settings

var _cache: Dictionary = {}

func get_dialogue(id: String) -> Dictionary:
    if _cache.has(id):
        return _cache[id]

    var path := "res://dialogue/%s.json" % id
    var file := FileAccess.open(path, FileAccess.READ)
    if file == null:
        push_error("Dialogue not found: " + path)
        return {}

    var json := JSON.new()
    json.parse(file.get_as_text())
    _cache[id] = json.data
    return json.data

The cache prevents re-reading the same file when the player replays a conversation. This is the same autoload pattern from our Signal Bus tutorial — a globally-accessible singleton that any node can call without hard-coded node paths.

📧

Want more Godot tricks?

Get our free GDScript Cheat Sheet with 20+ copy-paste snippets for signals, exports, state machines & more.

GDSJoin 500+ Godot devs · No spam · Unsubscribe anytime
4

Typewriter Effect — RichTextLabel Magic

The heart of any godot 4 dialogue system is the typewriter effect — text that appears character by character. In Godot 4, RichTextLabel has a built-in visible_ratio property that makes this trivial:

dialogue_box.gd
extends CanvasLayer

signal dialogue_finished
signal choice_selected(choice: Dictionary)

@onready var name_label: Label = $Panel/MarginContainer/HBoxContainer/VBoxContainer/NameLabel
@onready var dialogue_label: RichTextLabel = $Panel/MarginContainer/HBoxContainer/VBoxContainer/DialogueLabel
@onready var portrait: TextureRect = $Panel/MarginContainer/HBoxContainer/Portrait
@onready var choices_container: VBoxContainer = $Panel/MarginContainer/ChoicesContainer

@export var chars_per_second: float = 30.0

var _is_typing: bool = false
var _lines: Array = []
var _current_index: int = 0

func start_dialogue(dialogue_id: String) -> void:
    var data := DialogueManager.get_dialogue(dialogue_id)
    _lines = data.get("lines", [])
    _current_index = 0
    show()
    _display_line()

func _display_line() -> void:
    if _current_index >= _lines.size():
        _end_dialogue()
        return

    var line := _lines[_current_index] as Dictionary
    name_label.text = line.get("speaker", "")
    dialogue_label.text = ""
    dialogue_label.bbcode_enabled = true
    dialogue_label.parse_bbcode(line.get("text", ""))
    dialogue_label.visible_ratio = 0.0

    # Set portrait
    var portrait_path := line.get("portrait", "") as String
    if portrait_path != "":
        portrait.texture = load(portrait_path)
        portrait.visible = true
    else:
        portrait.visible = false

    # Hide choices until needed
    choices_container.visible = false
    _type_text()

The key trick: visible_ratio on a RichTextLabel controls how much of the text is visible, from 0.0 (nothing) to 1.0 (everything). We animate this value over time to get the typewriter effect — and it respects BBCode tags automatically.

5

Tween-Powered Typing Animation

Rather than incrementing visible_ratio manually in _process(), we use a Tween for clean, cancellable animation:

dialogue_box.gd (continued)
var _tween: Tween

func _type_text() -> void:
    _is_typing = true

    # Kill any running tween
    if _tween and _tween.is_running():
        _tween.kill()

    var char_count := dialogue_label.get_total_character_count()
    var duration := char_count / chars_per_second

    _tween = create_tween()
    _tween.tween_property(
        dialogue_label, "visible_ratio", 1.0, duration
    )
    _tween.finished.connect(_on_typing_finished)

func _skip_typing() -> void:
    if _tween and _tween.is_running():
        _tween.kill()
    dialogue_label.visible_ratio = 1.0
    _on_typing_finished()

func _on_typing_finished() -> void:
    _is_typing = false
    var line := _lines[_current_index] as Dictionary
    if line.has("choices"):
        _show_choices(line.choices)

The duration scales with text length so short lines feel snappy and long ones don't crawl. _skip_typing() instantly reveals the full text when the player clicks — a must-have for impatient readers. This tween pattern is covered in-depth in our Tween tutorial.

Love building dialogue systems? Our micro-courses cover signals, state machines, and more patterns that power RPGs and visual novels.

Browse courses — from $4.99 →
6

Branching Choices — Let Players Decide

When a line has a choices array, we spawn buttons dynamically and let the player pick. Each choice has a next field that points to another dialogue ID:

dialogue_box.gd (choices)
func _show_choices(choices: Array) -> void:
    # Clear old buttons
    for child in choices_container.get_children():
        child.queue_free()

    for choice in choices:
        var btn := Button.new()
        btn.text = choice.label
        btn.pressed.connect(
            _on_choice_pressed.bind(choice)
        )
        choices_container.add_child(btn)

    choices_container.visible = true

func _on_choice_pressed(choice: Dictionary) -> void:
    choice_selected.emit(choice)
    if choice.has("next"):
        start_dialogue(choice.next)
    else:
        _end_dialogue()

The bind() method captures the choice dictionary in the button's callback — no need for separate scripts per button. The choice_selected signal lets other systems react without coupling. A quest manager, for example, can connect to this signal to track which branch the player chose.

7

Input Handling — Advance & Skip

The player needs to advance dialogue with a key press or click. Handle both “skip typing” and “next line” with a single input action:

dialogue_box.gd (input)
func _unhandled_input(event: InputEvent) -> void:
    if not visible:
        return

    if event.is_action_pressed("ui_accept"):
        get_viewport().set_input_as_handled()

        if _is_typing:
            # First press: show all text instantly
            _skip_typing()
        elif not choices_container.visible:
            # Second press: advance to next line
            _current_index += 1
            _display_line()

func _end_dialogue() -> void:
    hide()
    dialogue_finished.emit()

Using _unhandled_input instead of _input lets UI buttons consume events first. If the player clicks a choice button, the dialogue box doesn't also advance a line. The set_input_as_handled() call prevents the event from bubbling further — your player character won't swing a sword mid-conversation.

Map "ui_accept" to Enter/Space/gamepad A in Project → Input Map to support multiple input devices automatically.

8

Portrait Display — Bring Characters to Life

Portraits are already handled in _display_line(), but let's add a smooth transition with a tween so the portrait doesn't just pop in:

dialogue_box.gd (portrait animation)
func _animate_portrait(new_texture: Texture2D) -> void:
    var t := create_tween()
    # Fade out old portrait
    t.tween_property(portrait, "modulate:a", 0.0, 0.1)
    # Swap texture at midpoint
    t.tween_callback(func():
        portrait.texture = new_texture
    )
    # Fade in new portrait
    t.tween_property(portrait, "modulate:a", 1.0, 0.15)

This quick fade-swap prevents the jarring “portrait pop” that plagues most tutorials. Call _animate_portrait() in _display_line() instead of directly setting the texture. Chained tweens handle the sequence automatically — no coroutines or timers needed.

Pro tip: use @export_dir to point to your portraits folder, then build a lookup dictionary mapping speaker names to their portrait textures. Check our @export tips guide for more Inspector tricks.

9

Signal-Driven Architecture — Keep It Decoupled

A good gdscript dialogue system never hard-codes references to game systems. Instead, emit signals and let other nodes react. Here's the full signal interface:

dialogue_box.gd (signals summary)
# Emitted when all dialogue lines are exhausted
signal dialogue_finished

# Emitted when the player picks a choice
signal choice_selected(choice: Dictionary)

# Emitted each time a new line begins displaying
signal line_started(speaker: String, text: String)

With these signals, the rest of your game connects naturally. An NPC triggers DialogueBox.start_dialogue("intro"). A quest tracker listens to choice_selected. A camera system listens to line_started to focus on the current speaker. The dialogue box knows nothing about quests, cameras, or NPCs — it just renders text and emits events.

This is exactly the signal bus pattern in action. For global events (like pausing gameplay while dialogue is active), route through a signal bus autoload so every system stays decoupled.

10

Triggering Dialogue from NPCs

The final piece: connecting an NPC interaction to the dialogue box. Here's a minimal interactable NPC script:

npc.gd
extends Area2D

@export var dialogue_id: String = "intro"

var _player_nearby: bool = false

func _unhandled_input(event: InputEvent) -> void:
    if _player_nearby and event.is_action_pressed("interact"):
        var box := get_tree().get_first_node_in_group("dialogue_box")
        if box:
            box.start_dialogue(dialogue_id)

func _on_body_entered(body: Node2D) -> void:
    if body.is_in_group("player"):
        _player_nearby = true

func _on_body_exited(body: Node2D) -> void:
    if body.is_in_group("player"):
        _player_nearby = false

Using get_first_node_in_group() avoids hard-coded node paths. Add your DialogueBox to the "dialogue_box" group in the editor, and any NPC can find it. The @export lets designers assign different dialogue_id values per NPC directly in the Inspector.

For more Inspector-friendly patterns, see our @export tips guide. If you need NPC state (idle, talking, walking), pair this with a state machine.

Level up your entire Godot workflow. The Godot Pro Pack bundles signals, state machines, shaders, and 3 more courses at 50%+ off.

Get the Pro Pack — $12.99 →
>> EOF

That's your complete Godot 4 dialogue system — from scene setup and JSON data to typewriter effects, branching choices, animated portraits, and a signal-driven architecture that scales from a game jam to a full visual novel.

The beauty of this approach is modularity. The DialogueBox doesn't know about quests, NPCs, or game state. It reads data, renders text, and emits signals. Everything else connects through Godot's signal system. Swap the JSON files, change the theme stylesheet, add sound effects on line_started — the core script stays untouched.

Next steps: add audio playback per line, implement a dialogue history log the player can scroll back through, or connect choices to a save system so branching decisions persist between sessions.

Free download
+ weekly tips

Get the Free GDScript Cheat Sheet + Weekly Godot Tips

Join indie devs getting our GDScript Cheat Sheet with 20+ copy-paste snippets for signals, exports, state machines, and more — plus weekly Godot tips and architecture tricks straight to your inbox.

20+ GDScript snippets
Weekly Godot tips
Zero spam, cancel anytime
>
GDSJoin 500+ Godot devs. Unsubscribe in one click.