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
└── CollisionShape2DStep 1 — add the node
Add an NPCTalkKit as a child:
Blacksmith (CharacterBody2D)
├── Sprite2D
├── CollisionShape2D
└── NPCTalkKit ← nothing else changesNothing 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:
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 templateThe 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:
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 templateConnect 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
- Use your own interaction system if you have a shared "press E on things" layer
- Dialogue choices when the NPC needs to ask something