14 KiB
Dialogue System
All authored text — NPC conversations, item pickup messages, battle narration — is written in .dialogue files and played back via DialogueManager (godot_dialogue_manager v3.10.4).
Plugin — godot_dialogue_manager v3.10.4
- Repo: nathanhoad/godot_dialogue_manager
- Autoload:
DialogueManager(registered inproject.godot) - Plugin must be enabled: Godot → Project Settings → Plugins → Dialogue Manager
.dialoguefiles are imported asDialogueResourceonce the plugin is active- Dialogue files live in
dialogue/organised by category (npc/,item/,battle/)
Key API
Fetching lines manually (our approach)
# Must be awaited. Runs any mutations encountered along the way.
# Returns a DialogueLine, or null when dialogue ends.
var line:DialogueLine = await DialogueManager.get_next_dialogue_line(
resource, # DialogueResource
"start", # cue / section title to begin from
[extra_state_obj] # optional: objects whose properties are accessible in {{variables}}
)
This is the low-level path we use — it gives full control over how lines are displayed. We do not use DialogueManager.show_dialogue_balloon() since we render lines ourselves via the world-space textbox system.
Advancing to the next line
# Pass line.next_id to continue the conversation
var next_line = await DialogueManager.get_next_dialogue_line(resource, line.next_id)
# For a chosen response, pass the response's next_id instead
var next_line = await DialogueManager.get_next_dialogue_line(resource, chosen_response.next_id)
Signals
| Signal | When |
|---|---|
dialogue_started(resource) |
First line fetched from a resource |
dialogue_ended(resource) |
get_next_dialogue_line returns null |
got_dialogue(line) |
Each time a printable line is found |
mutated(mutation) |
A do / set mutation line is about to run |
passed_cue(cue) |
A ~ cue marker is passed through |
UISingleton.dialogueActive should be driven by dialogue_started / dialogue_ended rather than set manually.
DialogueLine properties
| Property | Type | Notes |
|---|---|---|
character |
String |
Speaker name (empty string if no speaker) |
text |
String |
The line body, with BBCode and {{variables}} resolved |
responses |
Array[DialogueResponse] |
Non-empty when the line has choices |
next_id |
String |
ID to pass to the next get_next_dialogue_line call |
tags |
PackedStringArray |
[#tag] annotations on the line — use for expressions, camera cues, etc. |
DialogueResponse properties
| Property | Type | Notes |
|---|---|---|
text |
String |
Display text for the option |
next_id |
String |
Pass to get_next_dialogue_line if this response is chosen |
is_allowed |
bool |
False if the [if condition] check failed — hide or grey out disallowed responses |
character |
String |
Speaker name for this response (usually the player) |
BBCode tags (dialogue-manager extras)
Beyond standard Godot BBCode, the plugin adds:
| Tag | Effect |
|---|---|
[wait=N] |
Pause reveal for N seconds (or [wait="action"] to wait for input) |
[speed=N] |
Multiply reveal speed by N for the remainder of the line |
[next=N] |
Auto-advance to next line after N seconds (used with TIMED mode) |
[next=auto] |
Auto-advance after a length-estimated delay |
[[A|B|C]] |
Pick one option at random inline |
Design Goals
These are the intended behaviours for the full system. Some are implemented; some are not yet (see status notes per section).
World-space textboxes
Every textbox is anchored to a 3D point in the world and projected into UI space each frame. There is no bottom-bar HUD textbox — all speech appears spatially near the speaker.
- Textbox shows a speaker name (small static label) and body text (character-by-character reveal).
- Max width/height is capped; text wraps automatically. Overflow becomes additional pages advanced by player input.
- If the anchor point goes off-screen, the textbox clamps to the nearest screen edge — a textbox is always visible if it is active. If a dialogue sequence should genuinely allow its textbox to go off-screen (e.g. distant background NPC chatter the player isn't meant to read), use a non-blocking sequence and don't show the textbox at all, or use a different trigger type.
- Multiple textboxes can be on screen simultaneously (e.g. two NPCs talking to each other while the player has their own dialogue open).
Speaker → Entity mapping
The speaker name in a .dialogue line (e.g. Guard: Halt!) is matched against entities present in the current scene by a separate dialogueName export on Entity (not entityId). The matched entity provides the 3D anchor point for the textbox and the display name shown in the speaker label.
dialogueNameis the key used for lookup — set it in the Inspector, keep it stable within a project.- The display name shown to the player is a separate property and can differ (e.g.
dialogueName = "guard_captain"but display name ="???"before the character has revealed their name). This lets you write dialogue that references a speaker before the player knows who they are. - Every dialogue line must have a speaker. There is no narrator/no-speaker path — if a line has no natural speaker, use a named invisible anchor Entity in the scene.
Not yet implemented: the entity-lookup and 3D→UI projection logic. Currently
DialogueActionpasses a target node manually.
All conversation routes through DialogueManager
Whether a line of dialogue is triggered by:
- A player pressing Interact on an NPC,
- A player walking into a proximity trigger,
- A cutscene sequence,
- Two NPCs conversing automatically,
…it is always written in a .dialogue file and played back via DialogueManager.get_next_dialogue_line(). There is no separate "dumb string" path for simple one-liners.
Migration needed: the current
CHATBOXinteract type (WorldChatBox, plain string) should be replaced with a one-line.dialoguefile going through the same pipeline.
Dialogue and movement control
Dialogue does not automatically block movement. Each dialogue sequence declares whether it pauses movement:
- Blocking (most NPC conversations triggered by player): sets
UI.dialogueActive = truefor the duration;EntityMovement._canMove()returns false. - Non-blocking (ambient chatter, background NPC conversations, timed popups): dialogue runs without setting
dialogueActive; the player can move freely.
This is configured per DialogueAction call, not per line.
Partially implemented:
DialogueActionalways setsUI.dialogueActive. The non-blocking path does not exist yet.
Text reveal (scrolling)
Body text is revealed character-by-character at a speed approximating natural human speech. The reveal is not purely uniform — punctuation introduces natural pauses:
| Character | Behaviour |
|---|---|
, ; |
Short pause (~0.15s) |
. ! ? |
Longer pause (~0.4s) |
... |
Each dot revealed individually with a pause between (~0.3s each), simulating thinking |
Holding the Interact bind speeds up reveal to near-instant. Once all characters on the current page are revealed, pressing Interact advances to the next page or the next dialogue line.
The [wait=N] and [speed=N] BBCode tags from DialogueManager can override reveal behaviour inline for specific moments (e.g. a shocked line flashing by at [speed=4]).
ADA note: fast reveal speeds (via [speed=N] or hold-to-skip) should not flash entire blocks of text in a way that could trigger photosensitive responses. Avoid using [speed=N] values so high that multiple lines appear simultaneously. The hold-to-skip path is safe since it requires sustained player input.
Partially implemented:
VNTextboxhas character-by-character reveal and hold-to-skip. Punctuation pause logic is not yet implemented.
Advancement modes
Each dialogue sequence uses one of two advancement modes:
| Mode | Behaviour |
|---|---|
PLAYER (default) |
Player presses Interact to advance to the next page or line once reveal is complete. |
TIMED |
Each line auto-advances after a delay estimated from line length (reading speed). A per-line [next=N] override is supported for lines that need a specific timing. Player input is ignored. |
Mixed sequences (some lines timed, some player-advanced) are not supported — the mode is set per conversation, not per line.
Not yet implemented:
TIMEDmode. Currently all dialogue requires player input.
Response / choice UI
When a DialogueLine has a non-empty responses array, reveal pauses and a choice textbox appears anchored to the player entity — choices appear as if the player is speaking. The options are a vertical list; the player selects with D-pad/arrow keys and confirms with Interact. The chosen response's next_id is passed to get_next_dialogue_line().
The choice textbox follows the same world-space anchor and screen-edge clamping rules as all other textboxes.
Not yet implemented:
DialogueActioncurrently auto-selects the first allowed response. Replace the auto-select block with a call to the response UI and await its selection signal.
Trigger types
| Trigger | How it works |
|---|---|
| Player interact | Entity interactType = CONVERSATION; player presses Interact nearby |
| Proximity (enter area) | Trigger volume fires when player enters; starts non-blocking dialogue |
| Cutscene sequence | DialogueAction added to a Cutscene queue |
| Scripted NPC-to-NPC | Cutscene sequence with non-blocking mode, no player involvement |
Proximity trigger: defined as
PROXIMITY_CHATBOXinEntity.InteractTypebut not yet implemented.
DialogueAction — the Cutscene bridge
cutscene/dialogue/DialogueAction.gd is the glue between the Cutscene queue and DialogueManager. It fetches lines via get_next_dialogue_line(), displays them in the world-space textbox, and returns CUTSCENE_CONTINUE when the last line is dismissed.
# Add a blocking dialogue step to any Cutscene
cutscene.addCallable(DialogueAction.getDialogueCallable(
load("res://dialogue/npc/test.dialogue"),
"start", # section to begin from
[entity], # extra_game_states exposed to {{variables}} in the file
true # blocking — pauses player movement
))
The blockMovement argument (fourth param, default true) controls whether UI.dialogueActive is set for the duration.
Writing .dialogue files
~ title_name # entry point / jump target
SpeakerName: Line of dialogue.
~ another_section
SpeakerName: Variables resolve inline: {{some_property}}.
=> END # end this dialogue
Key syntax:
~ title— section anchor; pass as thetitleargument toDialogueAction=> title— jump to another section;=> ENDends the dialogue{{variable}}— resolves a property from any autoload orextra_game_statesobjectdo AUTOLOAD.method()— call a method on any autoload (e.g.do PARTY.BACKPACK.addStack(stack))set AUTOLOAD.property = value— set a property on any autoload- Option text— response branch (player choice); indented lines run for that branch[if condition]— conditional line or response[wait=N]— pause character reveal for N seconds mid-line[speed=N]— multiply reveal speed by N for the rest of the line
Mutations fire automatically before the next dialogue line is returned — you do not handle them manually in GDScript.
Speaker names
The name before the colon becomes DialogueLine.character. This string is matched case-insensitively against the dialogueName export on Entity nodes in the current scene, to find the 3D anchor point.
Keep dialogueName values stable across a project — they are the linking key between dialogue files and scene nodes. The player-visible name shown in the textbox speaker label is a separate property and can change at runtime (e.g. "???" before a character is introduced).
guard_captain: Halt! Who goes there?
# Entity Inspector exports
entityId = "guard_captain_01" # internal UUID, can be anything
dialogueName = "guard_captain" # must match the .dialogue speaker name
displayName = "???" # shown to the player in the textbox speaker label
Passing runtime data to dialogue
Use extra_game_states to expose GDScript objects to {{variable}} tokens:
class ItemDialogueState:
var item_name:String
var quantity:int
DialogueAction.getDialogueCallable(resource, 'start', [ItemDialogueState.new("Potato", 1)])
Then in the .dialogue file:
shopkeeper: Here's your {{item_name}} x{{quantity}}.
NPC entities
Set these exports on an Entity node whose interactType = CONVERSATION:
| Export | Purpose |
|---|---|
dialogueResource:DialogueResource |
The .dialogue file to run |
dialogueTitle:String |
Section to start from (default "start") |
The entity's entityId must match the speaker name used in the dialogue file so the textbox anchor resolves correctly.
Dialogue files
| File | Used by |
|---|---|
dialogue/npc/test.dialogue |
TestMap NPC (NotPlayer) |
dialogue/item/pickup.dialogue |
ItemAction — item pickup text with {{item_name}} and {{quantity}} |
dialogue/battle/narration.dialogue |
BattleCutsceneAction — move announcements, victory/defeat |
Translation
Translation uses per-locale dialogue files. Each .dialogue file has a locale suffix; DialogueAction selects the correct file at runtime based on the active locale in SETTINGS.
dialogue/npc/test.en.dialogue
dialogue/npc/test.ja.dialogue
DialogueAction.getDialogueCallable() accepts a base path (without locale suffix) and resolves the active locale automatically, falling back to en if the locale file is missing.
Do not hardcode any visible text outside of .dialogue files. All player-facing strings — including item pickup messages, battle narration, and UI prompts — must live in a dialogue file so they are covered by the locale system.
Not yet implemented: locale resolution in
DialogueAction. Currently loads the resource directly.
Open questions
None currently. Update this section as new design questions arise.