Files
Dawn-Godot/.claude/docs/dialogue.md
T

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 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.

Not yet implemented: the entity-lookup and 3D→UI projection logic. Currently DialogueAction passes 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 CHATBOX interact type (WorldChatBox, plain string) should be replaced with a one-line .dialogue file 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 = true for 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: DialogueAction always sets UI.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: VNTextbox has 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: TIMED mode. 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: DialogueAction currently 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_CHATBOX in Entity.InteractType but 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 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
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.