Skip to content

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.

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

Three 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:

gdscript
# 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:

gdscript
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:

gdscript
Quests.accept(&"mill_delivery")

Next time, branch on that at the start:

gdscript
func talk_to_miller() -> void:
    $NPCTalkKit.play(&"already_working" if Quests.is_active(&"mill_delivery") else &"offer")

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.

Reference ​

Events and branching · NPCTalkKit API

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