UI Construction Conventions
Underroot builds almost all of its runtime UI in code — no .tscn scene files, no visual editor layouts. Follow the conventions below and a new node behaves, sizes, and themes like the rest of the game.
Procedural UI in _ready()
With two exceptions (scenes/ui/TitleScreen.tscn and the intro story scene), every UI element is constructed procedurally: the script's _ready() allocates nodes with .new(), wires their styles and signals, and attaches them with add_child(). There is no .tscn for the HUD, the bars, the drawers, or the modals.
The typical shape, seen across scenes/ui/HUD.gd, scenes/ui/BottomBar.gd, scenes/ui/VillageBar.gd, and scenes/ui/BuildMenu.gd:
func _ready() -> void:
_build() # allocate + style all nodes
# connect EventBus / manager signals
EventBus.food_changed.connect(func(v: float) -> void: _update_food(v))
call_deferred("_initial_refresh") # seed values after the tree settles
get_viewport().size_changed.connect(_reposition)
Conventions that recur:
- A private
_build()(or_build_ui()/_build_left_panel()) does all node allocation._ready()stays a short assembly of build call, signal connections, and a deferred first refresh. - Signal wiring goes through the
EventBusautoload, never node-to-node. See EventBus and the Signal Convention. - Initial data population is deferred with
call_deferred("_initial_refresh")so it runs after the tree is fully built and after a continued run has restoredcurrent_run. - Styling is applied per node with
add_theme_stylebox_override(),add_theme_color_override(), andadd_theme_font_size_override(). There is no global theme resource; each script builds its ownStyleBoxFlatobjects inline — but font sizes and shared colour ramps come from theUIThemetokens (next section), not magic numbers.
Because the tree is assembled in code, node references are held in typed member fields (var _panel: PanelContainer, var _food_bar: ProgressBar) rather than looked up by path.
UITheme: the token file
scripts/ui/UITheme.gd (class_name UITheme, static) grew from a tooltip helper into the single source for the UI's type scale and semantic colour ramps (Talismans HUD work). New UI code MUST use the tokens; older panels migrate opportunistically when touched.
Type scale — names, not numbers, at call sites:
| Token | Value | Register |
|---|---|---|
T_CAPTION |
11 |
smallest legal persistent text |
T_BODY |
13 |
labels, gauge numbers |
T_TOAST |
15 |
toast text |
T_TITLE |
16 |
panel titles, section heads |
T_BANNER |
22 |
layer banners, storm toasts |
T_CARVED |
32 |
talisman face numerals (breach ETA, population, depth) |
Semantic ramps — one implementation instead of per-file copies:
UITheme.eta_color(seconds) # supply/breach ETA urgency ramp
UITheme.timer_color(ratio) # bar-fill ramp: green → amber → red as it empties
UITheme.legible(col) # luminance floor for text on dark panels
Talisman visual constants (sizes, offsets, pulse rates) live at the top of their component script or in UITheme, so post-play tuning stays a one-line edit.
HudShell: skin state and geometry
Two HUD skins exist (classic and talisman — see The Talismans HUD). The conventions a UI node must respect:
- Ask
HudShell.style(), never a sibling panel, when behaviour differs per skin; react toEventBus.hud_style_changedif the node outlives a flip. - Bottom chrome geometry comes from the skin-aware
HUD.get_bottom_bar_y()(classic: bottom bar top; talisman: the higher of crest/charm tops). - Threat panel geometry comes from the
maw_bargroup: both MawBar and MawMedallion join it and exposebottom_y(), so consumers (BuildMenu, the tutorial ring) duck-type through the group instead of referencing a class.
CanvasLayer sizing: set .size explicitly
Most top-level UI scripts extends CanvasLayer and position a child panel in screen space from a _reposition() method connected to get_viewport().size_changed. A CanvasLayer child does not resize to fit from custom_minimum_size alone — you must assign .size explicitly.
scenes/ui/VillageBar.gd _reposition() does both:
_panel.reset_size()
_panel.custom_minimum_size = Vector2(0.0, BAR_H)
_panel.size = Vector2(_panel.size.x, BAR_H)
_panel.position = Vector2(0.0, hud_bottom_y - BAR_H)
Setting only custom_minimum_size leaves the panel at its previous height. The explicit .size assignment is what actually applies the new dimensions. scenes/ui/BuildMenu.gd follows the same rule when it pins the drawer:
_panel.custom_minimum_size = Vector2(DRAWER_W, panel_h)
_panel.size = Vector2(DRAWER_W, panel_h)
_panel.position = Vector2(vp.x - maxf(_panel.size.x, DRAWER_W), top_y)
Assigning
custom_minimum_sizewithout also assigning.sizeis the most common CanvasLayer sizing bug — the panel silently keeps its old size.
Content-sized panels must be clamped to the viewport
A panel that sizes to its own content will happily grow past the bottom of the screen. Centring it is not a clamp: maxf((vp.y - panel.size.y) * 0.5, MARGIN) pins the top edge, and everything below the fold simply disappears. The LedgerOverlay shipped that way and a late-game factory pushed its last column off-screen.
The rule: any panel whose height depends on run state must clamp to the live viewport on both axes and scroll its overflow. Put the growing content in a ScrollContainer, leave the header outside it (a pinned header is worth more than three extra rows), then size the scroll region by measuring rather than by guessing at the chrome:
# Pass 1 — measure the panel around the content at its natural size.
var natural := _grid_wrap.get_combined_minimum_size()
_scroll.custom_minimum_size = natural
_panel.reset_size()
var chrome := _panel.size - natural # borders + margins + header, exactly
# Pass 2 — hand the content whatever the screen actually leaves.
var avail := Vector2(minf(vp.x - MARGIN * 2.0, PANEL_MAX_W) - chrome.x,
vp.y - MARGIN * 2.0 - chrome.y)
_scroll.custom_minimum_size = Vector2(minf(natural.x, avail.x), minf(natural.y, avail.y))
_panel.reset_size()
Two pitfalls the two-pass form avoids:
- Do not hardcode the chrome. Deriving it from a measured pass keeps working when a header gains a row or a stylebox margin changes.
- A
ScrollContainerreports a near-zero minimum size on any axis it can scroll, so aPanelContainerabove it collapses unless you drivecustom_minimum_sizeyourself.
Also: pay for the vertical scrollbar's width up front when the content is taller than the space (want_x = natural.x + _scroll.get_v_scroll_bar().get_combined_minimum_size().x), or the bar eats into the content width and induces a spurious horizontal scrollbar.
A "max width" constant must be a real cap. PANEL_MAX_W was originally used only inside the centring maths — a panel wider than it was centred as if it were that wide, and hung off the right edge.
Scrollbar gutters
A scrollbar sits at the right edge of the scroll region, so a fat panel margin parks it in the middle of nowhere while the content still runs right up against it. Invert it: give the panel a small right content margin so the bar rides the border, and give the content its own gutter with a MarginContainer inside the scroll. Apply the same inset to the pinned header so its controls keep their distance from the edge.
Where the skin's scrollbars are deliberately quiet, say so in words — the ledger appends · scroll for more to its header hint when the content is clipped.
PanelContainer shrink after hide: call reset_size()
A PanelContainer sizes to its visible content, but hiding a child (or zeroing its custom_minimum_size) does not shrink the container synchronously — Godot defers the recalculation. Call reset_size() on the container to force it that frame.
scenes/ui/HUD.gd calls it every time the sidebar swaps what it shows:
func _switch_tab(tab: String) -> void:
_active_tab = tab
_left_scroll.visible = tab == "items"
_journal_scroll.visible = tab == "journal"
# ...
_reposition()
_left_panel.reset_size()
_toggle_sidebar() does the same after collapsing the panel to its compact view. Without the reset_size() call the panel would keep the height of whichever tab was previously larger.
scenes/ui/MawBar.gd combines this with immediate child removal. When the expandable column queue collapses, its rows are free()d (synchronous) rather than queue_free()d (deferred to frame-end), so that the following reset_size() measures the panel without the dead rows:
func _refresh_queue() -> void:
for child in _queue_box.get_children():
child.free() # immediate — queue_free would leave rows in the tree at measure time
# ... rebuild rows ...
Its _reposition() then calls _panel.reset_size() before reading _panel.size.x, so the right-edge pin is exact on the same frame.
Centered modals: FULL_RECT CenterContainer
Centering a panel — especially one containing an autowrapping label — is done by nesting it in a CenterContainer anchored to PRESET_FULL_RECT. PRESET_CENTER and reset_size() both misplace the panel and must not be used for this.
scenes/ui/ConfirmModal.gd states the rule directly in its header comment: the panel is "Wrapped in a FULL_RECT CenterContainer — the pattern that actually centers a panel with an autowrapping label (PRESET_CENTER and reset_size both misplace it)."
scenes/ui/SettingsPanel.gd shows the full three-layer idiom:
var root := Control.new()
root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
var dimmer := ColorRect.new() # visual dim, no input
dimmer.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
dimmer.mouse_filter = Control.MOUSE_FILTER_IGNORE
var click_catcher := Control.new() # closes on outside click
click_catcher.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
click_catcher.mouse_filter = Control.MOUSE_FILTER_STOP
var center := CenterContainer.new() # sizes + centers the panel
center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
center.mouse_filter = Control.MOUSE_FILTER_PASS # miss-clicks fall through to click_catcher
The layering matters: a full-rect dimmer for the visual, a full-rect click-catcher that closes the modal on an outside click, and a MOUSE_FILTER_PASS CenterContainer so clicks that miss the panel reach the catcher behind it. The panel itself sets mouse_filter = Control.MOUSE_FILTER_STOP to swallow its own clicks.
Simpler transient popups (the songbook, the challenge-details panel in scenes/ui/BottomBar.gd) use Godot's PopupPanel with popup_centered() instead — appropriate when there is no custom dimmer or outside-click behaviour to manage.
Full-screen overlays size against the live get_viewport_rect().size, never a hardcoded 1280×720 — the stretch aspect is expand (the LedgerOverlay and storm overlays both follow this).
Drawn markers instead of glyphs
Web builds have no OS fonts; the bundled emoji fallback is a hand-subset of about three dozen glyphs (see the cosmetics/fonts notes). Any dingbat outside that set renders as tofu. So decorative markers are drawn with draw_circle / draw_rect / draw_colored_polygon in a _draw() rather than typed as a character — SkillTreeOverlay's node diamonds, stars and crowns, and LineageOverlay's headstones and ritual diamonds all do this. Text inside a drawn control uses draw_string(get_theme_default_font(), …) with a UITheme size token.
Tooltips: _make_custom_tooltip and the UITheme Tip subclasses
Godot draws tooltips in a separate popup whose theme does not inherit from the root Window at runtime, so a global theme cannot style them. The reliable mechanism is overriding _make_custom_tooltip(for_text) to return the literal Control to display. scripts/ui/UITheme.gd centralizes this.
UITheme.build_tooltip(for_text) returns a dark PanelContainer matching the game's panel style. It respects manual \n line breaks (the built-in tooltip label does not auto-wrap) and understands [c=#rrggbb]…[/c] spans for tinting segments such as material names.
Rather than override the method on every node, UITheme provides three ready subclasses:
| Class | Base | Use for |
|---|---|---|
UITheme.TipPanel |
PanelContainer |
a panel whose tooltip_text should render styled |
UITheme.TipButton |
Button |
any styled button with a tooltip |
UITheme.TipControl |
Control |
a bare control (e.g. a hover region) with a tooltip |
Swap the plain node for its Tip* variant and set tooltip_text; the tooltip then renders in the game's dark panel instead of Godot's pale default. scenes/ui/BuildMenu.gd uses UITheme.TipButton.new() for its tab buttons and action buttons; scenes/ui/MawBar.gd uses UITheme.TipPanel for the threat panel and UITheme.TipButton for the chevron and surge-song buttons.
When a node needs a dynamic tooltip — different text per hover position — it overrides _make_custom_tooltip itself and delegates to UITheme.build_tooltip, setting tooltip_text from _gui_input. scenes/ui/DepthRuler.gd does exactly this to describe whichever layer band the cursor is over:
func _make_custom_tooltip(for_text: String) -> Object:
return UITheme.build_tooltip(for_text)
func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseMotion:
tooltip_text = _tooltip_for_local((event as InputEventMouseMotion).position)
A separate hover tooltip pattern exists for cases that need live per-frame data (a status line recomputed at hover time): scenes/ui/VillageBar.gd builds its own PanelContainer with a RichTextLabel, shows it on mouse_entered, and positions it manually above the hovered cell. Use this only when _make_custom_tooltip (which receives a static string) cannot carry the live content.
High-frequency signal rebuild gotcha
Some EventBus signals fire every frame. storage_changed fires continuously while a silo or water tower tops up its bar. A handler that does a full UI rebuild on such a signal will free and recreate its child buttons dozens of times per second, and any click landing between the free and the rebuild is lost.
The rule: on a high-frequency signal, update label text and bar values in place; never rebuild the node structure. scenes/ui/BuildMenu.gd is explicit about it:
# storage_changed fires every frame while a silo/tower tops up the bar — only the
# fill numbers change, never the card structure. Update labels in place; a full
# rebuild here would free+recreate the Build buttons 60×/s and eat every click.
EventBus.storage_changed.connect(func(_sf: float, _sw: float) -> void: _update_storage_status_labels())
_update_storage_status_labels() walks cached label references (_storage_status_lbls) and rewrites only their .text. Structural refreshes (_refresh_storage_cards(), which frees and rebuilds the cards) are reserved for low-frequency signals like recipe_unlocked or tool_unlocked. scenes/ui/BottomBar.gd applies the same split: storage_changed and well_changed update values and flip a row's visible flag, and only a visibility change (a row appearing or disappearing) emits layout_changed — never a value tick. The talisman LedgerOverlay follows the same discipline while open: values refresh in place, and its machines block structurally rebuilds only when grouping changes — never while the cursor is over it, and it re-runs the viewport clamp afterwards because the rebuild can change the column's height.
The same rule reaches dropdowns: the exchange OptionButtons carry live held-counts in their item labels, and both refuse to relabel while get_popup().visible — rewriting a list the player is mid-click on is the same footgun in a different shape.
Key files
scripts/ui/UITheme.gd— type-scale tokens (T_CAPTION…T_CARVED), semantic ramps (eta_color,timer_color,legible), tooltip styling and theTipPanel/TipButton/TipControlsubclasses.scripts/ui/HudShell.gd— HUD skin state; ask it, not a sibling panel.scenes/ui/SettingsPanel.gd— canonical FULL_RECTCenterContainermodal (dimmer, click-catcher, centered panel).scenes/ui/ConfirmModal.gd— minimal centered-modal reference; documents whyPRESET_CENTERfails.scenes/ui/LedgerOverlay.gd— two-pass viewport clamp, scrollbar gutter, in-place refresh.scenes/ui/HUD.gd—reset_size()after tab swaps; procedural sidebar build.scenes/ui/VillageBar.gd— explicit CanvasLayer.sizeassignment in_reposition().scenes/ui/BuildMenu.gd— in-place label updates vs. structural rebuild on frequency-sensitive signals.