Godot 4 Dialogue System Tutorial
Build a Visual Novel-Style Text Box
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.
Scene Setup — The Dialogue Box UI
Every godot text box tutorial starts with the UI. Create a new scene with this 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.
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_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:
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.
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:
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.dataThe 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.
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:
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.
Tween-Powered Typing Animation
Rather than incrementing visible_ratio manually in _process(), we use a Tween for clean, cancellable animation:
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.
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:
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.
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:
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.
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:
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.
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:
# 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.
Triggering Dialogue from NPCs
The final piece: connecting an NPC interaction to the dialogue box. Here's a minimal interactable NPC script:
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 = falseUsing 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.
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.
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.
Ready to go deeper? Our 15-minute micro-courses cover signals, state machines, and more — practical patterns for indie devs who ship.
Mastering GDScript Signals
Signal buses, typed signals, async patterns — the definitive signals course for Godot 4.
$4.99 · 15 min →State Machines in GDScript
Clean game logic with reusable state machines. Perfect for AI, UI, and game flow.
$4.99 · 15 min →Godot Pro Pack Bundle
All courses at a discount. Signals, shaders, state machines, multiplayer, and more.
$12.99 · Save 50%+ →Keep reading
Enjoyed this? Read How to Use the Signal Bus Pattern in Godot 4, Godot 4 State Machine Tutorial, and Godot 4 Tween Tutorial for more architecture patterns. Browse all courses →