Speech bubbles above an NPC
A bubble that tracks a character is the default behaviour of the Speech Bubble template, so most of this guide is about the cases that go wrong.
The basics
- Open the TalkKit workspace. In Dialogue Boxes, choose + New → Speech Bubble — the conversation's defaults only list boxes the node carries.
- Back in Conversations, set the conversation's Dialogue Box default to
Speech Bubble. - Leave its placement on Use dialogue box placement.
The bubble follows conversation_target, which defaults to the node's parent — usually the NPC itself. There is nothing else to configure.
Moving it off the head
The default sits 96 pixels above the target. If your sprite is taller or the origin is at the feet rather than the centre, adjust it:
extends Node
## Docs: /guide/anchoring — where the box sits and what it tracks.
var _camera: Camera3D
var _villager: Node3D
func _ready() -> void:
_build()
#region screen
# Pinned to the viewport. The Classic Panel ships with this, but any box can
# use it — placement is not decided by the template you picked.
func pin_to_the_bottom() -> void:
var anchor := TalkAnchor.new()
anchor.mode = TalkAnchor.Mode.SCREEN
anchor.screen_spot = TalkAnchor.Spot.BOTTOM
anchor.screen_margin = Vector2(32.0, 32.0)
anchor.stretch_horizontal = true
$NPCTalkKit.quick_say_placement = anchor
#endregion
#region follow-2d
# Tracks a node. `pivot` says which part of the box lands on the target:
# (0.5, 1) puts it above, (0.5, 0) below, (1, 0.5) to its left.
func float_above_the_npc(npc: Node2D) -> void:
var anchor := TalkAnchor.new()
anchor.mode = TalkAnchor.Mode.NODE_FOLLOW
anchor.follow_offset = Vector2(0.0, -96.0)
anchor.pivot = Vector2(0.5, 1.0)
anchor.when_offscreen = TalkAnchor.Offscreen.CLAMP
$NPCTalkKit.quick_say_placement = anchor
$NPCTalkKit.conversation_target = npc
#endregion
#region follow-3d
# Two offsets, and the difference matters. follow_offset_3d is world space and
# is applied before projection, so it stays on the character's head as the
# camera moves. follow_offset is a screen-space nudge applied afterwards.
func float_above_a_3d_character(character: Node3D) -> void:
var anchor := TalkAnchor.new()
anchor.mode = TalkAnchor.Mode.NODE_FOLLOW
anchor.follow_offset_3d = Vector3(0.0, 1.9, 0.0) # head height, in metres
anchor.follow_offset = Vector2(0.0, -12.0) # a few pixels of air
anchor.max_distance = 30.0 # hide beyond this
anchor.when_offscreen = TalkAnchor.Offscreen.HIDE
$NPCTalkKit.quick_say_placement = anchor
$NPCTalkKit.conversation_target = character
#endregion
#region distance
# Off by default: a constant on-screen size keeps text readable at any range.
# Turn it on when the box should feel part of the world.
func shrink_with_distance(anchor: TalkAnchor) -> void:
anchor.scale_with_distance = true
anchor.reference_distance = 8.0
anchor.min_scale = 0.6
anchor.max_scale = 1.4
#endregion
func _build() -> void:
var box := _instant_box()
var talk := NPCTalkKit.new()
talk.name = "NPCTalkKit"
talk.dialogue_boxes = [box]
add_child(talk)
_camera = Camera3D.new()
_camera.position = Vector3(0.0, 0.0, 10.0)
add_child(_camera)
_camera.make_current()
_villager = Node3D.new()
add_child(_villager)
func _verify() -> Array[String]:
var failures: Array[String] = []
var box := Vector2(400.0, 100.0)
var talk: NPCTalkKit = $NPCTalkKit
pin_to_the_bottom()
var screen := talk.quick_say_placement.resolve(self, box)
var view := get_viewport().get_visible_rect().size
if not screen.visible or not is_equal_approx(screen.position.y, view.y - 100.0 - 32.0):
failures.append("anchoring: the screen anchor did not sit on the bottom margin")
float_above_a_3d_character(_villager)
var tracked := talk.quick_say_placement.resolve(self, box, _villager)
if not tracked.visible:
failures.append("anchoring: a character in front of the camera must be visible")
# Behind the camera must hide, not mirror across the screen.
_villager.position = Vector3(0.0, 0.0, 40.0)
if talk.quick_say_placement.resolve(self, box, _villager).visible:
failures.append("anchoring: a target behind the camera must be hidden")
_villager.position = Vector3.ZERO
# Past max_distance must hide.
_camera.position = Vector3(0.0, 0.0, 100.0)
if talk.quick_say_placement.resolve(self, box, _villager).visible:
failures.append("anchoring: max_distance did not hide the box")
_camera.position = Vector3(0.0, 0.0, 10.0)
shrink_with_distance(talk.quick_say_placement)
var scaled := talk.quick_say_placement.resolve(self, box, _villager)
if is_equal_approx(scaled.scale, 1.0):
failures.append("anchoring: distance scaling had no effect")
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 templatepivot of (0.5, 1) means "the bottom-centre of the box lands on the target". Change it to (0.5, 0) and the bubble hangs below instead.
When the NPC walks off screen
Three behaviours, and the right one depends on your game:
when_offscreen | Use it when |
|---|---|
CLAMP | the conversation matters more than the position — the bubble slides along the screen edge |
HIDE | the bubble should disappear with the speaker |
FREE | your camera always frames the speaker anyway |
CLAMP is the default because a conversation the player cannot read is worse than a bubble slightly out of place.
Pointing at a different node
The bubble tracks conversation_target, but an NPC's origin is often at its feet. Add a Marker2D at head height and point the anchor at it:
anchor.follow_target = ^"../HeadMarker"That path is resolved relative to the NPCTalkKit node, so the same anchor resource works on every NPC built the same way — which means you can share one .tres across a dozen villagers.
Making the bubble not look like the default bubble
The tail, shape and colours are separate concerns:
The tail, the colours and the border are all settings on the template — speech_bubble.tres is one starting point, and the Dialogue Boxes manager can duplicate it into an editable local copy.
A panel that follows instead
Nothing stops you. Use classic_panel.tres and give it a follow placement — the template decides what the box is, not where it goes.