Skip to content

Customise the dialogue box style ​

Four routes, from "change one colour" to "draw the whole thing myself". Start at the top and stop as soon as it looks right.

1. Pick a template ​

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 ship with the addon, each a different kind of box: classic_panel (a panel with a portrait), speech_bubble (a bubble with a tail) and terminal (text only, instant).

None bundle a font or a texture, so they inherit whatever font your project uses. Everything else, from colour to transitions, is yours to change.

2. Customize from what you see ​

Open TalkKit → Dialogue Boxes. Choose a working preset from + New, or select the current box and use Actions → Duplicate before changing it. The right pane is a live runtime preview, so a warmer colour is visible immediately.

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

To reuse it, choose Actions → Save as Shared…. It writes the .tres and replaces the collection entry with the shared resource. Assign that box to other NPCs; edit it once and they all change. Make Unique lets one diverge.

Preset or blank?

Start from a preset for incremental customization. Choose Blank Custom only when you deliberately want the script defaults.

3. A hand-drawn frame ​

Set Panel › Appearance to Nine-slice Texture, choose the image and set its margins. The margins are drawn as guide lines on the image. Scale keeps pixel art crisp, and padding follows the frame's margins unless you choose Custom.

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

Already have a StyleBox, such as your theme's panel? Choose Existing Godot Resource and assign it. TalkKit draws it as it is.

Name boxes, portrait frames and corner ornaments are layers on top of the frame; see Dialogue Box Anatomy.

4. Your project's theme ​

If you already have a Theme for the rest of your UI:

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

When theme is set, the style stops overriding fonts, sizes and text colours, so your theme wins. The Panel, padding, layers and layout still apply: a Theme styles the text and never removes the frame.

If a theme seems ignored

Check for theme_override_* on the control in question. Overrides beat a theme, always. The shipped box has none, specifically so this route works.

Transitions ​

Animation is part of the template, so it travels with the box:

SettingValues
open_transition, close_transition, line_transitionnone, fade, slide, scale_pop
transition_duration, transition_curve, transition_easingGodot Tween settings

For anything else, subclass TalkTransition and assign it to custom_transition.

5. Build the whole box yourself ​

When none of the above is enough, the renderer itself is replaceable with four methods. See a renderer of your own.

Reference ​

Dialogue box · Dialogue Box Anatomy · TalkBoxTemplate API

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