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:

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:

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_size without also assigning .size is 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:

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