Godot UI (UI developer)
Target: Godot 4.7.2.stable, macOS Apple Silicon.
Expert level here means one layout proven, by numbers and images, from a 1080x2400 phone in portrait to a 3840x2160 monitor, in every language, with mouse, pad and screen reader. The stretch transform scales, containers place, one Theme built in code styles, and nothing ships before a layout matrix and a contact sheet say so. If a sibling skill named here is missing from your available skills, ask the user to install it (npx skills add scenario-labs/skills --skill <name>); unattended, proceed from tool schemas and flag the gap.
REQUIRED BACKGROUND: scenario-godot-expert (channels, review loop, 6.3 traps).
Stance (the expert delta)
- Pick the base size for the extremes, not for 1080p. A square base (720x720) with
canvas_items + expand and Handheld Orientation sensor gives one scale for portrait and landscape (docs, Multiple resolutions). Measured: 1.5x on a 1080x2400 phone in both orientations, 3x on 4K. A 1152x648 base gives a 0.9375x canvas on the same phone and three undersized buttons [added, measured]. Godotneers' 1920x1080 base (1_OFJLyqlXI) suits desktop only.
- Containers place, anchors pin. Sizing is a negotiation: the minimum size is the floor, Expand claims leftover space, Fill uses it (Godotneers 1_OFJLyqlXI 00:35:00, Karto 5Hog6a0EYa0 00:14:39). Measured in a 400 px HBox: Expand without Fill leaves the claimed space empty (sibling pushed to x 380); Fill or a stretch ratio without Expand changes nothing; ratios are summed, 1:3 gives 96 and 290 of 386 px (Karto 00:15:45). Anchor presets are relative to the parent;
set_anchors_preset() alone keeps the old offsets (a 50x50 child stays 50x50 on FULL_RECT), so use set_anchors_and_offsets_preset() (Karto 00:05:24, measured). A corner element also needs its grow direction, or longer text grows off screen [added, measured 1462 px wide in a 1152 px canvas].
- The look lives in one Theme resource built in code, with type variations instead of per-node overrides (docs, Theme type variations). StyleBoxes are shared Resources: an override reused on two nodes changes both (Karto 00:19:00).
- Focus is explicit. Nothing has focus at scene start, so
ui_down does nothing until something calls grab_focus() (docs, Controller navigation; measured). Keep a menu stack that disables covered panels and returns focus to the opener (Mostly Mad Productions ecJZipjUj6k 00:08:57).
- The current selection is the highest-contrast element on screen, body text comes first, two or three fonts at most, and the outro is faster than the intro (Rawb Herb, GodotCon 2025, sjmAD1ZLBcE 00:31:42, 00:21:34, 00:55:38).
- Text is keys, never literals. Named placeholders with
format(), tr_n() for plurals, context for identical English, auto translate disabled on dynamic text (docs, Internationalizing games). Fonts get fallbacks, not remaps.
- HUD is passive and signal-driven. Its root and wrappers ignore the mouse; values change on signals, not in
_process (consensus of 1_OFJLyqlXI, Yuw7CK5H8eA, f90ieBOoIYQ). A new string reshapes (200 labels: 1.1 to 1.5 ms of CPU per frame); assigning the identical string is an early-out, so the cost is the changed text, not the assignment [added, measured]. In-place array mutations never call a setter (0 calls after append, 1 after reassigning), so state arrays emit their own signal (NoBSGodot Yuw7CK5H8eA 00:05:06, measured).
- Verify at device sizes headless, then look. Headless,
root.size takes any size and applies the project stretch, so a layout matrix runs in seconds; a windowed capture then renders each device with the notch drawn in [added, measured].
Establish first
| Input | Changes | Default if nobody says |
|---|
| Platforms and orientations | base size, orientation, touch target size | phone portrait + landscape, desktop to 4K: base 720x720, canvas_items, expand, orientation 6 (sensor) |
| Art style | stretch mode and scale mode | vector or high-res: canvas_items + fractional; pixel art: viewport, base 640x360, integer, Exclusive Fullscreen (docs) |
| Input devices | focus work, rebind screen, touch sizes | mouse, keyboard, pad, touch |
| Languages | fonts, RTL, Text Server Data on export | en plus one long (de), one CJK (ja), one RTL (ar) for tests |
| Accessibility bar | screen reader, UI scale slider, contrast | WCAG 4.5:1 body text, UI scale 0.75 to 2.0 via content_scale_factor |
| Renderer | upscaler choice in settings | Forward+ (Jolt kept); Compatibility forces bilinear 3D scaling |
Workflow
1. Project settings. Write the stretch keys explicitly: a hand-written project.godot gets disabled and keep (deltas). gd_env.set_project_setting for display/window/size/viewport_width/height, stretch/mode, stretch/aspect, handheld/orientation, gui/theme/custom. Check that 3d/physics_engine="Jolt Physics" and the renderer survived.
GATE: gd_ui.stretch_math(window, base, ...) for each target gives the expected logical size and scale (port matches the engine on 224 cases, origin and scale included).
2. Theme in code. ui_theme.gd build(overrides) returns a Theme: Button states with a thick accent focus box, Label, panels, sliders, tabs, ProgressBar, variations TitleLabel, HudLabel, HudValue, PrimaryButton, HudPanel, HealthBar, TrailBar. Save it as .tres, set the default font to a SystemFont with CJK and Arabic fallbacks, two or three fonts at most. Text drawn at many scales (HUD numerals at 3x) gets MSDF: gui/theme/default_font_multichannel_signed_distance_field or the font's multichannel_signed_distance_field, both false by default (Karto 5Hog6a0EYa0 00:20:50, measured). Default Theme Scale is read once: a runtime set changed nothing (measured). In the editor a Theme edit needs a scene reload to repaint (Godotneers 00:22:10) and an override is inactive until its checkbox is ticked (Karto 00:17:25); in code only add_theme_*_override counts, and runtime Theme edits propagate at once (measured).
GATE: contrast_report() body pairs >= 4.5:1 (worst measured 4.37 is the disabled state, allowed); check_variations() finds no misspelt variation.
3. Scenes in code. Root Control full rect, a safe_area.gd MarginContainer, then containers. Buttons 88 logical tall at a 720 base (132 px on a phone at 1.5x). Settings rows in ScrollContainers inside a TabContainer. HUD on a CanvasLayer, root MOUSE_FILTER_IGNORE, AspectRatioContainer for the minimap; a round minimap or portrait frame is a clip_children mask (Clip Only on a rounded Panel), but a child with another z_index escapes it (GDQuest W4j4tnQLcTA 00:03:47, measured), so keep masked content at z 0 or use a SubViewport. Toggles read "On"/"Off" as text (CoffeeCrow 9H_fN4OxyIE 00:08:11). safe_area.gd recomputes on size_changed (top margin 104 at 1.5x, 82 at 2x, measured). examples/build_g7.gd builds the whole G7 set.
GATE: ui_audit.gd audit_tree() returns no hud_blocks_input, icon_keep_size, grows_offscreen_*, no_accessible_name, shared_stylebox_override, unknown_variation.
4. Layout matrix headless. gd_ui.layout_matrix(P, scene, targets, min_touch_px=..., safe_insets=..., locale=..., pseudo=True) for 8 targets (phone portrait and landscape, 4:3 tablet, 1366x768, 1080p, 21:9, 4K, 800x600).
GATE: zero offscreen, outside_safe, small_target, text (clipped or overflowing), overlap and collapsed issues for each scene, in en, de and pseudolocalized at 0.4 expansion.
5. Capture matrix windowed, then look. gd_ui.capture_matrix(P, scene, targets, safe_insets=...) renders each target at its physical size with black bars and red notch bands, and builds a contact sheet. Open the sheet.
GATE: image_checks flags none; by eye: selection visible, no clipped glyphs (CJK, Arabic), notch bands over background only, bars and frames intact. Never capture through an embedded Window with content_scale_*: it draws unscaled (P13, measured).
6. Focus and input. gd_ui.focus_walk(P, scene, steps) pushes ui_* actions through push_input and records the focus owner; ui_focus.graph() lists unreachable focusables. Menu stack: open, cancel, focus back on the opener.
GATE: every focusable reachable from the default; first key press from no focus lands on the default; covered panels unreachable.
7. Rebinding. ui_rebind.gd: accept (no echo, no Escape, sticks over 0.5), normalize (physical keycode, device -1, axis sign), report conflicts, swap or refuse, save as ConfigFile, reset from project settings. Show the key with DisplayServer.keyboard_get_label_from_physical() (windowed only): on a French layout physical Q reads "A" (measured). Add pad buttons yourself: in 4.7.2 ui_accept and ui_cancel hold keys only (Enter, Kp Enter, Space; Escape), so add Joypad Button 0 and 1 (Mostly Mad ecJZipjUj6k 00:12:00, measured).
GATE: binding matches on keyboard and on any pad after a save and load round trip.
8. Localization. CSV with a ?plural column, gd_ui.check_csv offline, --import, then gd_ui.register_translations. Run en, fr, de, ja, ar and pseudolocalization; expansion_ratio defaults to 0.0, so set 0.3 to 0.4 and call reload_pseudolocalization() (measured). --language fr is the non-persistent test. Animated text is a RichTextEffect: bbcode var plus _process_custom_fx(char_fx), options from char_fx.env (Cashew OldDew KMqYeRae6rM 00:14:01; measured [lift px=6] read 6.0), installed with install_effect().
GATE: no missing keys per locale; Arabic mirrors (anchors and HBox order); plurals right in en and fr; layout matrix clean.
9. Settings and cost. ui_settings.gd autoload applies window mode, vsync, render scale and upscaler, quality, UI scale, bus volumes, locale; saves and reloads.
GATE: save and load round trip; ui_scale 1.5 turns a 1280x720 canvas into 853x480.
Numbers
| Value | Relative to | Source |
|---|
Base 720x720, canvas_items, expand, orientation sensor | phone portrait and landscape plus desktop | docs, Multiple resolutions |
| Scale 1.5 on 1080x2400 and 2400x1080, 3.0 on 3840x2160, 1.0672 on 1366x768 | base 720x720 | measured [added] |
Base 720x1280 on a 2400x1080 phone: expand 2844x1280 at 0.844x (UI shrinks), keep_width 897 px side bars; keep_height on 1080x2400: 240 px bars | why a square base beats an aspect choice | measured [added] |
Pixel art 640x360 viewport integer; viewport at fractional 1.0672 (1366x768) resamples and blurs text | 720p to 4K | docs, measured |
| Default Theme Scale 2.0 to 3.0 for a 4K base, 1.5 to 2.0 mobile 1080p base; read once (runtime set: no change) | base size | docs, measured |
| Touch target 126 px phone, 96 px tablet (48 dp at about 420 and 320 dpi) | physical pixels | [added] |
| Button 88 logical at base 720 | 132 px on a phone | [added, measured] |
| WCAG 4.5:1 body, 3:1 large text | text vs its background | WCAG 2 [added] |
| Damage trail: one-shot timer 0.4 s, restarted per hit; heal instant | health change | DashNothing f90ieBOoIYQ 00:01:26 |
| Text Server Data ~4 MB | export size, for no-space scripts | docs |
| 200 labels: 1.1 to 1.5 ms new text every frame, 0.17 to 0.23 ms for 10 per frame, under 0.1 ms same text (as idle) | CPU per frame, headless M5 Max | measured [added] |
Quality gates
Measurable: test_ui_live.py U1 to U15 green (compile, stretch parity, CSV, layout matrix, capture, focus, rebind, HUD, localization, engine facts, theme and settings, cost, embedded Window, round-2 layout and theme facts, clip mask and text effect); zero issues in the layout matrix for each scene and locale; audit clean; contrast >= 4.5:1 on body pairs; save and load round trips.
Visual: one contact sheet per scene and locale (menu, HUD, settings ja, menu ar, settings pseudo), opened: focus visible, glyphs complete, nothing under the notch, letterbox where expected, selection still visible in grayscale (Rawb Herb).
Common mistakes
| Mistake | What it looks like | Fix |
|---|
| 1920x1080 or 1152x648 base for a phone game | tiny buttons in portrait (scale 0.9375 at 1080x2400) | square base 720x720 + expand |
| Stretch keys left out of an agent-written project.godot | UI fixed size, black space on 4K | write mode and aspect explicitly |
| Full-rect Control with default STOP filter over the game | clicks never reach _unhandled_input (0 of 1) | MOUSE_FILTER_IGNORE on HUD roots and wrappers |
| Corner preset without grow direction | score text runs off the right edge | grow_horizontal = GROW_DIRECTION_BEGIN |
No grab_focus() on open | pad does nothing | menu stack grabs the default on the first nav event |
TextureRect left on KEEP_SIZE in a container | a 512 px icon makes a 64 px row 614x512; IGNORE_SIZE with no minimum collapses to width 0 | EXPAND_FIT_WIDTH_PROPORTIONAL (64x64 measured) or ignore size + min size, Stretch Keep Aspect Centered (Godotneers 1_OFJLyqlXI 00:10:28) |
| OptionButton items as literal English | Japanese screen shows "Windowed" | items as keys (OPT_WINDOWED) |
| Tabs left at theme default | tabs too small to touch | tab styleboxes with content margins; the matrix checks the TabBar |
| Stored binding with device 0 from a synthesized event | pad 2 or the real keyboard (device 16) do not trigger | normalize to device -1 |
| Pseudolocalization on, expansion untouched or no reload | no padding (default ratio 0.0) | set expansion_ratio, TranslationServer.reload_pseudolocalization() |
| POT generation trusted for custom nodes in old tutorials | strings missing (4.2 bug) | fixed in 4.3 (Cashew OldDew v0tJPsNNOM8 00:08:00, not rerun); CSV keys avoid POT |
| Locale Test set in project settings | ships in French | clear it; use --language fr |
Hiding a container child with visible = false for an effect | siblings jump (relayout) | modulate.a = 0 or offset_transform_scale (MrElipteach pK2vI2-jCH4 00:02:54) |
| Expecting a focus ring after a mouse click | ring only on the next key or pad press | by design: gui/common/show_focus_state_on_pointer_event 1 (text inputs only) |
Handoffs
- In: state autoloads and signals (scenario-godot-architecture), input actions and values (scenario-godot-gameplay), bus names Master, Music, SFX (scenario-godot-audio), icons, panels, fonts (scenario-* skills; nine-patch margins set in code).
- Out: render scale and upscaler contract (scenario-godot-rendering-lighting); Text Server Data, real-device safe area and touch (scenario-godot-performance-export); intro and outro tracks,
offset_transform motion (scenario-godot-animation); test_ui_live.py for CI (scenario-godot-pipeline-automation).
Godot 4.7 notes
offset_transform_* (4.7) moves container children without relayout; offset_transform_visual_only defaults to true, so clicks hit the layout rect (measured). pivot_offset_ratio (4.7) for center pivots; grab_focus(hide_focus) (4.6).
AccessibilityServer (4.7), accessibility_name, FOCUS_ACCESSIBILITY (RichTextLabel default); accessibility_support 0 Auto runs only with an assistive app active.
auto_translate_mode replaced auto_translate; FoldableContainer (4.5), VirtualJoystick, PopupMenu.allow_search (4.7); [img] takes width_unit/height_unit. Upscalers: FSR2=2, MetalFX spatial=3, temporal=4.
push_input(event, true) takes logical coordinates. Godot 3 names (rect_size, add_color_override, margin_*, stretch 2d, process_custom_fx) are gone.
References