Skip to content

Shared library ​

Optional. An NPC that says its own lines needs nothing on this page.

A TalkKitDatabase is a plain resource holding conversations and character speakers, for content reused across NPCs or scenes — a shopkeeper greeting that twelve shopkeepers share.

Lookup order ​

gd
extends Node

## Docs: /guide/library — reusing conversations across NPCs. Optional.

func _ready() -> void:
	_build()


#region lookup
# play() checks the node first, then the library. So an NPC overrides a shared
# conversation simply by owning one with the same name — there is no alias
# concept to learn.
func who_wins() -> StringName:
	return $NPCTalkKit.find_conversation(&"greeting").conversation_id
#endregion


#region list
# Everything this NPC can play, node content shadowing the library.
func every_name() -> Array[StringName]:
	return $NPCTalkKit.list_conversations()
#endregion


func _build() -> void:
	var box := _instant_box()
	var shared := TalkConversation.new()
	shared.conversation_id = &"greeting"
	shared.default_dialogue_box = box
	var shared_line := TalkLine.new()
	shared_line.text = "Welcome to Oakhollow."
	shared.lines = [shared_line]

	var farewell := TalkConversation.new()
	farewell.conversation_id = &"farewell"
	farewell.default_dialogue_box = box
	var bye := TalkLine.new()
	bye.text = "Safe roads."
	farewell.lines = [bye]

	var library := TalkKitDatabase.new()
	library.conversations = [shared, farewell]

	var own := TalkConversation.new()
	own.conversation_id = &"greeting"
	own.default_dialogue_box = box
	var own_line := TalkLine.new()
	own_line.text = "Mind the anvil, it is still hot."
	own.lines = [own_line]

	var talk := NPCTalkKit.new()
	talk.name = "NPCTalkKit"
	talk.dialogue_boxes = [box]
	talk.library = library
	talk.conversations = [own]
	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))

	talk.play(&"greeting")
	if spoken.is_empty() or not spoken[0].begins_with("Mind the anvil"):
		failures.append("library: the node's own conversation must win, got %s" % str(spoken))
	talk.stop()

	spoken.clear()
	talk.play(&"farewell")
	if spoken.is_empty() or spoken[0] != "Safe roads.":
		failures.append("library: a library-only conversation must still play")
	talk.stop()

	var names := every_name()
	if not (names.has(&"greeting") and names.has(&"farewell")) or names.size() != 2:
		failures.append("library: listing must merge both without duplicates, got %s" % str(names))
	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

play() checks the node first, then the library. So an NPC overrides shared content by owning a conversation with the same name. There is no alias concept, because there does not need to be one.

gd
extends Node

## Docs: /guide/library — reusing conversations across NPCs. Optional.

func _ready() -> void:
	_build()


#region lookup
# play() checks the node first, then the library. So an NPC overrides a shared
# conversation simply by owning one with the same name — there is no alias
# concept to learn.
func who_wins() -> StringName:
	return $NPCTalkKit.find_conversation(&"greeting").conversation_id
#endregion


#region list
# Everything this NPC can play, node content shadowing the library.
func every_name() -> Array[StringName]:
	return $NPCTalkKit.list_conversations()
#endregion


func _build() -> void:
	var box := _instant_box()
	var shared := TalkConversation.new()
	shared.conversation_id = &"greeting"
	shared.default_dialogue_box = box
	var shared_line := TalkLine.new()
	shared_line.text = "Welcome to Oakhollow."
	shared.lines = [shared_line]

	var farewell := TalkConversation.new()
	farewell.conversation_id = &"farewell"
	farewell.default_dialogue_box = box
	var bye := TalkLine.new()
	bye.text = "Safe roads."
	farewell.lines = [bye]

	var library := TalkKitDatabase.new()
	library.conversations = [shared, farewell]

	var own := TalkConversation.new()
	own.conversation_id = &"greeting"
	own.default_dialogue_box = box
	var own_line := TalkLine.new()
	own_line.text = "Mind the anvil, it is still hot."
	own.lines = [own_line]

	var talk := NPCTalkKit.new()
	talk.name = "NPCTalkKit"
	talk.dialogue_boxes = [box]
	talk.library = library
	talk.conversations = [own]
	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))

	talk.play(&"greeting")
	if spoken.is_empty() or not spoken[0].begins_with("Mind the anvil"):
		failures.append("library: the node's own conversation must win, got %s" % str(spoken))
	talk.stop()

	spoken.clear()
	talk.play(&"farewell")
	if spoken.is_empty() or spoken[0] != "Safe roads.":
		failures.append("library: a library-only conversation must still play")
	talk.stop()

	var names := every_name()
	if not (names.has(&"greeting") and names.has(&"farewell")) or names.size() != 2:
		failures.append("library: listing must merge both without duplicates, got %s" % str(names))
	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

Promoting a conversation ​

Authoring starts inline. Moving one into the library is an explicit action, not something that happens behind your back:

  1. Assign a library to the node, and save that library to a file.
  2. Select the conversation in the TalkKit workspace and choose ⋮ → Save to Library.

The conversation is saved as its own .tres, the node's slot is repointed at the file, and the library gains an entry.

Nothing auto-syncs. Syncing on scene save would dirty a shared file on every scene edit — an invisible side effect and a merge-conflict generator.

Conversation ids are per node ​

play("greeting") is called on a specific node, so twenty NPCs may each own a greeting. Only the library is a single namespace. The validator follows that:

SituationReported as
the library declares an id twiceerror — genuinely ambiguous
one node declares an id twiceerror — the second can never play
two different nodes share an idwarning — normal, but promoting both would collide
a node shadows a library idnothing — that is the override above

Validating a whole scene ​

TalkValidator and TalkKitSerializer take plain arrays, never a database, so inline content is a first-class citizen rather than a special case:

gdscript
var report := TalkKitSceneCollector.validate(scene_root, library)

It walks every NPCTalkKit in the scene and checks it alongside the library.

JSON ​

TalkKitSerializer exports and imports conversations as JSON, for a translation pipeline, an external tool, or an AI agent writing content. There is no LLM anywhere in the addon — it is structured data that happens to be easy for one to produce.

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