2D UI polish
Comp-faithful UI: a design-token class, drawn controls, the screen-builder traps that silently swallow writes.
How to take a visual design (HTML/CSS comps, Figma frames) to a polished Godot UI that actually matches it — and how to build those screens programmatically without the editor fighting you. Distilled from a shipped-quality game built with godot-mcp itself against pixel comps; every gotcha here was hit for real. Core kit validated headless against the live engine.
Design tokens: one class, transcribed from the comps#
Create a Design class (@tool, RefCounted, all const/static) as the single source of
truth for the look — never hard-code a color or size anywhere else. Discipline that pays:
- Fix the ratio, scale the constant. Author comps at one 16:9 size, run the game at another,
and make every length
comp_px * SCALE. Withstretch/aspect = keep, common resolutions scale exactly; nothing re-lays-out. - Name colors by meaning, not hue:
FELT/RIVAL_FELT,NEG_FACE(“dark = subtracts”),BTN_GHOST_HOVER. A palette that encodes the rules teaches the player through color. - Solve constants, don’t pick them — and write the derivation in the comment: a scrim alpha is “the alpha that takes color A to the comp’s measured B”; an optical nudge is “measured at 1:1 over three font sizes.” The comment is what stops the next session re-guessing.
- Deterministic scruff: hand-authored tilt lists cycled by index (
TILTS[i % n]), never runtimerandf— random re-rolls on every redraw and jitters. - BLEED overscan: if a
Camera2Dleans or shakes, every full-screen rect must overhang the viewport on all sides by the max travel, or impacts expose the clear color at the edges.
Typography facts (all verified live, all silent failures otherwise):
Label.label_settingsdisables the theme for that label entirely —add_theme_*_overridecalls on it are ignored without error. Write size/color into the settings resource.LabelSettingswithshadow_size = 0is the only crisp (unblurred) text drop shadow.- Shared resources are shared: a
LabelSettings/FontVariationpreloaded by N instances is one object — the last writer styles them all.duplicate()a private copy per instance, keeping the inner font shared (shallow copy) to preserve the glyph cache. - Letter-spacing = a
FontVariationwithset_spacing(TextServer.SPACING_GLYPH, em * size), cached per (em, size). Real metrics — not spaces stuffed between characters. - Variable fonts render at their axis defaults (often too light) unless every
FontVariationpins the axes:{ts.name_to_tag("weight"): 700.0}viaTextServerManager.get_primary_interface(). A variation does not inherit axes from the theme. - A
Labelcenters its line box, not its ink. Descender-less text (digits, all-caps) sits visibly low. Fix with a measured nudge: shiftoffset_top/bottombyfont_size * -0.03 + line_spacing * 0.5(measure your own font; center on the face rect only, never face + shadow).
The drawn-control kit: one shape, one feel float#
When comps use flat solids with hard shadows (box-shadow: 0 Npx 0 c), draw the controls
yourself — StyleBoxFlat.shadow_size grows on all four sides and can’t express a directional
hard drop. The whole kit is one primitive: a rounded rect face over an identical rect offset
depth px straight down, drawn in _draw():
var travel := clampf(sink, -depth, depth)if depth - travel > 0.0: draw_style_box(style(drop_color, radius), Rect2(rect.position + Vector2(0, depth), rect.size))draw_style_box(style(face_color, radius), rect.grow_side(SIDE_TOP, -travel).grow_side(SIDE_BOTTOM, travel))sinkis the entire button feel: tween it todepthon press (face bottoms into the shadow), slightly negative on hover (lifts, shadow reads taller), back to 0 on release withTRANS_BACKso it overshoots. One float, one property tween.- Cache
StyleBoxFlats by (color, radius) — allocation per redraw churns; the palette bounds the set. - Extend
BaseButton, notButton—Button’s own StyleBox/text machinery fights a drawn face. Put the label on the face node so press travel moves both with one tween. - A ring/outline = a larger rounded rect drawn behind the face (
rect.grow(w), radius + w) — that’s CSSbox-shadow: 0 0 0 4pxspread. Godot has no dashed border either: stroke the rounded-rect perimeter as a polyline and walk dashes along it — and guard the walk’s minimum step (maxf(step, 0.001)): float phase math at a boundary can yield a step smaller than an ulp, and the loop hangs the editor at 100% CPU.@export_rangeclamps the inspector, not the value. - Layout-safe lift and tilt: write
offset_transform_position/rotation(visual-only), neverposition/rotation— a container undoes those on its next sort, while the offset transform keeps the layout rect intact so rows stay even. Treat the authored value as the rest pose and add your lift on top; overwriting it ratchets@toolscenes downward on reload. - On
NOTIFICATION_RESIZED, setpivot_offset = size * 0.5so pops/squashes punch from center. - Game pieces resolve their own colors. A
Die/card exposes rule state (role,selected,zeroed); aface_colors()ladder maps state → palette in priority order. Screens never tint pieces — per-screen tinting drifts. Bonus: a concealed piece checks concealment first, so a bluff can’t leak through the fill.
Juice grammar: which beat gets which effect#
Scale feedback to the size of the moment (all fire-and-forget; see game-patterns.md for the
tween recipes): entrances grow-in staggered by index (delay = i * 0.05); selection a
small pop (1.18); placement/impact squash + small camera shake + tiny zoom punch;
round/score beats bigger pop (1.35) + stronger shake + harder zoom punch; turn handoff
a few px of camera offset lean toward the actor — felt, not seen. Two live-verified camera
caveats: Camera2D.zoom above 1.0 pushes IN — never below 1.0 if your board is drawn
viewport-sized (the void shows), and a second tween on the same property cancels the one
already running — so “punch then settle” must wait out the hit before issuing the settle, or
the pair collapses to a no-op.
Programmatic screen builders (editor scripts that emit .tscn)#
For comp-faithful screens, a builder script beats forty node add/node set round trips:
express the screen as a flat list of comp rects, run once via editor.run_script
(--allow-unsafe-editor-io since it writes the scene), commit the emitted .tscn, and the
builder has done its job. The traps — each one a silent failure, all hit live:
- Stale editor caches: a long-lived editor caches
PackedScenes/Scripts and a rescan does not evict them. A builder loading a just-edited component gets the OLD version — and a scene whose cached script is gone instances as a bareControlthat swallows every property write. Load withResourceLoader.load(path, type, ResourceLoader.CACHE_MODE_REPLACE_DEEP)and assertget_script() != nullafter instancing. ownerdecides whatPackedScene.pack()keeps. Every node you add must getnode.owner = rootor it is silently dropped from the saved file.- Minimum-size caching outside the tree:
get_combined_minimum_size()refreshes on a deferred update that never runs for an out-of-tree builder — writingsizeclamps to whatever minimum the packed scene saved. Keep components’custom_minimum_sizeat zero in their own scenes; size instances in the builder (setcustom_minimum_size+size, then verify). - Don’t combine
PRESET_FULL_RECTwith an explicitsize— the anchors and the size compound into a rect 2× the board. - After saving,
EditorInterface.reload_scene_from_path(path)so the open editor shows the result.
Checklist#
- All colors/sizes live in
Design; comments carry the derivation of every solved constant. label_settingsset ⇒ theme overrides dead; shared font resources get private duplicates.- Drawn controls: face + hard drop,
sinktweened for press/hover/release,BaseButtonbase. - Lift/tilt through
offset_transform_*; pivot recentered on resize. - Pieces own their colors via a priority ladder; concealment outranks everything.
- Juice scaled to the beat; same-property tweens never stacked.
- Builders:
CACHE_MODE_REPLACE_DEEP,owneron every node, explicit sizing, script asserts.