Dialog System

Multi-turn conversations, current_line advance, period lookup, typed arrays, and dialog bugs we hit.

stablegodotdialoggdscriptarchitecture
On this page
Multi-line dialog UI open at bottom with speaker and portrait
One conversation turn at a time. E advances current_line; Q quits. Portrait swaps for player vs guest via is_player.

What we built

Dialog is not a 2D dialog[station][hour] table anymore. It is an ordered list of turns (player / guest), looked up by time-of-day period (morning / afternoon / evening / night) plus a one-shot checkin conversation.

Data flow:

  1. Guest JSON → GuestLoaderGuestData.dialog (GuestDialog)
  2. Guest.get_dialog() picks conversation via GuestDialog.get_conversation(period, day, possessed)
  3. Turns render to Array[Dictionary] with speaker, text, is_player
  4. dialog_box.start_dialog(lines, portrait) shows one line at a time

Core UI state machine

From scripts/dialog_box.gd:

var lines: Array[Dictionary] = []
var current_line := 0
var is_open := false

func start_dialog(dialog_lines: Array, portrait: Texture2D = null) -> void:
	if dialog_lines.is_empty():
		return
	lines.assign(dialog_lines)  # typed Array copy — see bug below
	current_line = 0
	is_open = true
	visible = true
	show_current_line()

func _unhandled_input(event: InputEvent) -> void:
	if not is_open:
		return
	if event.is_action_pressed("dialog_quit"):
		close_dialog()
	elif event.is_action_pressed("interact"):
		current_line += 1
		if current_line >= lines.size():
			close_dialog()
		else:
			show_current_line()
	get_viewport().set_input_as_handled()

Why advance then show

Index starts at 0. First open calls show_current_line() immediately. Each E increments before showing the next line. End condition is current_line >= lines.size().

Wrong pattern (off-by-one / stuck on first line):

# BAD: increment after show, or show then check size incorrectly
show_current_line()
current_line += 1

If you show before incrementing and also show on the same press that opened dialog without consuming input, the first line can appear to “repeat”.

Preventing empty dialog crashes

Always guard:

if dialog_lines.is_empty():
	return

Guest.get_dialog() falls back to fallback_lines so the UI never indexes an empty array.

Per-line speaker

Earlier design passed one fixed speaker string into start_dialog(speaker, lines: Array[String]). Now each line owns its speaker:

lines.append({"speaker": speaker, "text": turn.text, "is_player": is_player})

show_current_line() updates both labels and swaps portrait (clerk vs guest).

Where data lives

LayerLocation
Authored textresources/guests/*.json
Runtime resourcesDialogConversation / DialogTurn
SelectionGuestDialog.get_conversation
Presentationdialog_box.gd

Lookup prefers matching is_corrupted == possessed, else falls back to the non-corrupted conversation for that period.

Bug: typed Array assignment

Symptom

Passing a plain Array / Array[Dictionary] into a typed Array[Dictionary] field with = can error or silently fail depending on Godot version / inference.

Cause

GDScript typed arrays are strict. lines = dialog_lines is not always a valid typed assignment.

Fix

lines.assign(dialog_lines)

Lesson

Prefer .assign() when filling Array[T] from a loosely typed parameter.

Bug: first line feels like it repeats / second line missing

Symptom

lines.size() prints correctly (e.g. 4) but pressing E shows line 1 again or skips oddly.

Cause (two we hit / guarded against)

  1. Same frame double-handle: Guest opens dialog on interact, dialog box also advances on interact in the same frame → immediate current_line += 1 or double show.
  2. Index shown before reset: Reopening without setting current_line = 0.

Fix

  • Guest calls set_input_as_handled() after opening.
  • Dialog box also marks handled while open.
  • start_dialog always resets current_line = 0.
  • Guest only opens when not dialog_box.is_open.

Lesson

One action, one consumer per frame. Use is_open as a mutex.

Bug: empty array index

Symptom

Crash or blank when lookup returns nothing.

Fix

Empty guard in start_dialog; fallback lines on the guest.

Extending later

Recommended next time (not fully built):

  • Branching choices (arrays of replies)
  • Typewriter / RichText BBCode effects
  • Voice beep per character
  • Logging heard_facts when a turn has an id (resource field exists on DialogTurn)

heard_facts exists on GameState but is not deeply wired through every turn yet — treat as partial.