Skip to content

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.

gd
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 template

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

gd
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 template
gd
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 template

While paused, advance() does nothing — the box stays on screen, frozen. Your game must choose one of three answers:

CallEffect
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.
gd
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 template

The 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 whileResult
idlestarts
opening or being readrefused, returns false — a second interact press cannot restart what the player is mid-way through
blockedallowed — 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.

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