Skip to content

Writing conversations ​

A conversation is a named list of lines. play() runs one by name, the way AnimationPlayer.play() runs an animation by name.

Where they live ​

Typed into the node, they are stored in the scene — zero files. Saving one as a .tres is a later choice, not a starting requirement. See Shared library.

gd
extends Node

## Docs: /guide/quick-start — the whole surface needed to say two lines.

func _ready() -> void:
	_build_without_files()


#region play
# Nothing here needs a .tres file. The conversation was typed in the TalkKit
# workspace, and this is the entire runtime surface.
func greet() -> void:
	$NPCTalkKit.play(&"greeting")
#endregion


## Builds in code what the TalkKit workspace builds by hand, so this file runs.
func _build_without_files() -> void:
	var talk := NPCTalkKit.new()
	talk.name = "NPCTalkKit"
	var smith := TalkSpeaker.new()
	smith.display_name = "Torvald"
	talk.speakers = [smith]
	var box := _instant_box()
	talk.dialogue_boxes = [box]

	var conversation := TalkConversation.new()
	conversation.conversation_id = &"greeting"
	conversation.default_speaker = smith
	conversation.default_dialogue_box = box
	var first := TalkLine.new()
	first.text = "The forge runs hot today."
	var second := TalkLine.new()
	second.text = "Come back when you need steel."
	conversation.lines = [first, second]
	talk.conversations = [conversation]
	add_child(talk)


func _verify() -> Array[String]:
	var failures: Array[String] = []
	var spoken: Array[String] = []
	var talk: NPCTalkKit = $NPCTalkKit
	talk.line_started.connect(func(line: TalkLine) -> void: spoken.append(line.text))

	greet()
	if not talk.is_running():
		failures.append("quick_start: play() did not start the conversation")
	talk.advance()
	talk.advance()
	if spoken.size() != 2:
		failures.append("quick_start: expected two lines, got %s" % str(spoken))
	if talk.is_running():
		failures.append("quick_start: the conversation should have ended")
	return failures


## Typing speed lives on the dialogue box now, so turning it off for a scripted
## run means handing the node a box that types instantly.
func _instant_box() -> TalkBoxTemplate:
	var template := TalkBoxTemplate.new()
	template.typewriter_speed = 0.0
	return template

Text parameters ​

Write in a line and fill it at playback:

gd
extends Node

## Docs: /guide/conversations — {{placeholders}} filled at playback.

func _ready() -> void:
	_build()


#region play
# {{player_name}} is replaced when the line is shown. TalkKit never reads game
# state: it renders what you hand it.
func greet(player_name: String) -> void:
	$NPCTalkKit.play(&"greeting", {"player_name": player_name})
#endregion


#region unknown
# A key with no value is left written as-is and warned about, rather than
# silently emptied — an authoring mistake you can see beats one you cannot.
func greet_without_a_name() -> void:
	$NPCTalkKit.play(&"greeting")  # renders: Welcome back, {{player_name}}.
#endregion


func _build() -> void:
	var box := _instant_box()
	var talk := NPCTalkKit.new()
	talk.name = "NPCTalkKit"
	talk.dialogue_boxes = [box]
	var line := TalkLine.new()
	line.text = "Welcome back, {{player_name}}."
	var conversation := TalkConversation.new()
	conversation.conversation_id = &"greeting"
	conversation.default_dialogue_box = box
	conversation.lines = [line]
	talk.conversations = [conversation]
	add_child(talk)


func _verify() -> Array[String]:
	var failures: Array[String] = []
	var talk: NPCTalkKit = $NPCTalkKit
	var spoken: Array[String] = []
	talk.line_started.connect(func(line: TalkLine) -> void: spoken.append(line.text))

	greet("Alex")
	if spoken.is_empty() or spoken[0] != "Welcome back, Alex.":
		failures.append("parameters: substitution failed, got %s" % str(spoken))
	talk.stop()

	spoken.clear()
	greet_without_a_name()
	if spoken.is_empty() or not spoken[0].contains("{{player_name}}"):
		failures.append("parameters: an unknown key must stay visible, got %s" % str(spoken))
	talk.stop()

	# The source line must never be mutated: it may be shared by other NPCs.
	if talk.conversations[0].lines[0].text != "Welcome back, {{player_name}}.":
		failures.append("parameters: the authored line was mutated by substitution")
	return failures


## Typing speed lives on the dialogue box now, so turning it off for a scripted
## run means handing the node a box that types instantly.
func _instant_box() -> TalkBoxTemplate:
	var template := TalkBoxTemplate.new()
	template.typewriter_speed = 0.0
	return template

Merge order, later wins:

  1. default_params on the node
  2. the dictionary passed to play()

Three rules worth knowing:

  • A key with no value stays visible. renders as written and logs a warning, rather than silently emptying — a mistake you can see beats one you cannot.
  • One pass, no recursion. A value containing is not expanded again. This is not a template language.
  • The authored line is never modified. Substitution renders into a copy, because the line may be a shared resource other NPCs are using.
gd
extends Node

## Docs: /guide/conversations — {{placeholders}} filled at playback.

func _ready() -> void:
	_build()


#region play
# {{player_name}} is replaced when the line is shown. TalkKit never reads game
# state: it renders what you hand it.
func greet(player_name: String) -> void:
	$NPCTalkKit.play(&"greeting", {"player_name": player_name})
#endregion


#region unknown
# A key with no value is left written as-is and warned about, rather than
# silently emptied — an authoring mistake you can see beats one you cannot.
func greet_without_a_name() -> void:
	$NPCTalkKit.play(&"greeting")  # renders: Welcome back, {{player_name}}.
#endregion


func _build() -> void:
	var box := _instant_box()
	var talk := NPCTalkKit.new()
	talk.name = "NPCTalkKit"
	talk.dialogue_boxes = [box]
	var line := TalkLine.new()
	line.text = "Welcome back, {{player_name}}."
	var conversation := TalkConversation.new()
	conversation.conversation_id = &"greeting"
	conversation.default_dialogue_box = box
	conversation.lines = [line]
	talk.conversations = [conversation]
	add_child(talk)


func _verify() -> Array[String]:
	var failures: Array[String] = []
	var talk: NPCTalkKit = $NPCTalkKit
	var spoken: Array[String] = []
	talk.line_started.connect(func(line: TalkLine) -> void: spoken.append(line.text))

	greet("Alex")
	if spoken.is_empty() or spoken[0] != "Welcome back, Alex.":
		failures.append("parameters: substitution failed, got %s" % str(spoken))
	talk.stop()

	spoken.clear()
	greet_without_a_name()
	if spoken.is_empty() or not spoken[0].contains("{{player_name}}"):
		failures.append("parameters: an unknown key must stay visible, got %s" % str(spoken))
	talk.stop()

	# The source line must never be mutated: it may be shared by other NPCs.
	if talk.conversations[0].lines[0].text != "Welcome back, {{player_name}}.":
		failures.append("parameters: the authored line was mutated by substitution")
	return failures


## Typing speed lives on the dialogue box now, so turning it off for a scripted
## run means handing the node a box that types instantly.
func _instant_box() -> TalkBoxTemplate:
	var template := TalkBoxTemplate.new()
	template.typewriter_speed = 0.0
	return template

TalkKit never reads game state. It renders what you hand it.

Saying something with no authoring at all ​

For a throwaway line — a tutorial hint, a debug message — skip authoring entirely:

gdscript
$NPCTalkKit.say("The gate is barred.")
$NPCTalkKit.say(["One.", "Two."], {"name": "Alex"})

The conversation is built on the fly and discarded; nothing is added to the node.

Per-line options ​

Most lines need nothing but text. Select an exceptional line and use the right-hand detail pane. Add override… reveals the complete task-oriented groups; active exceptions remain visible and each has its own Reset:

FieldFor
speaker_mode, speakerInherit the conversation's speaker, None for a narrator line, or a different character speaking this line — name, face and voice together
display_name_overridea different name only: "???" before a reveal. The face and the voice stay whoever is speaking
visual_variantone of the speaker's named faces, such as angry
characters_per_secondslowing one dramatic line down; 0 inherits
event_id, event_payloadtelling the game something — events
event_blockingpausing for an answer — branching
dialogue_boxone line in a different box
placementone line somewhere else — a bubble over whoever interrupts
voice_clipa recorded voice line, played as the line starts
line_idmatching on a specific line from host code

line_id is optional. It exists for host code and localisation keys, not because TalkKit needs it.

The right pane also renders the selected line with the real runtime renderer. ▶ Play plays the conversation there from the selected line — typewriter, transitions and voice, one line after another — and ■ Stop or ↓ Next holds it still again. At medium widths the pane becomes a drawer so line text keeps useful space. Line checkboxes enable bulk Speaker, Dialogue Box, Placement, duplicate and delete; the drag handle and Alt+↑/↓ both reorder.

GDScript-first. No telemetry, no network requests, no AI service dependency in the shipped addon.