Files

20 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 in project.godot)
  • Plugin must be enabled: Godot → Project Settings → Plugins → Dialogue Manager
  • .dialogue files are imported as DialogueResource once 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.

  • dialogueName is 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 in DialogueTextbox._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: CHATBOX interact type removed. All NPC text now routes through DialogueManager.

Dialogue and movement control

Dialogue does not automatically block movement or camera. Each dialogue sequence declares whether it pauses them:

  • Blocking (most NPC conversations triggered by player): sets UI.activeConversation = true for the duration; EntityMovement._canMove() returns false and OverworldCamera._canOrbit() returns false.
  • Non-blocking (ambient chatter, background NPC conversations, timed popups): dialogue runs without setting activeConversation; the player can move and orbit the camera freely.

This is configured per DialogueAction call, not per line.

Implemented: DialogueMode.CONVERSATION sets UI.activeConversation = true (blocks movement and camera orbit). NARRATION and AMBIENT are non-blocking. UI.dialogueActive is driven by DialogueManager.dialogue_started/ended signals (emitted by DialogueAction) 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: DialogueTextbox has character-by-character reveal, punctuation pauses, ... detection, hold-to-skip (requires releasing Interact first — guarded by _hasLetGoOfInteract), and a pulsing advance indicator (▼) shown when waiting for input. Punctuation pauses are suppressed for the last visible character on a page or at end of text so there is no delay before the player can advance. [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: TIMED auto-advance is wired in DialogueTextbox._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: DialogueChoiceBox is shown when allowed responses exist, anchored to the player entity via OVERWORLD.getPlayerEntity(). Selected item shows a prefix and yellow highlight. Navigation uses Godot's native focus system — labels have focus_mode = FOCUS_ALL with explicit focus_neighbor_top/bottom to prevent focus escaping the list at the edges; focus_entered signals update _selectedIndex. _input handles only the game-specific interact confirm; ui_up/ui_down/ui_accept are handled natively by the engine.

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_CHATBOX in Entity.InteractType but 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 Camera orbit Advancement Typical use
CONVERSATION Blocked Locked Player (Interact) NPC interactions, cutscene dialogue
NARRATION Non-blocking Free Player (Interact) Item pickups, announcements the player can dismiss when ready
AMBIENT Non-blocking Free Timed (auto) Background NPC-to-NPC chatter, timed popups

UI.dialogueActive is driven by DialogueManager.dialogue_started / dialogue_ended signals (emitted by DialogueAction) and is true whenever any dialogue is running, regardless of mode. Movement and camera blocking are checked separately via UI.activeConversation: EntityMovement._canMove() and OverworldCamera._canOrbit() both return 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 the title argument to DialogueAction
  • => title — jump to another section; => END ends the dialogue
  • {{variable}} — resolves a property from any autoload or extra_game_states object
  • do 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 via TranslationServer.get_locale().left(2), falling back to en if the locale file is missing.


Technical implementation notes

DialogueTextbox layout

DialogueTextbox (ui/component/DialogueTextbox.gd) does its entire layout synchronously in setup() — there is no deferred pre-pass frame:

  1. size.x = MAX_WIDTH is set on the PanelContainer.
  2. _bodyLabelWidth() reads the panel StyleBox margins to compute the BodyLabel's inner width.
  3. _bodyLabel.size.x is set explicitly so that get_character_line(i) has correct metrics when called next.
  4. get_character_line() internally calls _validate_line_caches() which forces synchronous text shaping — no render frame needed.
  5. _buildPreWrappedText() iterates characters, detects wrap boundaries via get_character_line(), and inserts explicit \n characters.
  6. The pre-wrapped text is set back on the label with autowrap_mode = AUTOWRAP_OFF so layout never changes during reveal.
  7. Page detection during reveal uses _parsedText.left(idx+1).count("\n") — pure string math, no Godot layout calls.
  8. _revealNextChar() is called at end of setup() and _advance() so the box first appears with one character already visible.

Do not re-introduce a pre-pass frame (setting visible = true with an off-screen position or modulate.a = 0) — this causes a one-frame flicker that the user can see.

DialogueChoiceBox layout

DialogueChoiceBox (ui/component/DialogueChoiceBox.gd) computes its height synchronously in setup():

  1. For each label: add_child(label), then label.size.x = _labelWidth(), then label.get_minimum_size().y (forces synchronous shaping).
  2. Sum label heights + list separator gaps + panel top/bottom margins.
  3. size = Vector2(MAX_WIDTH, totalHeight) is set before visible = true.

Navigation uses Godot's native focus system. Labels have focus_mode = FOCUS_ALL; focus_neighbor_top/bottom is set explicitly to keep focus within the list (edge labels point back to themselves). focus_entered signals update _selectedIndex. _input handles only the game-specific interact confirm; ui_up/ui_down are handled natively by the engine. _confirm() guards if not visible to prevent double-firing if interact and ui_accept share a physical key.

Open questions

None currently. Update this section as new design questions arise.