Dialogue choices without a branching engine
Most dialogue addons answer "how do I add choices?" with a graph editor, a condition language and a variable store. That is a second game-logic system living beside the one you already have, and the two drift apart.
TalkKit answers it with a pause.
How it works
Tick event_blocking on a line. When the player advances past it, playback stops and your game is told. Nothing else happens until you say so.
extends Node
## Docs: /guide/events — player choice without a branching engine.
func _ready() -> void:
_build()
#region listen
func _connect_talk() -> void:
$NPCTalkKit.event_blocked.connect(_on_blocked)
#endregion
#region respond
# Playback is paused. The game decides what happens next, reading its own quest
# and inventory state — TalkKit never learns what a quest is.
func _on_blocked(event_id: StringName, payload: Variant) -> void:
if event_id != &"offer_job":
return
var accepted: bool = _player_has_room_for(payload)
$NPCTalkKit.play(&"accepted" if accepted else &"declined")
#endregion
#region resume
# The other two answers: carry on where it paused, or end the conversation.
func _keep_going() -> void:
$NPCTalkKit.resume()
func _walk_away() -> void:
$NPCTalkKit.stop()
#endregion
func _player_has_room_for(_payload: Variant) -> bool:
return true
func _build() -> void:
var box := _instant_box()
var talk := NPCTalkKit.new()
talk.name = "NPCTalkKit"
talk.dialogue_boxes = [box]
var ask := TalkLine.new()
ask.text = "Carrying a message to the mill pays three silver. Take it?"
ask.event_id = &"offer_job"
ask.event_blocking = true
var offer := TalkConversation.new()
offer.conversation_id = &"offer"
offer.default_dialogue_box = box
offer.lines = [ask]
var yes := TalkLine.new()
yes.text = "Good. The mill before dusk."
var accepted := TalkConversation.new()
accepted.conversation_id = &"accepted"
accepted.default_dialogue_box = box
accepted.lines = [yes]
var no := TalkLine.new()
no.text = "Suit yourself."
var declined := TalkConversation.new()
declined.conversation_id = &"declined"
declined.default_dialogue_box = box
declined.lines = [no]
talk.conversations = [offer, accepted, declined]
add_child(talk)
_connect_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))
talk.play(&"offer")
talk.advance()
if spoken.size() != 2 or not spoken[1].begins_with("Good."):
failures.append("blocking: the branch did not play, got %s" % str(spoken))
if talk.is_blocked():
failures.append("blocking: still blocked after branching")
talk.stop()
# resume() continues the conversation that paused.
var second := TalkLine.new()
second.text = "Dusk, remember."
talk.conversations[0].lines.append(second)
talk.event_blocked.disconnect(_on_blocked)
spoken.clear()
talk.play(&"offer")
talk.advance()
if not talk.is_blocked():
failures.append("blocking: advancing past a blocking line must pause")
talk.resume()
if spoken.size() != 2:
failures.append("blocking: resume() did not continue, got %s" % str(spoken))
talk.stop()
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 templateextends Node
## Docs: /guide/events — player choice without a branching engine.
func _ready() -> void:
_build()
#region listen
func _connect_talk() -> void:
$NPCTalkKit.event_blocked.connect(_on_blocked)
#endregion
#region respond
# Playback is paused. The game decides what happens next, reading its own quest
# and inventory state — TalkKit never learns what a quest is.
func _on_blocked(event_id: StringName, payload: Variant) -> void:
if event_id != &"offer_job":
return
var accepted: bool = _player_has_room_for(payload)
$NPCTalkKit.play(&"accepted" if accepted else &"declined")
#endregion
#region resume
# The other two answers: carry on where it paused, or end the conversation.
func _keep_going() -> void:
$NPCTalkKit.resume()
func _walk_away() -> void:
$NPCTalkKit.stop()
#endregion
func _player_has_room_for(_payload: Variant) -> bool:
return true
func _build() -> void:
var box := _instant_box()
var talk := NPCTalkKit.new()
talk.name = "NPCTalkKit"
talk.dialogue_boxes = [box]
var ask := TalkLine.new()
ask.text = "Carrying a message to the mill pays three silver. Take it?"
ask.event_id = &"offer_job"
ask.event_blocking = true
var offer := TalkConversation.new()
offer.conversation_id = &"offer"
offer.default_dialogue_box = box
offer.lines = [ask]
var yes := TalkLine.new()
yes.text = "Good. The mill before dusk."
var accepted := TalkConversation.new()
accepted.conversation_id = &"accepted"
accepted.default_dialogue_box = box
accepted.lines = [yes]
var no := TalkLine.new()
no.text = "Suit yourself."
var declined := TalkConversation.new()
declined.conversation_id = &"declined"
declined.default_dialogue_box = box
declined.lines = [no]
talk.conversations = [offer, accepted, declined]
add_child(talk)
_connect_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))
talk.play(&"offer")
talk.advance()
if spoken.size() != 2 or not spoken[1].begins_with("Good."):
failures.append("blocking: the branch did not play, got %s" % str(spoken))
if talk.is_blocked():
failures.append("blocking: still blocked after branching")
talk.stop()
# resume() continues the conversation that paused.
var second := TalkLine.new()
second.text = "Dusk, remember."
talk.conversations[0].lines.append(second)
talk.event_blocked.disconnect(_on_blocked)
spoken.clear()
talk.play(&"offer")
talk.advance()
if not talk.is_blocked():
failures.append("blocking: advancing past a blocking line must pause")
talk.resume()
if spoken.size() != 2:
failures.append("blocking: resume() did not continue, got %s" % str(spoken))
talk.stop()
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 templateThree answers are available, and that is the whole vocabulary:
play("other") | branch to another conversation |
resume() | carry on where it paused |
stop() | end the conversation |
Building the menu
TalkKit does not draw the buttons. That sounds like a gap until you consider that your game already has a UI style, a font, an input scheme and a controller focus system — and a dialogue addon's generic menu would match none of them.
Put the options in the line's event_payload:
# On the selected line, in the TalkKit detail pane:
# event_id = offer_job
# event_blocking = true
# event_payload = { "options": ["Take the job", "Not today"] }
func _on_blocked(event_id: StringName, payload: Variant) -> void:
var choice: int = await my_menu.ask(payload["options"])
$NPCTalkKit.play(&"accepted" if choice == 0 else &"declined")await works because you are in ordinary GDScript. There is no coroutine discipline to learn.
Conditions
There is no condition syntax, because the branch is a GDScript if:
func _on_blocked(event_id: StringName, payload: Variant) -> void:
if event_id != &"offer_job":
return
if not Inventory.has_room():
$NPCTalkKit.play(&"hands_full")
elif Quests.is_active(&"mill_delivery"):
$NPCTalkKit.play(&"already_working")
else:
$NPCTalkKit.play(&"accepted")That reads your real inventory and your real quest log, not a mirror of them that you have to keep in sync.
Remembering the answer
TalkKit stores nothing between conversations — it has no save file and no variable store, on purpose. Record the answer wherever your game already records things:
Quests.accept(&"mill_delivery")Next time, branch on that at the start:
func talk_to_miller() -> void:
$NPCTalkKit.play(&"already_working" if Quests.is_active(&"mill_delivery") else &"offer")Why the box does not blink
Branching with play() reuses the same dialogue box when the renderer matches, so the player sees the text change, not the box disappear and come back.