Skip to content

Add dialogue to an existing NPC ​

The usual problem with a dialogue addon is that it wants to own your NPC: a base class to inherit, an autoload to register, a scene structure to adopt. This one does not. The NPC you already have keeps its script and its hierarchy.

What you start with ​

Say you have this, and its script already handles movement and animation:

Blacksmith (CharacterBody2D)
├── Sprite2D
└── CollisionShape2D

Step 1 — add the node ​

Add an NPCTalkKit as a child:

Blacksmith (CharacterBody2D)
├── Sprite2D
├── CollisionShape2D
└── NPCTalkKit          ← nothing else changes

Nothing about Blacksmith is modified. No base class, no autoload, no registration step.

Step 2 — type the lines ​

Select NPCTalkKit, press Open Editor in its Inspector summary, then use Conversations → + in the TalkKit main screen. Name it greeting and type.

No resource files are created; the lines live in the scene.

Step 3 — call it from the code you already have ​

Your NPC presumably already knows when the player is interacting with it. Call play() from there:

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

The is_running() check matters: without it, a player holding the interact key restarts the conversation. (TalkKit refuses the call anyway, so this is belt and braces.)

Step 4 — stop the player walking off mid-sentence ​

TalkKit does not touch your player controller. It asks:

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

Connect it in _ready(), not after play() — a handler connected later misses the first true and your state goes out of step.

What you did not have to do ​

  • No autoload
  • No base class on your NPC
  • No change to your player controller beyond one boolean
  • No resource files
  • No change to how you save the game

Next ​

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