Unity UI Toolkit game UI
Structure in UXML, look in USS, behaviour in small C# controllers that react to game events. Distilled from a real project (horror-shooter HUD); the troubleshooting list is the part most likely to save time.
1. Before writing code
Ask only for what is missing, in one short batch:
- UI API.
[RequireComponent(typeof(PanelRenderer))]+RegisterUIReloadCallback= PanelRenderer workflow (noUIDocument, norootVisualElement).UIDocument+rootVisualElement= classic. Infer it from pasted code; if unclear, ask for one working controller and its UXML. Never mix the two (causes NullReferenceExceptions). A working file from the user beats your memory of the API. - Genre/theme (with screenshots or palette), platform and input, reference resolution/aspect.
- The game scripts the UI listens to. Ask for them, read the real public events/properties, bind to those. If an event is missing, propose the smallest addition to the game script.
- Reply in the user's language; write code comments in the language the existing scripts use.
2. Style guide first
Consistency is what users notice first. Before building many parts: propose a short guide, build ONE small part, let the user react, and turn their feedback into rules ("no background box", "more compact"). Save it in the project (e.g. Assets/UI/UI_STYLE_GUIDE.md) and tell the user to paste it into future UI requests. Update it when parts or rules change. Contents: design language (tone, containers, density, motion), palette table (text, muted, warning, critical, shadow), typography (sizes per role, shadow rule), layout (zones, margins, Panel Settings), USS naming and state classes, code architecture rules, UXML gotchas, list of existing parts, checklist for a new part.
3. Architecture
- One part = one controller + one UXML + one USS, with one data source. No mega-controller.
- Parts that must sit together share one UXML/flex container (two controllers may share one GameObject/PanelRenderer; each finds its own elements by name).
- Controllers only find elements, set text/values and toggle USS classes. Colours, sizes and animation live in USS.
- PanelRenderer: root arrives via
RegisterUIReloadCallback(OnUIReload)(register in OnEnable, unregister in OnDisable). It fires when UI is ready and on every reload, so ALLQ<T>("name")lookups, caching, event binding and state reset go there. Null-guardUpdate()until elements exist. - Game events can fire before the UI exists: cache latest values in fields, re-apply at the end of
OnUIReload. - Player/game objects may spawn late: serialized ref, else
FindFirstObjectByTypethrottled (~0.5 s), then bind; unsubscribe in OnDisable. - Push, don't poll. Subscribe to events and write only on change. Poll only values with no event (continuous stamina, a reload bool), toggling classes on change. No string building per frame.
- Build repeated elements (pips, segments) in code once; rebuild only when their source changes; clear cached lists on reload.
- Timers/pulses:
element.schedule.Execute(...).Every(ms)created paused, resumed only while needed. One-shot flashes:.StartingIn(ms). - HUD elements:
picking-mode="Ignore"everywhere, no focusable controls. Menus are the exception. - File name = MonoBehaviour class name.
4. Layout (source of most visual bugs)
- Never place related parts with separately guessed pixel offsets: two absolute panels overlapped because
bottomwas estimated blind. Put them in one flex container and position only that container. - One absolutely positioned container per screen zone; children flow with flex and margins.
- Hide with
opacity+ transition when layout must not jump;display:noneonly when space should collapse. - Fixed
widthon numeric labels so 9 to 10 to 100 does not shift neighbours. - Boxes are border-box: segment row width =
count x (width + margin) - margin. Align related widgets by identical left edge and computed width. - Panel Settings: Scale With Screen Size (e.g. 1920x1080, match 0.5); for HUDs turn Clear Color and Clear Depth Stencil OFF. Use the PanelRenderer's Sort Order to layer parts (full-screen vignette below text).
5. USS and state
- State = class toggled from C# (
EnableInClassList); animation = USS transition. Never set colours or animate in code. - Name states by meaning:
--low,--critical,--hidden,--hit,--pulse,--unavailable. Prefer BEM for new parts. - Overriding rules (spent segments, flash) go AFTER the rules they beat; equal specificity, last wins.
- A transition uses the destination state's duration/delay:
.x--hidden { opacity:0; transition-duration:.5s; transition-delay:.9s; }fades out late and in instantly;.x--flash { transition-duration:.05s; }snaps on, fades off at base speed. - Text floating without a background needs
text-shadow. Notext-transform(uppercase in C#); no radial gradients (see vignette). - For re-skinnable UI put colours/sizes in USS variables on a root class (
--c-text, used asvar(--c-text)); check the user's Unity version supports it.
6. Controller skeleton (PanelRenderer)
[RequireComponent(typeof(PanelRenderer))]
public class PartController : MonoBehaviour
{
[SerializeField] private SourceComponent source; // empty = find at runtime
private PanelRenderer _pr; private VisualElement _block; private Label _value;
private bool _bound; private float _v = -1f; private int _shown = -1; private float _nextFind;
private void Awake() => _pr = GetComponent<PanelRenderer>();
private void OnEnable() { _pr.RegisterUIReloadCallback(OnUIReload); TryBind(); }
private void OnDisable() { _pr.UnregisterUIReloadCallback(OnUIReload); Unbind(); _block = null; _value = null; }
private void Update()
{
if (_bound || Time.unscaledTime < _nextFind) return;
_nextFind = Time.unscaledTime + 0.5f; TryBind();
}
private void TryBind()
{
if (_bound) return;
if (source == null) source = FindFirstObjectByType<SourceComponent>();
if (source == null) return;
source.OnChanged += OnChanged; _bound = true;
_v = source.Current; Apply(); // pull current state once
}
private void Unbind() { if (_bound && source != null) source.OnChanged -= OnChanged; _bound = false; }
private void OnUIReload(PanelRenderer r, VisualElement root)
{
_block = root.Q<VisualElement>("part"); _value = root.Q<Label>("part-value");
if (_block == null || _value == null) { Debug.LogError("Part: elements not found in UXML (check Source Asset).", this); _block = null; return; }
_shown = -1; Apply(); // new tree: force rewrite from cache
}
private void OnChanged(float v) { _v = v; Apply(); }
private void Apply()
{
if (_block == null || _v < 0f) return;
int n = Mathf.CeilToInt(_v);
if (n != _shown) { _shown = n; _value.text = n.ToString(); } // write only on change
_block.EnableInClassList("part--low", _v <= 0.3f);
}
}
7. Reusable snippets
- Segments/pips: in
OnUIReload,row.Clear(); list.Clear();then add NVisualElement{pickingMode=Ignore}with classseg; on changelist[i].EnableInClassList("seg--spent", i >= filled). Show at least 1 filled while value > 0:filled = v<=0 ? 0 : Clamp(CeilToInt(v*N-0.001f),1,N). - Pulse:
_pulse = _block.schedule.Execute(() => _block.ToggleInClassList("part--pulse")).Every(400); _pulse.Pause();Resume on entering the state, Pause + remove class on leaving; track a_pulsingbool and reset it on reload. - Flash on event:
var b=_block; b.AddToClassList("part--hit"); b.schedule.Execute(()=>b.RemoveFromClassList("part--hit")).StartingIn(150); - Vignette: generate once in Awake a 128x128
Texture2D(RGBA32, Clamp, Bilinear,HideFlags.HideAndDontSave) where alpha =SmoothStep(0,1,InverseLerp(inner,outer,dist/1.4142))from centre, white RGB; set_el.style.backgroundImage = new StyleBackground(tex)in OnUIReload; Destroy in OnDestroy. USS: full-screen absolute element,opacity:0,-unity-background-scale-mode: stretch-to-fill,-unity-background-image-tint-colorfor the colour, classes--low/--critical/--pulse/--flashsetting opacity (flash last, fast duration). Lower Sort Order than text parts.
8. Genres and platforms
Architecture is identical across genres; the style guide and input model change. Arcade: big chunky numbers, count-up, scale pops, combo glow, punchy 0.08-0.25 s transitions, containers welcome. Retro/pixel: pixel font, integer scaling, Point-filtered textures, sprite 9-slice borders. Mobile: touch targets about 44-48 dp, device safe area as root padding (check the current API for the user's Unity version), no hover states, bigger min font, test on a real device. Console/gamepad: focusable elements with a strong :focus style, explicit initial focus, TV-safe margins (3-5 percent), larger text, glyphs that swap with the active device. VR/world-space: depends on Unity version, look it up first. These platform points are sound general practice but untested in a real project: say so and ask the user to verify on device. Keep input labels (key name or glyph) in data the controller reads, not hard-coded in UXML.
9. Delivering a part
In order: complete UXML, complete USS, complete controller, setup steps (GameObject, PanelRenderer + shared Panel Settings + Source Asset, Sort Order, references to fill, what to see in Play), tuning knobs (which USS value changes size/position/colour/thresholds), and a short style-guide entry. When changing existing files, give whole files if most of the file changes (partial pastes caused real errors).
10. Troubleshooting
| Symptom | Cause / fix |
|---|---|
NullReference on rootVisualElement / GetComponent<UIDocument>() | Code uses UIDocument but object has PanelRenderer. Use RegisterUIReloadCallback, bind in OnUIReload |
| Controller logs "element not found" | Wrong Source Asset, name mismatch with Q<>(), or invalid UXML |
| UI Builder "Failed to open asset" | Invalid UXML (duplicate </ui:UXML>, bad <Style>, placeholder GUID, hidden characters). Fix, then right-click file > Reimport. Read the console message above the generic error |
| Unstyled elements | <Style> missing or wrong path. In UI Builder: StyleSheets > + > Add Existing USS (writes full project://database/...guid=...) |
| Everything vanished after an edit | UXML broke, or Source Asset reference went stale after a rename. Fix, Reimport, reassign. Test with background-color: rgba(255,0,0,.3) on the root |
| Part misplaced or overlapping another | Stale GameObject still drawing old UXML, or separate absolute offsets. Delete old object, merge neighbours into one container |
| Placeholder text in Game view | Edit mode draws the UXML; press Play |
| Script can't be added | File name != class name. Rename inside the Unity Project window so .meta follows |
Not valid fixes: the stylesheet tag is <Style src="..."/> WITHOUT the ui: prefix (<ui:Style> is silently ignored); never invent or leave a placeholder GUID; use the standard header <ui:UXML xmlns:ui="UnityEngine.UIElements" xmlns:uie="UnityEditor.UIElements" editor-extension-mode="False">.
11. Working without seeing the editor
Ask for a screenshot on layout complaints and read it closely; it solved bugs guessing could not. Own mistakes briefly and fix with complete files. For compile errors, ask for the exact text and Unity version rather than guessing again. Do not promise device behaviour or performance numbers; build the structure and ask the user to test.