17 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.
Implemented: Entity lookup is live —
OVERWORLD.getEntityByDialogueName(line.character)resolves the speaker. 3D→UI projection runs every frame inDialogueTextbox._updateWorldPosition().
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.
Done:
CHATBOXinteract type removed. All NPC text now routes through DialogueManager.
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.
Implemented:
DialogueMode.CONVERSATIONsetsUI.activeConversation = true(blocks movement).NARRATIONandAMBIENTare non-blocking.UI.dialogueActiveis driven byDialogueManager.dialogue_started/endedsignals and is true for any running dialogue regardless of mode.
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 plugin returns DialogueLine.text as a plain BBCode string — it does not handle punctuation pauses or ... detection. All reveal pacing is implemented in our textbox, not the plugin.
The [wait=N] and [speed=N] BBCode tags are extracted from the text by the plugin during line resolution (stored in DialogueLine.speeds and inline mutation data). Our textbox must read these to adjust reveal behaviour inline — e.g. a shocked line using [speed=4] to flash past quickly.
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 [speed=N] values so high that multiple lines appear simultaneously. The hold-to-skip path is safe since it requires sustained player input.
Implemented:
DialogueTextboxhas character-by-character reveal, punctuation pauses,...detection, and hold-to-skip.[wait=N]/[speed=N]BBCode tag support is not yet wired.
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. Used by CONVERSATION and NARRATION modes. |
TIMED |
Each line auto-advances after a delay estimated from line length (reading speed). A per-line [next=N] tag overrides the estimated delay. Player input is ignored. Used by AMBIENT mode. |
Mixed sequences (some lines timed, some player-advanced) are not supported — advancement mode is determined by DialogueMode, not per line.
Implemented:
TIMEDauto-advance is wired inDialogueTextbox._processAutoAdvance. Delay is estimated from line length (characters /READING_CHARS_PER_SECOND). Per-line[next=N]override is not yet wired.
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.
Implemented:
DialogueChoiceBoxis shown when allowed responses exist, anchored to the player entity viaOVERWORLD.getPlayerEntity(). Player navigates with ui_up/ui_down or move_forward/move_back, confirms with Interact.
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.
DialogueMode enum
Every dialogue sequence has a DialogueMode that controls movement blocking and advancement behaviour. Set it per DialogueAction call — not per line.
| Mode | Movement | Advancement | Typical use |
|---|---|---|---|
CONVERSATION |
Blocked | Player (Interact) | NPC interactions, cutscene dialogue |
NARRATION |
Non-blocking | Player (Interact) | Item pickups, announcements the player can dismiss when ready |
AMBIENT |
Non-blocking | Timed (auto) | Background NPC-to-NPC chatter, timed popups |
UI.dialogueActive is driven by DialogueManager.dialogue_started / dialogue_ended signals and is true whenever any dialogue is running, regardless of mode. Movement blocking is checked separately: EntityMovement._canMove() is false only when an active CONVERSATION sequence is in progress.
More modes can be added to this enum as new use cases arise.
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 dialogue step to any Cutscene
cutscene.addCallable(DialogueAction.getDialogueCallable(
"res://dialogue/npc/guard", # base path — no locale suffix, no extension
"start", # section to begin from
[entity], # extra_game_states exposed to {{variables}} in the file
DialogueAction.DialogueMode.CONVERSATION
))
DialogueAction resolves the base path to the correct locale file at runtime (e.g. guard.en.dialogue), falling back to en if the active locale file is missing.
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 |
|---|---|
dialogueBasePath:String |
Base path to the dialogue file, without locale suffix or extension (e.g. "res://dialogue/npc/guard") |
dialogueTitle:String |
Section to start from (default "start") |
dialogueName:String |
Key matched against .dialogue speaker names to find this entity's textbox anchor |
displayName:String |
Player-visible name shown in the textbox speaker label (can differ from dialogueName, e.g. "???") |
dialogueName must match the speaker name used in the dialogue file. entityId is unrelated to dialogue lookup.
Dialogue files
| File | Used by |
|---|---|
dialogue/npc/test.en.dialogue |
TestMap NPC (NotPlayer / "Stranger") |
dialogue/item/pickup.en.dialogue |
ItemAction — item pickup text with {{item_name}} and {{quantity}} |
dialogue/battle/narration.en.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 (no locale suffix, no extension — e.g. "res://dialogue/npc/test") and resolves the active locale automatically via TranslationServer.get_locale() (e.g. → test.en.dialogue), falling back to en if the locale file is missing.
To switch language at runtime: TranslationServer.set_locale("ja") — all subsequent dialogue loads will use the new locale. The project default is configured in Godot's Project Settings → Localization tab.
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.
Implemented:
DialogueAction._loadLocaleResource(basePath)resolves the active locale viaTranslationServer.get_locale().left(2), falling back toenif the locale file is missing.
Open questions
None currently. Update this section as new design questions arise.