Game Development with Godot & GDScript Module 11 – UI, menus, accessibility and localization
Themes, fonts and rich text
Style a whole Godot 4.7 UI from one Theme with type variations, choose fonts and sizes that stay readable, and format dialogue with RichTextLabel BBCode.
What you will learn
- Apply one Theme to a whole UI, with type variations for the special cases
- Choose fonts, fallback fonts and sizes that stay readable on every screen
- Format dialogue with RichTextLabel BBCode and escape the text players type
- Build a typewriter reveal that keeps its speed at every frame rate and can be skipped
Before you start
What you’ll learn · Before you start
What you will learn
- Apply one Theme to a whole UI, with type variations for the special cases
- Choose fonts, fallback fonts and sizes that stay readable on every screen
- Format dialogue with RichTextLabel BBCode and escape the text players type
- Build a typewriter reveal that keeps its speed at every frame rate and can be skipped
Before you start
On this page
Thirty buttons that each carry their own colour override need thirty edits for one new palette, and the thirty-first button, added next month, still gets the old colour. A Theme fixes that: one resource that styles every control below the node that holds it, with named exceptions for the controls that must differ.
This page styles a menu from one Theme, checks what the default font can draw and how large its text is on screen, and formats dialogue as rich text with BBCode. The examples ran on Godot 4.7.2 without a window, in a project whose stretch settings are those of a new 4.7 project.
One Theme styles every control below the node that holds it
menu.tscn sets ui_theme.tres on its Menu, a VBoxContainer. The theme gives Button and Label a font colour and
sets a default font size of 18, and it defines two type variation: A named theme type built on a base type, such as DangerButton on Button. A control that sets theme_type_variation looks under the variation first and takes everything the variation leaves out from its base.: TitleLabel, a
Label with size 32, and DangerButton, a Button in red. theme_lookup.gd asks six controls for their colour and size,
and names where each answer came from:
[gd_resource type="Theme" format=3]
[resource]
default_font_size = 18
Button/colors/font_color = Color(0.92, 0.95, 1, 1)
Label/colors/font_color = Color(0.85, 0.88, 0.95, 1)
TitleLabel/base_type = &"Label"
TitleLabel/font_sizes/font_size = 32
DangerButton/base_type = &"Button"
DangerButton/colors/font_color = Color(1, 0.42, 0.36, 1) extends SceneTree
## Opens menu.tscn and asks six controls for two theme items. For each answer, source_of() walks the
## lookup order of the GUI skinning page by hand and names where the value was found.
## Whether the theme sets this item for this type itself (has_font_size() also counts the theme's
## default font size, so the lists are read instead).
func has_item(theme: Theme, kind: StringName, item: StringName, type: StringName) -> bool:
var names := theme.get_color_list(type) if kind == &"color" else theme.get_font_size_list(type)
return names.has(item)
## The types a control looks under: its variation and the variation's bases, then its class and the
## classes it extends.
func types_of(control: Control, theme: Theme) -> Array[StringName]:
var types: Array[StringName] = []
var variation := control.theme_type_variation
while variation != &"":
types.append(variation)
variation = theme.get_type_variation_base(variation)
var type := StringName(control.get_class())
while type != &"" and type != &"Control":
types.append(type)
type = ClassDB.get_parent_class(type)
return types
func source_of(control: Control, item: StringName) -> String:
var kind := &"color" if item == &"font_color" else &"size"
var override := control.has_theme_color_override(item) if kind == &"color" else control.has_theme_font_size_override(item)
if override:
return "its own override"
var node: Node = control
while node is Control:
var theme := (node as Control).theme
if theme != null:
for type in types_of(control, theme):
if has_item(theme, kind, item, type):
return "the theme on %s, type %s" % [node.name, type]
if kind == &"size" and theme.has_default_font_size():
return "the theme on %s, its default size" % node.name
node = node.get_parent()
var project := ThemeDB.get_project_theme()
if project != null:
for type in types_of(control, project):
if has_item(project, kind, item, type):
return "the project theme, type %s" % type
return "the default theme"
func _initialize() -> void:
var scene: Control = load("res://menu.tscn").instantiate()
root.add_child(scene)
await process_frame
for path in ["Menu/Title", "Menu/Play", "Menu/Music", "Menu/Shop", "Menu/Quit", "Credits"]:
var control: Control = scene.get_node(path)
var color := control.get_theme_color(&"font_color")
print("%s, a %s:" % [control.name, control.get_class()])
print(" font_color (%.2f, %.2f, %.2f) from %s" % [color.r, color.g, color.b, source_of(control, &"font_color")])
print(" font_size %d from %s" % [control.get_theme_font_size(&"font_size"), source_of(control, &"font_size")])
quit() from session import files, run
# One Theme on the menu's VBoxContainer, six controls and where each found its colour and its font size.
files("project.godot", "theme/ui_theme.tres=ui_theme.tres", "theme/menu.tscn=menu.tscn", "theme/theme_lookup.gd=theme_lookup.gd")
run("""
godot --headless --no-header --script theme_lookup.gd
""") Output
$ godot --headless --no-header --script theme_lookup.gd Title, a Label: font_color (0.85, 0.88, 0.95) from the theme on Menu, type Label font_size 32 from the theme on Menu, type TitleLabel Play, a Button: font_color (0.92, 0.95, 1.00) from the theme on Menu, type Button font_size 18 from the theme on Menu, its default size Music, a CheckButton: font_color (0.92, 0.95, 1.00) from the theme on Menu, type Button font_size 18 from the theme on Menu, its default size Shop, a Button: font_color (1.00, 0.84, 0.00) from its own override font_size 18 from the theme on Menu, its default size Quit, a Button: font_color (1.00, 0.42, 0.36) from the theme on Menu, type DangerButton font_size 18 from the theme on Menu, its default size Credits, a Button: font_color (0.88, 0.88, 0.88) from the default theme font_size 16 from the default theme
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 theme_session.py
A Theme: A resource that holds theme items, colours, constants, fonts, font sizes, icons and styleboxes, each under a type such as Button. Set on a Control, it styles that control and every control below it. holds theme items, colours, constants, fonts, font sizes, icons and styleboxes, each under a type such as Button. Themes cascade down the tree, so the one on Menu reached all five of its children. The GUI skinning page gives the order in which a control looks for an item, and the run followed it:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Shop. Shop is a Button with font_color set in its own overrides. That is the first place Godot looks, so it gets (1, 0.84, 0) and looks no further.
- Quit, its own theme. Quit has no override and no theme of its own. Its theme_type_variation is DangerButton, so in every theme it asks for DangerButton first, then Button, its base.
- Quit, the parent's theme. The theme set on Menu, its parent, has font_color under DangerButton: (1, 0.42, 0.36). The variation set only that one item, so its font size came from the same theme's default size, 18.
- Music. Music is a CheckButton. The theme has nothing under CheckButton, so Godot tries the classes it extends and finds font_color under Button.
- Credits. Credits sits beside Menu, not inside it, so no theme is above it. The project sets no theme either, and it gets the default theme's font colour, (0.88, 0.88, 0.88), and size 16.
- The last resort. If even the default theme lacked an item, Godot would return a default value for its data type. With a theme on the root of every screen, each control stops at its theme.
Quit and Title set theme_type_variation, so they looked under DangerButton or TitleLabel first and fell back to the
base type for everything the variation leaves out. Credits sits outside Menu and got the default theme’s values. Shop
kept its theme override: A theme item set on one Control itself, such as theme_override_colors/font_color. It wins over every theme and affects only that control, not its children., which wins over every theme.
An override for every control that differs
Overrides style one control each, as Shop’s gold does, so a new palette means finding every one of them. Give a
kind of control a type variation in the theme, set theme_type_variation on each, and keep overrides for true
one-offs.
Checkpoint
Check: Theme lookup, overrides and type variations
Fonts and their sizes are theme items too
A theme also holds a default font and size. Without one, a label gets the font below:
extends SceneTree
## The font a Label gets when no theme sets one: its name, size and line height, a measured text,
## the size it is drawn at on a 1920 x 1080 screen, and five characters it may or may not have.
func _initialize() -> void:
var label := Label.new()
root.add_child(label)
await process_frame
root.size = Vector2i(1920, 1080)
await process_frame
var font := label.get_theme_font(&"font")
var size := label.get_theme_font_size(&"font_size")
print("Default font: %s, size %d, line height %d px" % [font.get_font_name(), size, font.get_height(size)])
var text_size := font.get_string_size("Coins: 120", HORIZONTAL_ALIGNMENT_LEFT, -1, size)
print("\"Coins: 120\" at size %d: %d x %d px" % [size, text_size.x, text_size.y])
var scale := root.get_final_transform().get_scale().y
print("A 1920 x 1080 window scales the %d x %d layout by %.3f: font size %d becomes %.1f px on screen" % [
root.get_visible_rect().size.x, root.get_visible_rect().size.y, scale, size, size * scale])
for ch: String in ["A", "é", "€", "₹", "क"]:
print(" %s U+%04X: %s" % [ch, ch.unicode_at(0), "has it" if font.has_char(ch.unicode_at(0)) else "missing"])
quit() from session import files, run
# The default font: its size, a measured text, its size on a 1080p screen and five characters.
files("project.godot", "fonts/font_facts.gd=font_facts.gd")
run("""
godot --headless --no-header --script font_facts.gd
""") Output
$ godot --headless --no-header --script font_facts.gd Default font: Open Sans SemiBold, size 16, line height 23 px "Coins: 120" at size 16: 79 x 23 px A 1920 x 1080 window scales the 1152 x 648 layout by 1.667: font size 16 becomes 26.7 px on screen A U+0041: has it é U+00E9: has it € U+20AC: has it ₹ U+20B9: missing क U+0915: missing
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 fonts_session.py
Godot draws dynamic fonts, TTF, OTF, WOFF or WOFF2 files, at any size, and the Using Fonts page says sizes are pixels and belong to the theme or the control, not to the font. Sizes are pixels of the layout area: a 1920 × 1080 window drew size 16 at 26.7 px. Two gaps show up at once. Two of the five characters were missing, so text in other scripts needs a fallback font: A font that Godot uses for the characters its main font lacks, asked in order after the main font. It fills gaps, such as another script, and never replaces the main font for characters the main font has., and whether 26.7 px is large enough depends on published minimum text sizes.
Prices in rupees and text in Hindi
The two missing characters were ₹ and the Devanagari letter क. A game that shows prices in rupees, or any text in Hindi, needs a fallback font that has them.
Checkpoint
Check: Fonts and font sizes as theme items
RichTextLabel turns BBCode into formatted text
A RichTextLabel with bbcode_enabled reads tags such as [b] and [color] from its text and draws rich text: bold,
colours, links, images and effects. bbcode_parse.gd gives it one line of gold:
extends SceneTree
## A line of BBCode in a RichTextLabel: what the player sees, how many characters that is, what
## visible_characters reveals, and what a slice of the BBCode string shows instead.
const LINE := "[b]Gold:[/b] [color=yellow]120[/color]"
func _initialize() -> void:
var label := RichTextLabel.new()
label.bbcode_enabled = true
label.custom_minimum_size = Vector2(400, 60)
root.add_child(label)
label.text = LINE
await process_frame
print("text: %s (%d characters)" % [LINE, LINE.length()])
print("shows: %s (get_total_character_count() = %d)" % [label.get_parsed_text(), label.get_total_character_count()])
label.visible_characters = 4
print("visible_characters = 4 shows \"%s\"; visible_ratio is %.3f" % [label.get_parsed_text().left(4), label.visible_ratio])
label.visible_characters = -1
for cut in [8, 17]:
label.text = LINE.left(cut)
await process_frame
print("the first %d characters of the BBCode show \"%s\"" % [cut, label.get_parsed_text()])
quit() from session import files, run
# One line of BBCode: what it shows, its characters, what visible_characters reveals and what a slice shows.
files("project.godot", "rich/bbcode_parse.gd=bbcode_parse.gd")
run("""
godot --headless --no-header --script bbcode_parse.gd
""") Output
$ godot --headless --no-header --script bbcode_parse.gd text: [b]Gold:[/b] [color=yellow]120[/color] (38 characters) shows: Gold: 120 (get_total_character_count() = 9) visible_characters = 4 shows "Gold"; visible_ratio is 0.444 the first 8 characters of the BBCode show "Gold:" the first 17 characters of the BBCode show "Gold: [col"
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 rich_session.py
BBCode: A markup of tags in square brackets, such as [b]bold[/b] or [color=yellow]120[/color], that a RichTextLabel with bbcode_enabled turns into formatted text. of 38 characters showed 9: get_parsed_text() returns what the player sees, and
get_total_character_count() counts it. visible_characters counts shown characters too, so 4 showed “Gold” with the
bold intact. A slice of the BBCode string itself showed “Gold: [col”, half a tag in raw text.
Key idea
Reveal text by raising visible_characters, which counts what the player sees; never cut the BBCode string.
Checkpoint
Check: BBCode, parsed text and visible characters
Fonts that stay readable on every screen and in Hindi Pro: opens with a Pro, Premium or Ultimate pass
A size that reads well on your monitor can be too small on a 4:3 screen or a television, and the default font has no rupee sign. This part measures text on screen against published minimums and adds a fallback font for Devanagari.
In this part:
- Font sizes measured on screen, against published minimums
- A fallback font for the rupee sign and Devanagari
4 runnable examples (GD, Python) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
BBCode that players cannot break, with effects and images Pro: opens with a Pro, Premium or Ultimate pass
Text that players type reaches the screen through the same parser as your dialogue, and every opening bracket in it can start a tag. This part reproduces three broken chat lines, fixes them with one escape, and adds a custom effect and images that follow the font size.
In this part:
- Three chat messages that changed when pasted as typed
- Custom effects, unknown tags and images sized in em
4 runnable examples (GD, Python) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
A dialogue box that types at a steady speed, in any script Pro: opens with a Pro, Premium or Ultimate pass
A typewriter that adds a character on every frame finishes more than twice as fast on a 144 Hz screen, and one that counts code points makes Hindi letters change shape as they appear. This part measures both mistakes and ends with a lab whose checker feeds a box frames of 1/60 s and 1/144 s.
In this part:
- Characters per second, not characters per frame
- Whole characters: why नमस्ते takes three steps, not six
- Lab: a dialogue box that types, finishes and moves on
1 diagram · 5 runnable examples (GD, Python) · about 5 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Practise
These questions cover themes, fonts and BBCode. A session starts with the idea you have answered least well, and every choice explains itself:
Practice that adapts to you
Practise this lesson
9 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up: what is a theme type variation, and when do you use one instead of a theme override?
A type variation is a named theme type with a base type, such as DangerButton based on Button. A control opts in by
setting theme_type_variation, and then looks up each item under the variation first and under its base after it, so
the variation changes only what it defines. An override is set on one control and wins over every theme. Use a
variation for a kind of control that appears in many places, such as every destructive button, and an override for
a true one-off.
-
Mid to senior
Your game's 60 buttons each carry font colour and stylebox overrides set in the inspector, and the art team wants a new palette for the next release. How do you move the project to a Theme without breaking any screen?
-
Fresher to mid
Players report that some names in the chat appear in red, one message showed up as a link, and once the chat box grew very tall. What is happening, and how do you fix it so that it stays fixed?
-
Mid to senior
The Hindi version of your game shows boxes instead of letters, and the shop shows a box instead of the rupee sign, but only in the web build. Every desktop test looked fine. Why, and how do you fix it and keep it fixed?
Key takeaways
- A Theme set on a node styles every control below it; each control looks in its overrides, then in the themes above it, under its type variation and classes, then in the project and default themes.
- Fonts and font sizes are theme items, with sizes in pixels of the layout area that the stretch settings scale.
- RichTextLabel draws BBCode as rich text; reveal it by raising
visible_characters, never by cutting the string.
Terms in this lesson
- BBCode
- A markup of tags in square brackets, such as [b]bold[/b] or [color=yellow]120[/color], that a RichTextLabel with bbcode_enabled turns into formatted text.
- fallback font
- A font that Godot uses for the characters its main font lacks, asked in order after the main font. It fills gaps, such as another script, and never replaces the main font for characters the main font has.
- Theme
- A resource that holds theme items, colours, constants, fonts, font sizes, icons and styleboxes, each under a type such as Button. Set on a Control, it styles that control and every control below it.
- theme override
- A theme item set on one Control itself, such as theme_override_colors/font_color. It wins over every theme and affects only that control, not its children.
- type variation
- A named theme type built on a base type, such as DangerButton on Button. A control that sets theme_type_variation looks under the variation first and takes everything the variation leaves out from its base.
References
- Introduction to GUI skinning (Godot Engine)
- Theme type variations (Godot Engine)
- Using Fonts (Godot Engine)
- BBCode in RichTextLabel (Godot Engine)
- RichTextLabel (class reference) (Godot Engine)
- TextServer (class reference): string_get_character_breaks() and VisibleCharactersBehavior (Godot Engine)
- Upgrading from Godot 4.6 to Godot 4.7: RichTextLabel (Godot Engine)
- Godot 4.7 release notes: font-size aware RichTextLabel images (Godot Engine)
- Xbox Accessibility Guideline 101: text display (Microsoft)
- Noto Sans Devanagari (The Noto Project Authors)
- SIL Open Font License 1.1 (SIL International)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress