Events and branching
TalkKit has no condition language, no variable store and no expression evaluator — because your game already has all three. What it has is a yield point.
Non-blocking events
Give a line an event_id and the game is told when that line finishes. Playback carries on.
extends Node
## Docs: /guide/quick-start, /guides/use-with-existing-interaction-system
##
## TalkKit asks; it never takes. Every hook below is opt-in — ignore them all
## and the addon still works, it just will not know about your player.
func _ready() -> void:
_build()
#region signals
func _connect_talk() -> void:
var talk: NPCTalkKit = $NPCTalkKit
talk.request_player_lock.connect(_on_player_lock)
talk.event_triggered.connect(_on_talk_event)
talk.conversation_finished.connect(_on_finished)
#endregion
#region lock
# TalkKit does not own your player controller. It asks, you decide.
func _on_player_lock(locked: bool) -> void:
_player_accepts_input = not locked
#endregion
#region event
# A non-blocking event: fire and forget, playback carries on.
func _on_talk_event(event_id: StringName, payload: Variant) -> void:
match event_id:
&"give_quest":
_quests.append(payload)
&"play_sound":
pass
#endregion
#region existing-interaction
# Already have an interaction system? Then skip TalkInteractionArea2D entirely
# and call play() from the code you already have. That is the preferred path.
func _on_my_own_interact_pressed(npc: Node) -> void:
var talk := npc.get_node_or_null("NPCTalkKit") as NPCTalkKit
if talk != null and not talk.is_running():
talk.play(&"greeting", {"player_name": _player_name})
#endregion
var _player_accepts_input := true
var _quests: Array = []
var _player_name := "Alex"
func _on_finished(_id: StringName) -> void:
pass
func _build() -> void:
var box := _instant_box()
var talk := NPCTalkKit.new()
talk.name = "NPCTalkKit"
talk.dialogue_boxes = [box]
var line := TalkLine.new()
line.text = "Good to see you, {{player_name}}."
line.event_id = &"give_quest"
line.event_payload = "deliver_the_letter"
var conversation := TalkConversation.new()
conversation.conversation_id = &"greeting"
conversation.default_dialogue_box = box
conversation.lines = [line]
talk.conversations = [conversation]
add_child(talk)
_connect_talk()
func _verify() -> Array[String]:
var failures: Array[String] = []
var talk: NPCTalkKit = $NPCTalkKit
_on_my_own_interact_pressed(self)
if not talk.is_running():
failures.append("integration: play() from host code did not start")
if _player_accepts_input:
failures.append("integration: the lock request never reached the host")
# A second press must not restart what the player is reading.
_on_my_own_interact_pressed(self)
talk.advance()
if not _quests.has("deliver_the_letter"):
failures.append("integration: the non-blocking event never arrived")
if not _player_accepts_input:
failures.append("integration: the lock was never released at the end")
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 templateUse it for sound cues, camera shakes, handing over a quest — anything that does not need an answer.
Blocking events: the branch
Tick event_blocking on a line and playback pauses until your game responds. A separate signal is used, deliberately, so it is impossible to miss that a response is expected.
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 templateWhile paused, advance() does nothing — the box stays on screen, frozen. Your game must choose one of three answers:
| Call | Effect |
|---|---|
play("other") | Abandon the rest, start another conversation. The box is reused when the renderer matches, so it does not blink. |
resume() | Carry on from the next line of the current conversation. |
stop() | Cancel. Emits conversation_cancelled and releases the player lock. |
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 templateThe branching logic is ordinary GDScript in your game, reading your own quest and inventory state. TalkKit never learns what a quest is.
The choice UI is not included
TalkKit pauses and tells you. Drawing the buttons is your game's job — it already knows what its menus look like.
Conversation-level events
A TalkConversation can carry its own event_id, fired when the conversation completes, immediately before conversation_finished.
Non-blocking only. There is no playback left to pause at that point; a blocking event at the end of a conversation is exactly a blocking event on its last line.
What play() does while busy
| Called while | Result |
|---|---|
| idle | starts |
| opening or being read | refused, returns false — a second interact press cannot restart what the player is mid-way through |
| blocked | allowed — this is the branch above |
It returns bool and never throws, so a refused call is something you can check rather than something that crashes.