Dialogue box
A template is everything about a dialogue box: which parts it has, how they are painted, and how it animates. It is one thing you pick, not two.
There used to be two
A presentation chose a scene and a style painted it, each with its own resolution chain doing the same job. They were never two architectures — the two built-in scenes shared one base class and differed only by having a dialogue visual or a tail. So the parts are settings now.
The workflow
Choose a preset → Edit with live preview → Save as Shared…No scene to build, no script to write, no interface to implement.
Three ship with the addon
extends Node
## Docs: /guide/dialogue-box — what the box is and how it looks.
func _ready() -> void:
_build()
#region template
# Three templates ship with the addon. None bundle a font or a texture, so
# they inherit whatever font your project already uses.
func use_a_template(talk: NPCTalkKit) -> void:
# A typed array, because that is what the node carries. Assigning a bare
# `[...]` through an untyped `$NPCTalkKit` fails at run time, not compile.
var boxes: Array[TalkBoxTemplate] = [
preload("res://addons/npc_talkkit/templates/terminal.tres")]
talk.dialogue_boxes = boxes
#endregion
#region share
# A style is an ordinary Resource. Save one as a .tres, assign it to twenty
# NPCs, edit it once, and all twenty change. Godot's Make Unique lets one of
# them diverge. There is no TalkKit-specific sharing mechanism to learn.
func share_one_template(npcs: Array[NPCTalkKit], template: TalkBoxTemplate) -> void:
var boxes: Array[TalkBoxTemplate] = [template]
for npc in npcs:
npc.dialogue_boxes = boxes
#endregion
#region tweak
# Styles hold appearance only — no target, no NodePath, no per-character data.
# That is what makes sharing safe.
func warm_up_the_box(template: TalkBoxTemplate) -> void:
# The Panel's Base Appearance is a plain Godot StyleBoxFlat.
var panel := template.base.style as StyleBoxFlat
panel.bg_color = Color(0.14, 0.09, 0.06, 0.96)
panel.border_color = Color(0.82, 0.58, 0.28)
panel.set_corner_radius_all(18)
template.text_size = 21
template.open_transition = "slide"
template.transition_duration = 0.22
#endregion
#region chain
# Two levels, and only two:
#
# line.dialogue_box → conversation.default_dialogue_box
#
# A conversation says once which box paints it. A line says so only when it is
# the exception — a flashback, an interruption — and the line after it goes
# back to the default without being told to.
func use_another_box_for_one_line(line: TalkLine, flashback: TalkBoxTemplate) -> void:
line.dialogue_box = flashback
func use_another_box_for_a_conversation(conversation: TalkConversation, warning: TalkBoxTemplate) -> void:
conversation.default_dialogue_box = warning
#endregion
#region theme
# Already have a UI theme? Assign it and the box stops overriding fonts, sizes
# and text colours, so your theme wins. The Panel, padding and layers always
# apply: a Theme styles the text, never removes the frame.
func follow_the_project_theme(template: TalkBoxTemplate, theme: Theme) -> void:
template.theme = theme
#endregion
#region nine_slice
# A frame from an asset pack: the Panel's Base Appearance becomes a nine-slice.
# Scale keeps pixel art crisp; padding follows the frame's margins unless set
# to Custom.
func use_a_pixel_frame(template: TalkBoxTemplate, frame: Texture2D) -> void:
template.base.visual_source = TalkBoxLayer.Source.NINE_SLICE
var panel := template.base.style as StyleBoxTexture
panel.texture = frame
panel.set_texture_margin_all(8)
template.base.scale = 3
template.base.filter = CanvasItem.TEXTURE_FILTER_NEAREST
#endregion
#region layers
# Layers decorate the Panel, the Speaker Name and the Dialogue Visual. Each
# draws a native resource: here an AtlasTexture region in a corner, and a
# StyleBoxFlat name box behind the name.
func add_an_ornament_and_a_name_box(template: TalkBoxTemplate, ornaments: Texture2D) -> void:
var gem := AtlasTexture.new()
gem.atlas = ornaments
gem.region = Rect2(0, 0, 16, 16)
var corner := TalkBoxLayer.new()
corner.name = "Top Left Ornament"
corner.position = TalkBoxLayer.Position.TOP_LEFT
corner.depth = TalkBoxLayer.Depth.ABOVE
corner.visual_source = TalkBoxLayer.Source.IMAGE
corner.texture = gem
corner.scale = 2
var name_box := TalkBoxLayer.new()
name_box.name = "Name Box"
name_box.slot = TalkBoxLayer.Slot.SPEAKER_NAME
name_box.depth = TalkBoxLayer.Depth.BEHIND
name_box.visual_source = TalkBoxLayer.Source.FLAT_STYLE
(name_box.style as StyleBoxFlat).bg_color = Color(0.48, 0.18, 0.11)
name_box.padding = Vector4(8, 2, 8, 2)
template.name_placement = TalkBoxTemplate.NamePlacement.TOP_EDGE
template.layers.append(corner)
template.layers.append(name_box)
#endregion
func _build() -> void:
var talk := NPCTalkKit.new()
talk.name = "NPCTalkKit"
add_child(talk)
func _verify() -> Array[String]:
var failures: Array[String] = []
var talk: NPCTalkKit = $NPCTalkKit
use_a_template(talk)
if talk.dialogue_boxes.is_empty():
failures.append("dialogue box: the preset did not load")
elif talk.dialogue_boxes[0].font != null or talk.dialogue_boxes[0].base.style is not StyleBoxFlat:
failures.append("dialogue box: a shipped template must bundle no font and no texture")
var style := _instant_box()
warm_up_the_box(style)
var painted := style.build_panel_style() as StyleBoxFlat
if painted == null or painted.get_corner_radius(CORNER_TOP_LEFT) != 18:
failures.append("dialogue box: the tweaks did not reach the generated StyleBox")
# A theme must actually win, or the escape hatch is decoration.
var label := RichTextLabel.new()
follow_the_project_theme(style, Theme.new())
style.apply_to(PanelContainer.new(), label)
if label.has_theme_font_size_override("normal_font_size"):
failures.append("dialogue box: paint overrides must stand down under a theme")
label.free()
# The resolution chain, end to end.
var conversation := TalkConversation.new()
conversation.conversation_id = &"tale"
var plain := TalkLine.new()
plain.text = "One."
var flashback := TalkLine.new()
flashback.text = "Two."
conversation.lines = [plain, flashback]
conversation.default_dialogue_box = style
talk.conversations = [conversation]
var owned: Array[TalkBoxTemplate] = [style]
talk.dialogue_boxes = owned
var memory := _instant_box()
use_another_box_for_one_line(flashback, memory)
talk.play(&"tale")
if talk._box.effective_template() != style:
failures.append("dialogue box: the conversation default should paint the first line")
talk.advance()
if talk._box.effective_template() != memory:
failures.append("dialogue box: a line override should paint its own line")
talk.stop()
# And the line after an override goes back to the default on its own.
var third := TalkLine.new()
third.text = "Three."
conversation.lines = [plain, flashback, third]
talk.play(&"tale")
talk.advance()
talk.advance()
if talk._box.effective_template() != style:
failures.append("dialogue box: the line after an override must return to the default")
talk.stop()
var warning := _instant_box()
use_another_box_for_a_conversation(conversation, warning)
talk.play(&"tale")
if talk._box.effective_template() != warning:
failures.append("dialogue box: changing the conversation default should repaint it")
talk.stop()
var framed := _instant_box()
var art := ImageTexture.create_from_image(Image.create_empty(24, 24, false, Image.FORMAT_RGBA8))
use_a_pixel_frame(framed, art)
if not framed.drawn_panel_style() is StyleBoxTexture or framed.effective_padding() != Vector4(24, 24, 24, 24):
failures.append("dialogue box: a nine-slice frame should pad by its margins at ×3")
add_an_ornament_and_a_name_box(framed, art)
if framed.layers_for(TalkBoxLayer.Slot.PANEL).size() != 1 or framed.layers_for(TalkBoxLayer.Slot.SPEAKER_NAME).size() != 1:
failures.append("dialogue box: the ornament and the name box should land on their slots")
var one := NPCTalkKit.new()
var two := NPCTalkKit.new()
share_one_template([one, two], style)
if one.dialogue_boxes != two.dialogue_boxes:
failures.append("dialogue box: sharing must hand both NPCs the same resource")
one.free()
two.free()
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| Template | Look |
|---|---|
classic_panel | The default. Dark panel across the bottom, speaker's visual, gold speaker name. |
speech_bubble | Cream bubble with a tail, no speaker visual, pops in. |
terminal | Square corners, green on near-black, no transition. |

None of them bundle a font or a texture, so they inherit whatever font your project already uses.
A panel and a bubble are the same renderer with different settings — one has a dialogue visual and a continue marker, the other a tail.
Zero configuration already looks right
Leave Dialogue Box empty and the Classic Panel is used. You never have to open this page to ship something presentable.
Making it yours
Open the Dialogue Boxes section of the TalkKit workspace. + New starts from a visual preset—Classic Panel, Speech Bubble, Terminal, or Blank Custom—rather than an empty wall of fields. Select a box to edit its task-oriented groups while the right pane renders a live sample.
Why presets come first
Most customization should start from a working look and change incrementally. Blank Custom remains available when you really want bare defaults.
extends Node
## Docs: /guide/dialogue-box — what the box is and how it looks.
func _ready() -> void:
_build()
#region template
# Three templates ship with the addon. None bundle a font or a texture, so
# they inherit whatever font your project already uses.
func use_a_template(talk: NPCTalkKit) -> void:
# A typed array, because that is what the node carries. Assigning a bare
# `[...]` through an untyped `$NPCTalkKit` fails at run time, not compile.
var boxes: Array[TalkBoxTemplate] = [
preload("res://addons/npc_talkkit/templates/terminal.tres")]
talk.dialogue_boxes = boxes
#endregion
#region share
# A style is an ordinary Resource. Save one as a .tres, assign it to twenty
# NPCs, edit it once, and all twenty change. Godot's Make Unique lets one of
# them diverge. There is no TalkKit-specific sharing mechanism to learn.
func share_one_template(npcs: Array[NPCTalkKit], template: TalkBoxTemplate) -> void:
var boxes: Array[TalkBoxTemplate] = [template]
for npc in npcs:
npc.dialogue_boxes = boxes
#endregion
#region tweak
# Styles hold appearance only — no target, no NodePath, no per-character data.
# That is what makes sharing safe.
func warm_up_the_box(template: TalkBoxTemplate) -> void:
# The Panel's Base Appearance is a plain Godot StyleBoxFlat.
var panel := template.base.style as StyleBoxFlat
panel.bg_color = Color(0.14, 0.09, 0.06, 0.96)
panel.border_color = Color(0.82, 0.58, 0.28)
panel.set_corner_radius_all(18)
template.text_size = 21
template.open_transition = "slide"
template.transition_duration = 0.22
#endregion
#region chain
# Two levels, and only two:
#
# line.dialogue_box → conversation.default_dialogue_box
#
# A conversation says once which box paints it. A line says so only when it is
# the exception — a flashback, an interruption — and the line after it goes
# back to the default without being told to.
func use_another_box_for_one_line(line: TalkLine, flashback: TalkBoxTemplate) -> void:
line.dialogue_box = flashback
func use_another_box_for_a_conversation(conversation: TalkConversation, warning: TalkBoxTemplate) -> void:
conversation.default_dialogue_box = warning
#endregion
#region theme
# Already have a UI theme? Assign it and the box stops overriding fonts, sizes
# and text colours, so your theme wins. The Panel, padding and layers always
# apply: a Theme styles the text, never removes the frame.
func follow_the_project_theme(template: TalkBoxTemplate, theme: Theme) -> void:
template.theme = theme
#endregion
#region nine_slice
# A frame from an asset pack: the Panel's Base Appearance becomes a nine-slice.
# Scale keeps pixel art crisp; padding follows the frame's margins unless set
# to Custom.
func use_a_pixel_frame(template: TalkBoxTemplate, frame: Texture2D) -> void:
template.base.visual_source = TalkBoxLayer.Source.NINE_SLICE
var panel := template.base.style as StyleBoxTexture
panel.texture = frame
panel.set_texture_margin_all(8)
template.base.scale = 3
template.base.filter = CanvasItem.TEXTURE_FILTER_NEAREST
#endregion
#region layers
# Layers decorate the Panel, the Speaker Name and the Dialogue Visual. Each
# draws a native resource: here an AtlasTexture region in a corner, and a
# StyleBoxFlat name box behind the name.
func add_an_ornament_and_a_name_box(template: TalkBoxTemplate, ornaments: Texture2D) -> void:
var gem := AtlasTexture.new()
gem.atlas = ornaments
gem.region = Rect2(0, 0, 16, 16)
var corner := TalkBoxLayer.new()
corner.name = "Top Left Ornament"
corner.position = TalkBoxLayer.Position.TOP_LEFT
corner.depth = TalkBoxLayer.Depth.ABOVE
corner.visual_source = TalkBoxLayer.Source.IMAGE
corner.texture = gem
corner.scale = 2
var name_box := TalkBoxLayer.new()
name_box.name = "Name Box"
name_box.slot = TalkBoxLayer.Slot.SPEAKER_NAME
name_box.depth = TalkBoxLayer.Depth.BEHIND
name_box.visual_source = TalkBoxLayer.Source.FLAT_STYLE
(name_box.style as StyleBoxFlat).bg_color = Color(0.48, 0.18, 0.11)
name_box.padding = Vector4(8, 2, 8, 2)
template.name_placement = TalkBoxTemplate.NamePlacement.TOP_EDGE
template.layers.append(corner)
template.layers.append(name_box)
#endregion
func _build() -> void:
var talk := NPCTalkKit.new()
talk.name = "NPCTalkKit"
add_child(talk)
func _verify() -> Array[String]:
var failures: Array[String] = []
var talk: NPCTalkKit = $NPCTalkKit
use_a_template(talk)
if talk.dialogue_boxes.is_empty():
failures.append("dialogue box: the preset did not load")
elif talk.dialogue_boxes[0].font != null or talk.dialogue_boxes[0].base.style is not StyleBoxFlat:
failures.append("dialogue box: a shipped template must bundle no font and no texture")
var style := _instant_box()
warm_up_the_box(style)
var painted := style.build_panel_style() as StyleBoxFlat
if painted == null or painted.get_corner_radius(CORNER_TOP_LEFT) != 18:
failures.append("dialogue box: the tweaks did not reach the generated StyleBox")
# A theme must actually win, or the escape hatch is decoration.
var label := RichTextLabel.new()
follow_the_project_theme(style, Theme.new())
style.apply_to(PanelContainer.new(), label)
if label.has_theme_font_size_override("normal_font_size"):
failures.append("dialogue box: paint overrides must stand down under a theme")
label.free()
# The resolution chain, end to end.
var conversation := TalkConversation.new()
conversation.conversation_id = &"tale"
var plain := TalkLine.new()
plain.text = "One."
var flashback := TalkLine.new()
flashback.text = "Two."
conversation.lines = [plain, flashback]
conversation.default_dialogue_box = style
talk.conversations = [conversation]
var owned: Array[TalkBoxTemplate] = [style]
talk.dialogue_boxes = owned
var memory := _instant_box()
use_another_box_for_one_line(flashback, memory)
talk.play(&"tale")
if talk._box.effective_template() != style:
failures.append("dialogue box: the conversation default should paint the first line")
talk.advance()
if talk._box.effective_template() != memory:
failures.append("dialogue box: a line override should paint its own line")
talk.stop()
# And the line after an override goes back to the default on its own.
var third := TalkLine.new()
third.text = "Three."
conversation.lines = [plain, flashback, third]
talk.play(&"tale")
talk.advance()
talk.advance()
if talk._box.effective_template() != style:
failures.append("dialogue box: the line after an override must return to the default")
talk.stop()
var warning := _instant_box()
use_another_box_for_a_conversation(conversation, warning)
talk.play(&"tale")
if talk._box.effective_template() != warning:
failures.append("dialogue box: changing the conversation default should repaint it")
talk.stop()
var framed := _instant_box()
var art := ImageTexture.create_from_image(Image.create_empty(24, 24, false, Image.FORMAT_RGBA8))
use_a_pixel_frame(framed, art)
if not framed.drawn_panel_style() is StyleBoxTexture or framed.effective_padding() != Vector4(24, 24, 24, 24):
failures.append("dialogue box: a nine-slice frame should pad by its margins at ×3")
add_an_ornament_and_a_name_box(framed, art)
if framed.layers_for(TalkBoxLayer.Slot.PANEL).size() != 1 or framed.layers_for(TalkBoxLayer.Slot.SPEAKER_NAME).size() != 1:
failures.append("dialogue box: the ornament and the name box should land on their slots")
var one := NPCTalkKit.new()
var two := NPCTalkKit.new()
share_one_template([one, two], style)
if one.dialogue_boxes != two.dialogue_boxes:
failures.append("dialogue box: sharing must hand both NPCs the same resource")
one.free()
two.free()
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 templateUse Actions → Duplicate before making a local variant. Save as Shared… writes a .tres and replaces this node's collection entry with that shared resource; Make Unique brings one shared box back into the scene. Scope and usage stay visible beside the editor so the reach of an edit is clear.
extends Node
## Docs: /guide/dialogue-box — what the box is and how it looks.
func _ready() -> void:
_build()
#region template
# Three templates ship with the addon. None bundle a font or a texture, so
# they inherit whatever font your project already uses.
func use_a_template(talk: NPCTalkKit) -> void:
# A typed array, because that is what the node carries. Assigning a bare
# `[...]` through an untyped `$NPCTalkKit` fails at run time, not compile.
var boxes: Array[TalkBoxTemplate] = [
preload("res://addons/npc_talkkit/templates/terminal.tres")]
talk.dialogue_boxes = boxes
#endregion
#region share
# A style is an ordinary Resource. Save one as a .tres, assign it to twenty
# NPCs, edit it once, and all twenty change. Godot's Make Unique lets one of
# them diverge. There is no TalkKit-specific sharing mechanism to learn.
func share_one_template(npcs: Array[NPCTalkKit], template: TalkBoxTemplate) -> void:
var boxes: Array[TalkBoxTemplate] = [template]
for npc in npcs:
npc.dialogue_boxes = boxes
#endregion
#region tweak
# Styles hold appearance only — no target, no NodePath, no per-character data.
# That is what makes sharing safe.
func warm_up_the_box(template: TalkBoxTemplate) -> void:
# The Panel's Base Appearance is a plain Godot StyleBoxFlat.
var panel := template.base.style as StyleBoxFlat
panel.bg_color = Color(0.14, 0.09, 0.06, 0.96)
panel.border_color = Color(0.82, 0.58, 0.28)
panel.set_corner_radius_all(18)
template.text_size = 21
template.open_transition = "slide"
template.transition_duration = 0.22
#endregion
#region chain
# Two levels, and only two:
#
# line.dialogue_box → conversation.default_dialogue_box
#
# A conversation says once which box paints it. A line says so only when it is
# the exception — a flashback, an interruption — and the line after it goes
# back to the default without being told to.
func use_another_box_for_one_line(line: TalkLine, flashback: TalkBoxTemplate) -> void:
line.dialogue_box = flashback
func use_another_box_for_a_conversation(conversation: TalkConversation, warning: TalkBoxTemplate) -> void:
conversation.default_dialogue_box = warning
#endregion
#region theme
# Already have a UI theme? Assign it and the box stops overriding fonts, sizes
# and text colours, so your theme wins. The Panel, padding and layers always
# apply: a Theme styles the text, never removes the frame.
func follow_the_project_theme(template: TalkBoxTemplate, theme: Theme) -> void:
template.theme = theme
#endregion
#region nine_slice
# A frame from an asset pack: the Panel's Base Appearance becomes a nine-slice.
# Scale keeps pixel art crisp; padding follows the frame's margins unless set
# to Custom.
func use_a_pixel_frame(template: TalkBoxTemplate, frame: Texture2D) -> void:
template.base.visual_source = TalkBoxLayer.Source.NINE_SLICE
var panel := template.base.style as StyleBoxTexture
panel.texture = frame
panel.set_texture_margin_all(8)
template.base.scale = 3
template.base.filter = CanvasItem.TEXTURE_FILTER_NEAREST
#endregion
#region layers
# Layers decorate the Panel, the Speaker Name and the Dialogue Visual. Each
# draws a native resource: here an AtlasTexture region in a corner, and a
# StyleBoxFlat name box behind the name.
func add_an_ornament_and_a_name_box(template: TalkBoxTemplate, ornaments: Texture2D) -> void:
var gem := AtlasTexture.new()
gem.atlas = ornaments
gem.region = Rect2(0, 0, 16, 16)
var corner := TalkBoxLayer.new()
corner.name = "Top Left Ornament"
corner.position = TalkBoxLayer.Position.TOP_LEFT
corner.depth = TalkBoxLayer.Depth.ABOVE
corner.visual_source = TalkBoxLayer.Source.IMAGE
corner.texture = gem
corner.scale = 2
var name_box := TalkBoxLayer.new()
name_box.name = "Name Box"
name_box.slot = TalkBoxLayer.Slot.SPEAKER_NAME
name_box.depth = TalkBoxLayer.Depth.BEHIND
name_box.visual_source = TalkBoxLayer.Source.FLAT_STYLE
(name_box.style as StyleBoxFlat).bg_color = Color(0.48, 0.18, 0.11)
name_box.padding = Vector4(8, 2, 8, 2)
template.name_placement = TalkBoxTemplate.NamePlacement.TOP_EDGE
template.layers.append(corner)
template.layers.append(name_box)
#endregion
func _build() -> void:
var talk := NPCTalkKit.new()
talk.name = "NPCTalkKit"
add_child(talk)
func _verify() -> Array[String]:
var failures: Array[String] = []
var talk: NPCTalkKit = $NPCTalkKit
use_a_template(talk)
if talk.dialogue_boxes.is_empty():
failures.append("dialogue box: the preset did not load")
elif talk.dialogue_boxes[0].font != null or talk.dialogue_boxes[0].base.style is not StyleBoxFlat:
failures.append("dialogue box: a shipped template must bundle no font and no texture")
var style := _instant_box()
warm_up_the_box(style)
var painted := style.build_panel_style() as StyleBoxFlat
if painted == null or painted.get_corner_radius(CORNER_TOP_LEFT) != 18:
failures.append("dialogue box: the tweaks did not reach the generated StyleBox")
# A theme must actually win, or the escape hatch is decoration.
var label := RichTextLabel.new()
follow_the_project_theme(style, Theme.new())
style.apply_to(PanelContainer.new(), label)
if label.has_theme_font_size_override("normal_font_size"):
failures.append("dialogue box: paint overrides must stand down under a theme")
label.free()
# The resolution chain, end to end.
var conversation := TalkConversation.new()
conversation.conversation_id = &"tale"
var plain := TalkLine.new()
plain.text = "One."
var flashback := TalkLine.new()
flashback.text = "Two."
conversation.lines = [plain, flashback]
conversation.default_dialogue_box = style
talk.conversations = [conversation]
var owned: Array[TalkBoxTemplate] = [style]
talk.dialogue_boxes = owned
var memory := _instant_box()
use_another_box_for_one_line(flashback, memory)
talk.play(&"tale")
if talk._box.effective_template() != style:
failures.append("dialogue box: the conversation default should paint the first line")
talk.advance()
if talk._box.effective_template() != memory:
failures.append("dialogue box: a line override should paint its own line")
talk.stop()
# And the line after an override goes back to the default on its own.
var third := TalkLine.new()
third.text = "Three."
conversation.lines = [plain, flashback, third]
talk.play(&"tale")
talk.advance()
talk.advance()
if talk._box.effective_template() != style:
failures.append("dialogue box: the line after an override must return to the default")
talk.stop()
var warning := _instant_box()
use_another_box_for_a_conversation(conversation, warning)
talk.play(&"tale")
if talk._box.effective_template() != warning:
failures.append("dialogue box: changing the conversation default should repaint it")
talk.stop()
var framed := _instant_box()
var art := ImageTexture.create_from_image(Image.create_empty(24, 24, false, Image.FORMAT_RGBA8))
use_a_pixel_frame(framed, art)
if not framed.drawn_panel_style() is StyleBoxTexture or framed.effective_padding() != Vector4(24, 24, 24, 24):
failures.append("dialogue box: a nine-slice frame should pad by its margins at ×3")
add_an_ornament_and_a_name_box(framed, art)
if framed.layers_for(TalkBoxLayer.Slot.PANEL).size() != 1 or framed.layers_for(TalkBoxLayer.Slot.SPEAKER_NAME).size() != 1:
failures.append("dialogue box: the ornament and the name box should land on their slots")
var one := NPCTalkKit.new()
var two := NPCTalkKit.new()
share_one_template([one, two], style)
if one.dialogue_boxes != two.dialogue_boxes:
failures.append("dialogue box: sharing must hand both NPCs the same resource")
one.free()
two.free()
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
Your own presets
Actions → Save as Preset… saves a copy of the selected box into the project's preset folder, res://talkkit_presets unless you change Project Settings › npc_talkkit › Dialogue Boxes › Preset Folder. From then on + New lists it under Your presets, next to the shipped ones.
The box you saved from stays as it was: a preset is a starting point that each new box copies, not a resource they share. Saving under the same name replaces the preset.
A preset is an ordinary .tres, so a template downloaded from elsewhere joins the list as soon as it lands in that folder. + New → Show Presets Folder opens it in the FileSystem dock, where you rename or delete presets.
Removing a box
Right-click a box in the list, press Delete, or use Actions → Remove. A box nothing uses goes at once. When conversations or lines still use it, the dialog lists them and asks which box to use instead; they all switch in the same undo step. A shared .tres stays on disk.
What you can change
Settings are grouped by the part of the box they affect, and each part's skin lives in its own group. Dialogue Box Anatomy shows every part on a real box.
| Group | Settings |
|---|---|
| Panel | Appearance (Flat Style, Nine-slice Texture, Image or an existing resource) with its controls, padding, Decorations |
| Text | font, size, colour, line spacing, minimum size, typing speed |
| Speaker Name | shown, placement (inline or on the top edge), font, size, colour, Name Box |
| Dialogue Visual | shown, size, side, spacing, texture filter, Frame |
| Tail | shown, look (colour or image on a textured panel), size, overlap |
| Continue Indicator | shown, glyph or image, idle motion |
| Transitions | open, close, line change, duration, curve, easing |
| Advanced | Theme, custom transition, renderer |
Decorations, Name Box and Frame are Visual Layers: extra images or styles drawn on the Panel, the name or the portrait. Layers (n) → above the settings lists them all.

One conversation, another box
Resolution is one chain, most specific first:
line.dialogue_box → conversation.default_dialogue_boxextends Node
## Docs: /guide/dialogue-box — what the box is and how it looks.
func _ready() -> void:
_build()
#region template
# Three templates ship with the addon. None bundle a font or a texture, so
# they inherit whatever font your project already uses.
func use_a_template(talk: NPCTalkKit) -> void:
# A typed array, because that is what the node carries. Assigning a bare
# `[...]` through an untyped `$NPCTalkKit` fails at run time, not compile.
var boxes: Array[TalkBoxTemplate] = [
preload("res://addons/npc_talkkit/templates/terminal.tres")]
talk.dialogue_boxes = boxes
#endregion
#region share
# A style is an ordinary Resource. Save one as a .tres, assign it to twenty
# NPCs, edit it once, and all twenty change. Godot's Make Unique lets one of
# them diverge. There is no TalkKit-specific sharing mechanism to learn.
func share_one_template(npcs: Array[NPCTalkKit], template: TalkBoxTemplate) -> void:
var boxes: Array[TalkBoxTemplate] = [template]
for npc in npcs:
npc.dialogue_boxes = boxes
#endregion
#region tweak
# Styles hold appearance only — no target, no NodePath, no per-character data.
# That is what makes sharing safe.
func warm_up_the_box(template: TalkBoxTemplate) -> void:
# The Panel's Base Appearance is a plain Godot StyleBoxFlat.
var panel := template.base.style as StyleBoxFlat
panel.bg_color = Color(0.14, 0.09, 0.06, 0.96)
panel.border_color = Color(0.82, 0.58, 0.28)
panel.set_corner_radius_all(18)
template.text_size = 21
template.open_transition = "slide"
template.transition_duration = 0.22
#endregion
#region chain
# Two levels, and only two:
#
# line.dialogue_box → conversation.default_dialogue_box
#
# A conversation says once which box paints it. A line says so only when it is
# the exception — a flashback, an interruption — and the line after it goes
# back to the default without being told to.
func use_another_box_for_one_line(line: TalkLine, flashback: TalkBoxTemplate) -> void:
line.dialogue_box = flashback
func use_another_box_for_a_conversation(conversation: TalkConversation, warning: TalkBoxTemplate) -> void:
conversation.default_dialogue_box = warning
#endregion
#region theme
# Already have a UI theme? Assign it and the box stops overriding fonts, sizes
# and text colours, so your theme wins. The Panel, padding and layers always
# apply: a Theme styles the text, never removes the frame.
func follow_the_project_theme(template: TalkBoxTemplate, theme: Theme) -> void:
template.theme = theme
#endregion
#region nine_slice
# A frame from an asset pack: the Panel's Base Appearance becomes a nine-slice.
# Scale keeps pixel art crisp; padding follows the frame's margins unless set
# to Custom.
func use_a_pixel_frame(template: TalkBoxTemplate, frame: Texture2D) -> void:
template.base.visual_source = TalkBoxLayer.Source.NINE_SLICE
var panel := template.base.style as StyleBoxTexture
panel.texture = frame
panel.set_texture_margin_all(8)
template.base.scale = 3
template.base.filter = CanvasItem.TEXTURE_FILTER_NEAREST
#endregion
#region layers
# Layers decorate the Panel, the Speaker Name and the Dialogue Visual. Each
# draws a native resource: here an AtlasTexture region in a corner, and a
# StyleBoxFlat name box behind the name.
func add_an_ornament_and_a_name_box(template: TalkBoxTemplate, ornaments: Texture2D) -> void:
var gem := AtlasTexture.new()
gem.atlas = ornaments
gem.region = Rect2(0, 0, 16, 16)
var corner := TalkBoxLayer.new()
corner.name = "Top Left Ornament"
corner.position = TalkBoxLayer.Position.TOP_LEFT
corner.depth = TalkBoxLayer.Depth.ABOVE
corner.visual_source = TalkBoxLayer.Source.IMAGE
corner.texture = gem
corner.scale = 2
var name_box := TalkBoxLayer.new()
name_box.name = "Name Box"
name_box.slot = TalkBoxLayer.Slot.SPEAKER_NAME
name_box.depth = TalkBoxLayer.Depth.BEHIND
name_box.visual_source = TalkBoxLayer.Source.FLAT_STYLE
(name_box.style as StyleBoxFlat).bg_color = Color(0.48, 0.18, 0.11)
name_box.padding = Vector4(8, 2, 8, 2)
template.name_placement = TalkBoxTemplate.NamePlacement.TOP_EDGE
template.layers.append(corner)
template.layers.append(name_box)
#endregion
func _build() -> void:
var talk := NPCTalkKit.new()
talk.name = "NPCTalkKit"
add_child(talk)
func _verify() -> Array[String]:
var failures: Array[String] = []
var talk: NPCTalkKit = $NPCTalkKit
use_a_template(talk)
if talk.dialogue_boxes.is_empty():
failures.append("dialogue box: the preset did not load")
elif talk.dialogue_boxes[0].font != null or talk.dialogue_boxes[0].base.style is not StyleBoxFlat:
failures.append("dialogue box: a shipped template must bundle no font and no texture")
var style := _instant_box()
warm_up_the_box(style)
var painted := style.build_panel_style() as StyleBoxFlat
if painted == null or painted.get_corner_radius(CORNER_TOP_LEFT) != 18:
failures.append("dialogue box: the tweaks did not reach the generated StyleBox")
# A theme must actually win, or the escape hatch is decoration.
var label := RichTextLabel.new()
follow_the_project_theme(style, Theme.new())
style.apply_to(PanelContainer.new(), label)
if label.has_theme_font_size_override("normal_font_size"):
failures.append("dialogue box: paint overrides must stand down under a theme")
label.free()
# The resolution chain, end to end.
var conversation := TalkConversation.new()
conversation.conversation_id = &"tale"
var plain := TalkLine.new()
plain.text = "One."
var flashback := TalkLine.new()
flashback.text = "Two."
conversation.lines = [plain, flashback]
conversation.default_dialogue_box = style
talk.conversations = [conversation]
var owned: Array[TalkBoxTemplate] = [style]
talk.dialogue_boxes = owned
var memory := _instant_box()
use_another_box_for_one_line(flashback, memory)
talk.play(&"tale")
if talk._box.effective_template() != style:
failures.append("dialogue box: the conversation default should paint the first line")
talk.advance()
if talk._box.effective_template() != memory:
failures.append("dialogue box: a line override should paint its own line")
talk.stop()
# And the line after an override goes back to the default on its own.
var third := TalkLine.new()
third.text = "Three."
conversation.lines = [plain, flashback, third]
talk.play(&"tale")
talk.advance()
talk.advance()
if talk._box.effective_template() != style:
failures.append("dialogue box: the line after an override must return to the default")
talk.stop()
var warning := _instant_box()
use_another_box_for_a_conversation(conversation, warning)
talk.play(&"tale")
if talk._box.effective_template() != warning:
failures.append("dialogue box: changing the conversation default should repaint it")
talk.stop()
var framed := _instant_box()
var art := ImageTexture.create_from_image(Image.create_empty(24, 24, false, Image.FORMAT_RGBA8))
use_a_pixel_frame(framed, art)
if not framed.drawn_panel_style() is StyleBoxTexture or framed.effective_padding() != Vector4(24, 24, 24, 24):
failures.append("dialogue box: a nine-slice frame should pad by its margins at ×3")
add_an_ornament_and_a_name_box(framed, art)
if framed.layers_for(TalkBoxLayer.Slot.PANEL).size() != 1 or framed.layers_for(TalkBoxLayer.Slot.SPEAKER_NAME).size() != 1:
failures.append("dialogue box: the ornament and the name box should land on their slots")
var one := NPCTalkKit.new()
var two := NPCTalkKit.new()
share_one_template([one, two], style)
if one.dialogue_boxes != two.dialogue_boxes:
failures.append("dialogue box: sharing must hand both NPCs the same resource")
one.free()
two.free()
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 templateTwo levels, and nothing underneath them. A conversation says once which box paints it; a line says so only when it is the exception, and the line after it goes back to the default without being told to.
There is no node-wide box
The node carries a collection of boxes to pick from — not a value that sits beneath every conversation deciding what it forgot to say. That extra tier is how a field ends up looking live while something three steps away quietly outranks it.
A conversation saved before its box was chosen falls back to the shipped Classic Panel so it cannot crash, and the validator reports it. That is a crash guard, not a tier you should author against.
Escape hatches
Existing Godot Resource — set Panel › Appearance to it and assign any StyleBox or Texture2D, such as your UI theme's panel. TalkKit draws it as it is and never modifies it: padding is kept on the box, not in your resource.
theme — already have a UI theme? Assign it and the template stops overriding fonts, sizes and text colours, so your theme wins. The Panel, padding and layers still apply: a Theme styles the text, never removes the frame.
extends Node
## Docs: /guide/dialogue-box — what the box is and how it looks.
func _ready() -> void:
_build()
#region template
# Three templates ship with the addon. None bundle a font or a texture, so
# they inherit whatever font your project already uses.
func use_a_template(talk: NPCTalkKit) -> void:
# A typed array, because that is what the node carries. Assigning a bare
# `[...]` through an untyped `$NPCTalkKit` fails at run time, not compile.
var boxes: Array[TalkBoxTemplate] = [
preload("res://addons/npc_talkkit/templates/terminal.tres")]
talk.dialogue_boxes = boxes
#endregion
#region share
# A style is an ordinary Resource. Save one as a .tres, assign it to twenty
# NPCs, edit it once, and all twenty change. Godot's Make Unique lets one of
# them diverge. There is no TalkKit-specific sharing mechanism to learn.
func share_one_template(npcs: Array[NPCTalkKit], template: TalkBoxTemplate) -> void:
var boxes: Array[TalkBoxTemplate] = [template]
for npc in npcs:
npc.dialogue_boxes = boxes
#endregion
#region tweak
# Styles hold appearance only — no target, no NodePath, no per-character data.
# That is what makes sharing safe.
func warm_up_the_box(template: TalkBoxTemplate) -> void:
# The Panel's Base Appearance is a plain Godot StyleBoxFlat.
var panel := template.base.style as StyleBoxFlat
panel.bg_color = Color(0.14, 0.09, 0.06, 0.96)
panel.border_color = Color(0.82, 0.58, 0.28)
panel.set_corner_radius_all(18)
template.text_size = 21
template.open_transition = "slide"
template.transition_duration = 0.22
#endregion
#region chain
# Two levels, and only two:
#
# line.dialogue_box → conversation.default_dialogue_box
#
# A conversation says once which box paints it. A line says so only when it is
# the exception — a flashback, an interruption — and the line after it goes
# back to the default without being told to.
func use_another_box_for_one_line(line: TalkLine, flashback: TalkBoxTemplate) -> void:
line.dialogue_box = flashback
func use_another_box_for_a_conversation(conversation: TalkConversation, warning: TalkBoxTemplate) -> void:
conversation.default_dialogue_box = warning
#endregion
#region theme
# Already have a UI theme? Assign it and the box stops overriding fonts, sizes
# and text colours, so your theme wins. The Panel, padding and layers always
# apply: a Theme styles the text, never removes the frame.
func follow_the_project_theme(template: TalkBoxTemplate, theme: Theme) -> void:
template.theme = theme
#endregion
#region nine_slice
# A frame from an asset pack: the Panel's Base Appearance becomes a nine-slice.
# Scale keeps pixel art crisp; padding follows the frame's margins unless set
# to Custom.
func use_a_pixel_frame(template: TalkBoxTemplate, frame: Texture2D) -> void:
template.base.visual_source = TalkBoxLayer.Source.NINE_SLICE
var panel := template.base.style as StyleBoxTexture
panel.texture = frame
panel.set_texture_margin_all(8)
template.base.scale = 3
template.base.filter = CanvasItem.TEXTURE_FILTER_NEAREST
#endregion
#region layers
# Layers decorate the Panel, the Speaker Name and the Dialogue Visual. Each
# draws a native resource: here an AtlasTexture region in a corner, and a
# StyleBoxFlat name box behind the name.
func add_an_ornament_and_a_name_box(template: TalkBoxTemplate, ornaments: Texture2D) -> void:
var gem := AtlasTexture.new()
gem.atlas = ornaments
gem.region = Rect2(0, 0, 16, 16)
var corner := TalkBoxLayer.new()
corner.name = "Top Left Ornament"
corner.position = TalkBoxLayer.Position.TOP_LEFT
corner.depth = TalkBoxLayer.Depth.ABOVE
corner.visual_source = TalkBoxLayer.Source.IMAGE
corner.texture = gem
corner.scale = 2
var name_box := TalkBoxLayer.new()
name_box.name = "Name Box"
name_box.slot = TalkBoxLayer.Slot.SPEAKER_NAME
name_box.depth = TalkBoxLayer.Depth.BEHIND
name_box.visual_source = TalkBoxLayer.Source.FLAT_STYLE
(name_box.style as StyleBoxFlat).bg_color = Color(0.48, 0.18, 0.11)
name_box.padding = Vector4(8, 2, 8, 2)
template.name_placement = TalkBoxTemplate.NamePlacement.TOP_EDGE
template.layers.append(corner)
template.layers.append(name_box)
#endregion
func _build() -> void:
var talk := NPCTalkKit.new()
talk.name = "NPCTalkKit"
add_child(talk)
func _verify() -> Array[String]:
var failures: Array[String] = []
var talk: NPCTalkKit = $NPCTalkKit
use_a_template(talk)
if talk.dialogue_boxes.is_empty():
failures.append("dialogue box: the preset did not load")
elif talk.dialogue_boxes[0].font != null or talk.dialogue_boxes[0].base.style is not StyleBoxFlat:
failures.append("dialogue box: a shipped template must bundle no font and no texture")
var style := _instant_box()
warm_up_the_box(style)
var painted := style.build_panel_style() as StyleBoxFlat
if painted == null or painted.get_corner_radius(CORNER_TOP_LEFT) != 18:
failures.append("dialogue box: the tweaks did not reach the generated StyleBox")
# A theme must actually win, or the escape hatch is decoration.
var label := RichTextLabel.new()
follow_the_project_theme(style, Theme.new())
style.apply_to(PanelContainer.new(), label)
if label.has_theme_font_size_override("normal_font_size"):
failures.append("dialogue box: paint overrides must stand down under a theme")
label.free()
# The resolution chain, end to end.
var conversation := TalkConversation.new()
conversation.conversation_id = &"tale"
var plain := TalkLine.new()
plain.text = "One."
var flashback := TalkLine.new()
flashback.text = "Two."
conversation.lines = [plain, flashback]
conversation.default_dialogue_box = style
talk.conversations = [conversation]
var owned: Array[TalkBoxTemplate] = [style]
talk.dialogue_boxes = owned
var memory := _instant_box()
use_another_box_for_one_line(flashback, memory)
talk.play(&"tale")
if talk._box.effective_template() != style:
failures.append("dialogue box: the conversation default should paint the first line")
talk.advance()
if talk._box.effective_template() != memory:
failures.append("dialogue box: a line override should paint its own line")
talk.stop()
# And the line after an override goes back to the default on its own.
var third := TalkLine.new()
third.text = "Three."
conversation.lines = [plain, flashback, third]
talk.play(&"tale")
talk.advance()
talk.advance()
if talk._box.effective_template() != style:
failures.append("dialogue box: the line after an override must return to the default")
talk.stop()
var warning := _instant_box()
use_another_box_for_a_conversation(conversation, warning)
talk.play(&"tale")
if talk._box.effective_template() != warning:
failures.append("dialogue box: changing the conversation default should repaint it")
talk.stop()
var framed := _instant_box()
var art := ImageTexture.create_from_image(Image.create_empty(24, 24, false, Image.FORMAT_RGBA8))
use_a_pixel_frame(framed, art)
if not framed.drawn_panel_style() is StyleBoxTexture or framed.effective_padding() != Vector4(24, 24, 24, 24):
failures.append("dialogue box: a nine-slice frame should pad by its margins at ×3")
add_an_ornament_and_a_name_box(framed, art)
if framed.layers_for(TalkBoxLayer.Slot.PANEL).size() != 1 or framed.layers_for(TalkBoxLayer.Slot.SPEAKER_NAME).size() != 1:
failures.append("dialogue box: the ornament and the name box should land on their slots")
var one := NPCTalkKit.new()
var two := NPCTalkKit.new()
share_one_template([one, two], style)
if one.dialogue_boxes != two.dialogue_boxes:
failures.append("dialogue box: sharing must hand both NPCs the same resource")
one.free()
two.free()
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 templaterenderer — for a box the settings and layers cannot describe: a messenger thread, a cinematic subtitle, a comic layout. See a renderer of your own.
Where it goes is a separate question
Templates do not decide placement. A Classic Panel can follow an NPC's head and a Speech Bubble can pin to a screen corner — see Placement.
The tail is the one part that notices: pinned to the screen it has nothing to point at, so it hides itself.
A renderer of your own
When the settings cannot describe the box you want, point renderer at a scene of yours. Extend TalkBoxRenderer and you inherit the typewriter, voice blips, input handling, styling, layers and anchoring; what is left is three accessors. Layers on a part your renderer does not return are skipped.
extends TalkBoxRenderer
## Docs: /guide/dialogue-box — a renderer of your own, for the rare box the
## template settings cannot describe.
##
## Extending [TalkBoxRenderer] gives you the typewriter, voice blips, input
## handling, styling and anchoring. What is left is telling it which controls to
## drive. The built-in box is never the ceiling.
##
## This file builds its controls in code so it can stand alone in the test
## suite; in a real project they would be nodes in your own .tscn.
var _panel: PanelContainer
var _text: RichTextLabel
var _name: Label
func _ready() -> void:
_build_controls()
super._ready()
#region contract
# Four accessors, and the base class does the rest.
func get_text_label() -> RichTextLabel:
return _text
func get_name_label() -> Label:
return _name
# Required, or anchoring and transitions have nothing to move and go quietly
# inert. Return the control that represents the box itself.
func get_box_control() -> Control:
return _panel
#endregion
func _build_controls() -> void:
_panel = PanelContainer.new()
add_child(_panel)
var column := VBoxContainer.new()
_panel.add_child(column)
_name = Label.new()
column.add_child(_name)
# A RichTextLabel, because a line is rich text and the base class reveals
# it by parsed character — a plain Label would type out the BBCode.
_text = RichTextLabel.new()
_text.bbcode_enabled = true
_text.fit_content = true
_text.scroll_active = false
column.add_child(_text)get_box_control() is not optional
Anchoring and transitions both act on the control it returns. A renderer that does not implement it looks fine and does nothing.
This is the advanced path. Normal use never comes here.