Skip to content

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 ​

gd
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
TemplateLook
classic_panelThe default. Dark panel across the bottom, speaker's visual, gold speaker name.
speech_bubbleCream bubble with a tail, no speaker visual, pops in.
terminalSquare corners, green on near-black, no transition.

All three dialogue box templates rendered by TalkKit's runtime

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.

gd
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

Use 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.

gd
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

Three-pane Dialogue Boxes manager with task-oriented properties, live runtime preview, scope and usage

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.

GroupSettings
PanelAppearance (Flat Style, Nine-slice Texture, Image or an existing resource) with its controls, padding, Decorations
Textfont, size, colour, line spacing, minimum size, typing speed
Speaker Nameshown, placement (inline or on the top edge), font, size, colour, Name Box
Dialogue Visualshown, size, side, spacing, texture filter, Frame
Tailshown, look (colour or image on a textured panel), size, overlap
Continue Indicatorshown, glyph or image, idle motion
Transitionsopen, close, line change, duration, curve, easing
AdvancedTheme, 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.

Flat Style, then a nine-slice frame, then layers on top

One conversation, another box ​

Resolution is one chain, most specific first:

line.dialogue_box  →  conversation.default_dialogue_box
gd
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

Two 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.

gd
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

renderer — 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.

gd
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.

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