feat(skills): ship ue-design-skills bundle, licensing and delivery gate

Phase 0 of the handoff plan, as a marketplace rather than a flat skills/
directory. Content moved out of the LyraResearch archive and depersonalised:
addresses stay in the archive, recipes ship.

- plugins/ue-design-skills: 17 skills, 232 failure-mode entries, each with the
  six required fields; catalog.json as the harness-neutral source of truth and
  .claude-plugin/ as one adapter over it.
- _gate: 16 rules, one poisoned fixture per rule, plus surface coverage so a
  declared file cannot silently miss the line rules.
- ADR-0002 (harness-neutral bundle behind a marketplace) and ADR-0003 (split
  licensing: CC BY-ND 4.0 prose, Apache-2.0 code and metadata).
- LICENSE files at both levels, CONTRIBUTING.md, docs/licensing-options.md as
  the material the licence decision grew from.

Verified: gate.py 0 violations; test_gate.py 16/16 rules redden on their
fixtures with a clean baseline and 2 root files reaching the line rules.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ue-toolchain
2026-09-05 23:48:55 +07:00
parent 9e2194c298
commit dab3f35079
67 changed files with 21630 additions and 0 deletions
@@ -0,0 +1,380 @@
---
name: ue-ui-architecture
description: >-
Design or review scalable Unreal Engine UI: a root layout per local player,
tagged layer stacks, extension points for slot-based widget injection from
feature plugins, input and focus policy per screen, and centrally arbitrated
loading screens. Use when building HUD, menu or modal architecture, adding
mode or plugin UI without base-class dependencies, supporting gamepad or local
multiplayer, or debugging focus, ordering, duplicate-widget and teardown
issues.
---
# UE UI architecture
The invariant:
> UI ownership, placement and content are separate. The shell owns slots and
> layers; features contribute content through handles and contracts.
Measured patterns from a reference product: [patterns](references/patterns.md).
Detection recipes for silent UI failures: [failure modes](references/failure-modes.md).
Related skills: `ue-modular-gameplay`, `ue-input-architecture`,
`ue-gameplay-messaging`, `ue-gameplay-tag-governance`.
Method, not architecture — how to check any claim in this bundle before repeating it: `ue-evidence-discipline`.
---
## 1. One root layout per local player
```text
UI manager subsystem
└─ UI policy
└─ primary layout, one per local player
├─ UI.Layer.Game
├─ UI.Layer.GameMenu
├─ UI.Layer.Menu
└─ UI.Layer.Modal
```
Responsibilities: the **subsystem** is the process-level entry point and owns the
active policy; the **policy** decides which layout class to create, where to
attach it, and how local players behave; the **layout** is a named layer registry
with a push/pop API; **widgets** are content only.
Do not let gameplay systems add widgets to the viewport with a guessed draw
order. A widget that bypasses the layout is invisible to every mechanism the
layout provides — visibility toggles, input suspension, focus restoration.
### Let the base subsystem stand aside
A base UI subsystem should decline to be created when a project subclass exists,
so the two never coexist. The idiom — *do not create me if I have derived
classes* — is worth copying anywhere a plugin offers a replaceable subsystem.
### Local players own their UI
Each local player needs an independent layout, focus state and input routing. One
global HUD root breaks split-screen and player-specific modal stacks, and the
breakage appears only when a second player joins.
---
## 2. Tagged layer stacks
Layers express **modality**, not individual screens:
| Layer | Use |
|---|---|
| Game | HUD and persistent gameplay presentation |
| GameMenu | pause and in-game menus |
| Menu | front-end navigation |
| Modal | confirmations, errors, blockers |
Each layer is an activatable widget stack: screens are pushed by soft class,
activated and deactivated, and popped by lifecycle.
### Rules
- register each layer tag exactly once per layout;
- no screen hard-codes a sibling widget path;
- the modal layer carries the strongest input and focus policy;
- popping restores the previous screen and its focus;
- async load failure reports a reason and clears any loading state;
- **pushing to an unregistered layer tag must not fail silently.** A typo in a
layer tag that returns null with no log is a screen that never appears and
nothing to search for. Recipe: UI-04.
### Layers are not draw-order buckets
If two widgets belong to one HUD composition, use extension slots inside the HUD
layout. Inventing `UI.Layer.HUDLeft` and `UI.Layer.HUDRight` turns a modality
concept into a layout concept and loses both.
---
## 3. Extension points decouple slot owners from contributors
A subsystem holds two maps keyed by tag:
```text
extension point tag → registered points
extension point tag → registered extensions
```
**A point** is owned by a shell widget: its tag, an optional context object, the
data or widget classes it accepts, its tag match mode, and a callback.
**An extension** is owned by a feature: the target tag, an optional context, a
data object or widget class, and a registration handle.
### Registration must be order-independent
Both sides catch up on registration — a new point receives already-registered
matching extensions, and a new extension notifies already-registered matching
points. This is what removes initialization-order coupling between the HUD and
feature activation, and it is the single most valuable property of the pattern.
### Both sides copy before iterating
Callbacks can register or unregister anything, including themselves. Copy the
list before notifying, and define what happens to an entry removed mid-notify.
---
## 4. Contract matching
An extension matches a point when all applicable rules pass:
1. **tag** — exact, or ancestor-ward for partial matching;
2. **context** — same object, or both explicitly global;
3. **class contract** — the data derives from, or implements, an allowed class;
4. **ownership** — local player and world are compatible.
### Context tiers
One slot widget can register points for the global context, for its local player,
and for that player's player state. The same slot tag then serves global HUD
elements and player-specific elements with no cross-player leakage — and
split-screen works without a special case.
### Know which direction partial matching runs
Ascending from the extension's tag to its ancestors means a point tagged `A.B`
with partial matching receives an extension tagged `A.B.C`. **The reverse does
not happen.** Prefer exact tags for production HUD placement, and give every
parent tag used for partial matching a coherent data contract.
### A dead context object is not a global context
If context matching compares pointers and the context is held weakly, an
extension whose context died matches nothing at all — it does not fall back to
global, and it does not clean itself up. It sits in the map until someone
unregisters it. Recipe: UI-06.
---
## 5. The slot widget owns rendering
The slot widget registers and unregisters points, converts extension data into
entry widgets, discovers its context, enforces the class whitelist, and defines
empty-state behaviour.
The feature does not find the slot and does not add children. It registers an
extension and retains the handle. That is dependency inversion:
```text
feature knows: tag + widget class
shell knows: tag + layout container
neither knows the other's concrete type
```
Register in the widget's build step rather than its constructor, and unregister
when Slate resources are released — otherwise a rebuilt widget leaves a
registration behind.
---
## 6. Feature-driven UI has two distinct paths
**Layout requests** push an activatable screen into a layer.
**Element extensions** register a widget into a named slot.
They are different mechanisms with different removal semantics, and putting both
in one feature action is convenient and dangerous. Retain separately: handles to
pushed layouts, an extension handle per slot registration, actor extension
request handles, and per-actor ownership records.
### The two teardowns are not equivalent
Unregistering an extension handle removes the entry widget **synchronously**.
Deactivating a layout **asks** the stack to release it. Do not assume the second
is removal — verify the stack no longer retains or reactivates it. Recipe: UI-05.
Removal must also be keyed to the specific actor that was registered: more than
one HUD actor can be alive on a client at once.
---
## 7. Ordering must be a real contract
If the API exposes a priority, the implementation must sort or insert by it.
**Copying a priority into a request does not create ordering.**
Define: which direction wins; the tie-break for equal values; whether priority is
per point, per tag, or per context; what happens when it changes; and who owns
the sort — the subsystem or the slot widget.
Where ordering is unimplemented, the effective order is registration order, which
in a plugin-based project is feature activation order — non-deterministic from the
designer's point of view, and made worse by any removal that swaps elements.
Recipe: UI-01.
---
## 8. The loading screen is arbitration, not a layer
A loading screen sits above the entire UI, including the root layout. A manager
aggregates reasons from objects implementing a loading-process interface:
```text
ShouldShowLoadingScreen(out Reason) → bool
```
Blockers typically include: experience or mode still loading, map travel, pending
network game, world not begun, seamless travel, missing player controllers, and
explicitly registered async tasks.
Manager behaviour: inspect all providers, show while any blocker exists, retain a
human-readable reason, enforce an optional minimum display time, hide when clear,
and survive world teardown.
### Rules
- providers report need and reason; the manager owns presentation;
- no subsystem toggles the loading widget directly;
- **require a non-empty reason.** The reason string is the only diagnostic that
answers "why is this screen still up";
- stale tasks have a timeout, a cancel path and an owner;
- editor and packaged behaviour are both tested — an input blocker that is
disabled in the editor means the editor is not testing the shipped behaviour.
Recipe: UI-08;
- **measure before copying performance side effects.** Forcing garbage collection
when the screen hides puts a hitch exactly at the moment control returns to the
player. Recipe: UI-07.
---
## 9. Input and focus policy per screen
Each activatable screen declares its input configuration: game-only, menu-only or
both; mouse capture and cursor behaviour; a desired focus target; back-action
handling; whether lower layers receive input.
The framework owns the activation stack, focus restoration, gamepad navigation,
the bound-action bar, and input suspension during transitions.
### Validate what the compiler cannot
- **every activatable screen has a desired focus target.** A screen without one
opens and leaves the gamepad with nowhere to go. If your framework only warns
about this, treat the warning as an error — a screen with no focus is not
shippable. Recipe: UI-09;
- the input configuration matches the layer's role;
- a required back action exists;
- no raw key handling bypasses the UI input system;
- extension point contracts are non-empty where required.
### Transitions and focus interact
A non-zero stack transition duration can break focus hand-off to the incoming
screen on a gamepad. If you disable transitions to fix focus, write down that
this is why — otherwise someone will re-enable them.
---
## 10. The communication boundary
UI may listen to local messages for **transient facts**: an elimination for the
feed, an accolade for a toast, an inventory delta for a notification.
Persistent display reads **observable state**: health, score, inventory,
settings. Do not rebuild state from a stream of messages — a widget created
mid-match will have missed them. See `ue-gameplay-messaging`.
UI sends commands through accountable controllers and services, never as a
broadcast with no owner.
---
## 11. Test matrix
### Extension ordering
Point first then extension; extension first then point; several points with
distinct contexts; global plus local-player plus player-state points;
unregistering an extension while its point lives; destroying a point before the
extension's owner.
### Modular lifecycle
```text
baseline HUD
→ activate feature → exactly one layout, one entry per extension
→ deactivate → back to baseline, verified not assumed
→ reactivate → no duplicates
```
### Local players
One player; split-screen; adding and removing a player at runtime; confirming
player-specific context does not leak; independent modal and focus stacks.
### Input and focus
Keyboard and mouse; gamepad navigation; device hot-swap; back through nested
stacks; modal blocking lower layers; the loading screen capturing input **in a
packaged build**; focus restored after a pop.
---
## 12. Review checklist
- [ ] One root layout per local player.
- [ ] Every screen enters through a named layer.
- [ ] HUD contributions use extension slots, not direct widget references.
- [ ] Registration order is irrelevant in both directions.
- [ ] Context and class contract are explicit at every point.
- [ ] Features retain extension, layout and request handles.
- [ ] Deactivation removes layouts and entries completely, verified.
- [ ] Ordering is deterministic, or documented as unspecified.
- [ ] Pushing to an unknown layer tag is loud.
- [ ] The loading screen is centrally arbitrated and every reason is non-empty.
- [ ] Every activatable widget has a desired focus target.
- [ ] Device presentation stays in the input/UI layer.
- [ ] Persistent display reads state; only transient facts come from messages.
- [ ] Split-screen behaviour is tested, not assumed.
---
## 13. When to simplify
For a small single-player game with one fixed HUD and no modular modes, a root
widget with a few direct child references is enough and this indirection is
overhead.
Introduce layers and extension points when at least one applies: plugins or modes
add UI independently; local multiplayer is required; modal, menu and game input
policies conflict; screens need async loading and a stack; contributors must not
depend on the shell widget class; gamepad focus restoration must be systematic.
The goal is not maximum indirection. It is explicit ownership and replaceable
placement contracts — and the cost of that is a debugging path that crosses
several assets, which you pay every time something does not appear.
---
## Provenance
The findings behind the failure modes come from a line-by-line source audit of
the UI layer of Epic's Lyra Starter Game on Unreal Engine 5.6: the extension
subsystem, the layer and policy plugins, the loading-screen plugin, and the
project's own widgets and feature action.
The framework the project sits on — the activatable widget stack, focus
management, the input subsystem — was **not** in the audited tree, so every
statement about it is inferred from call sites and is marked as such rather than
presented as measured. Widget blueprints are binary and were not read.
Source addresses stay in the research archive that produced this skill; what
ships is the detection recipe. Each entry carries a stable identifier (`UI-01`
and up) that resolves back to the audited location.
## Evidence boundary
One project, one engine version, one workspace. Layer-to-container binding lives
in binary assets, so which layers actually exist in that project is out of scope
rather than concluded. Re-run the recipes against your own tree before acting on
any specific claim.
@@ -0,0 +1,518 @@
# Failure modes: UI architecture
Fourteen ways a layered, slot-based UI puts the wrong thing in the wrong place,
in the wrong order, or nowhere at all — without reporting anything.
The shared property: **this architecture is built out of joins that nothing
checks.** A layer is a tag looked up in a map. A slot is a tag matched against
another tag. A contribution is a class pointer that may or may not be loaded.
Every one of those joins fails by producing nothing, and producing nothing is
also what a correctly configured, currently-empty UI looks like.
A second property worth stating: **UI failures are reported by people, not by
tools.** Nobody writes a test that asserts a widget appeared in a slot. So the
detection recipes below matter more here than in most areas, because they are
frequently the only instrument that exists.
Recipes use `rg` from a project root and were executed against the audited
project while this file was written.
---
## Ordering and placement
### UI-01 - A priority field that never sorts
**Mechanism.** The extension API accepts a priority on every registration
overload, stores it on the entry, and copies it into the request struct handed to
the slot widget. Nothing ever compares two priorities.
**Why it is silent.** An order is produced on every run — registration order —
and it is stable for as long as the set of contributors is stable. The UI looks
deliberate. Nothing is missing; things are merely arranged by accident.
**Why the obvious check misses it.** The value travels through the entire public
surface: eight registration entry points, the entry struct, the request struct.
Any review asking "is priority used?" finds a dozen sites and stops. The right
question is much narrower — *does it appear inside a sort or a comparison?* — and
that is not a question anyone thinks to ask about a field that is so obviously
plumbed.
**Symptom.** Widget order inside a slot changes between runs and between
machines. In a plugin-based project registration order is feature activation
order, so the symptom reads as a race condition and gets investigated in the
loading system.
**Detect.** Count the plumbing, then look for the comparison:
```bash
rg -n "\bPriority\b" --glob "*.cpp" --glob "*.h" <ui-extension-module>/ | wc -l
rg -n "Sort|StableSort|Algo::" <ui-extension-module>/ | wc -l
```
In the audited project the first number was substantial and **the second was
exactly zero** — no sort of any kind exists in the module. A removal that uses
swap-with-last compounds it by reordering the array on every unregistration.
**Guardrail.** An ordering field ships with its comparator in the same change, or
it does not ship. If order genuinely does not matter, do not expose a field that
promises it — the field is a more expensive lie than the missing feature.
---
### UI-02 - A duplicated key in the plugin descriptor silently drops a dependency
**Mechanism.** The descriptor is JSON. It contains the dependency array key twice.
By JSON semantics the second occurrence replaces the first, so half the declared
dependencies do not exist as far as any parser is concerned.
**Why it is silent.** The project builds and runs, because the lost dependency
arrives transitively through the surviving one and is also enabled at the project
level. The declaration was redundant in this configuration — which is exactly why
nobody notices it is gone.
**Why the obvious check misses it.** Reading the file shows both blocks. A human
reader sees two lists and mentally unions them; a parser sees one key assigned
twice and keeps the last. The file *looks* more correct than it is, and no editor
or build step warns about duplicate keys.
**Symptom.** Nothing, until the transitive path disappears — someone disables the
intermediate plugin, or extracts this plugin into another project. Then a
dependency that the descriptor appears to declare is simply not there.
**Detect.** Do not read it. Parse it, and compare against the raw text:
```bash
rg -c '"Plugins"' <plugin>/<Plugin>.uplugin # occurrences in the text
python -c "import json,sys; d=json.load(open(sys.argv[1],encoding='utf-8-sig')); \
print([p.get('Name') for p in d.get('Plugins',[])])" <plugin>/<Plugin>.uplugin
```
A count above one from the first command means the second command is
authoritative. In the audited project the text contained two dependency blocks
and the parser reported exactly one surviving dependency — the other was
discarded. This is the single cheapest recipe in this file and it generalises to
every hand-edited descriptor in a project.
**Guardrail.** Validate every descriptor by parsing it in CI, not by reading it.
Assert that the parsed dependency set equals the intended set.
---
### UI-03 - Unknown layer tag resolves to a silent null
**Mechanism.** Pushing a screen looks the layer tag up in a map. A miss returns
null and the push does not happen.
**Why it is silent.** Returning null is the correct behaviour for a container
that does not exist. The caller usually ignores the return value, because in the
working case it is a widget nobody needs a reference to.
**Why the obvious check misses it.** The call site reads perfectly: a tag, a
class, a push. The tag is a constant that exists in the vocabulary, so it is
spelled correctly — it simply names a layer that this particular root layout did
not register. Layers are registered from a Blueprint layout asset, which no code
search can inspect, so the set of valid tags is not knowable from source.
**Symptom.** A screen never appears. No log, no assertion. Investigation starts
from the screen's own class, which is fine, and from the push call, which is also
fine.
**Detect.** Compare the tags that are pushed against the tags that are
registered, accepting that the second half may require the editor:
```bash
rg -o -N 'PushWidgetToLayerStack\w*\(\s*([A-Za-z_:]+)' -r '$1' --glob "*.cpp" .
rg -n "RegisterLayer" --glob "*.cpp" --glob "*.h" .
rg -o -N 'Tag="(UI\.Layer\.[^"]+)"' -r '$1' Config/ | sort -u
```
The third command gives the declared vocabulary; if a pushed tag is not in it,
the defect is certain. If it is in the vocabulary, the layout asset still has to
be checked in the editor — which is the finding in itself.
**Guardrail.** Make the push assert or log an error on an unknown layer, naming
the tag and listing the registered ones. A lookup miss on a hardcoded key is
never a legitimate runtime state.
---
## Contribution lifetime
### UI-04 - Contribution class resolved with a bare accessor and no fallback
**Mechanism.** The feature action resolves its widget classes with an accessor
that returns whatever is already loaded, relying on asset bundles to have loaded
them, and passes the result on without checking.
**Why it is silent.** When bundles work — which is always, inside the pipeline
that was designed for them — the classes are loaded and everything is correct. A
null is rejected downstream with at most a verbose log.
**Why the obvious check misses it.** The bundle annotation is right there on the
property, so the loading question looks answered. The failure requires the action
to run outside its intended pipeline, which is not a state the code was reviewed
against.
**Symptom.** Widgets silently missing for one feature, in one configuration
— typically a packaged build, or a manual invocation of the action — while
everything works in the editor.
**Detect.** Find bare accessors on soft references in contribution paths:
```bash
rg -n "\.Get\(\)" --glob "*.cpp" . | rg -i "widget|layout|class"
rg -n -B4 "\.Get\(\)" --glob "*.cpp" . | rg "AssetBundles"
```
Then check each hit for a null branch. In the audited project both contribution
loops used the bare accessor; the layout loop tested for null and the slot loop
did not — the null travelled one function further and was rejected there with a
verbose-level log.
**Guardrail.** Either load explicitly with a failure path, or check the result
and log at warning level with the asset name. "The bundle guarantees it" is a
statement about one pipeline, not about the code.
---
### UI-05 - Two teardown paths with different strength
**Mechanism.** The feature action removes its two kinds of contribution
differently: slot extensions are unregistered, which synchronously removes the
entry widget; layouts are asked to deactivate, which is a request the stack may
honour later or differently.
**Why it is silent.** Both calls succeed. Deactivation is a real operation with a
visible effect, so the layout does disappear from view in the common case.
Whether the stack still retains it, and whether it will reactivate when the layer
is next popped to, is not observable from the action.
**Why the obvious check misses it.** Both halves of teardown exist and are
symmetric in shape — a loop over layouts, a loop over handles. Reading the
function gives no reason to suspect that one loop is weaker than the other. The
difference is in the semantics of two APIs in a different plugin.
**Symptom.** A layout that reappears, or that is still in the stack's history
after its owning feature is gone. On reactivation the layer may hold two.
**Detect.** Compare the verbs used in the two removal loops:
```bash
rg -n -A20 "::Remove\w*\(" --glob "*.cpp" . | rg "Deactivate|Unregister|RemoveWidget"
```
A `Deactivate` in one loop and an `Unregister` in the other is the finding.
Confirm at runtime: activate, deactivate, reactivate, and count entries in both
the slot and the layer.
**Guardrail.** Removal means removal. If the stack API distinguishes deactivation
from removal, call the removing one and verify the stack no longer retains the
widget. Assert the count returns to baseline.
---
### UI-06 - Manual garbage-collection reference tracking
**Mechanism.** The object held by a registration is not a reflected property, so
the subsystem walks its own maps in the reference-collection hook to keep those
objects alive.
**Why it is silent.** It works, exactly and completely, for as long as the hook
matches the data structure. There is no degraded mode — either the object is
collected or it is not.
**Why the obvious check misses it.** The hook is correct when written and is far
from the data structure it mirrors. Adding a third map, or a new object-holding
field, is a local change that does not visibly relate to a function elsewhere in
the file. Nothing links them.
**Symptom.** After a refactor, an object is collected while still registered.
Symptoms are arbitrary: a null contribution, a crash on a stale pointer, a
contribution that silently stops matching.
**Detect.** Find manual reference collection and check it covers every container:
```bash
rg -n -A15 "AddReferencedObjects" --glob "*.cpp" .
rg -n "TMap<.*TSharedPtr<|TObjectPtr<\w+> \w+;" --glob "*.h" <ui-extension-module>/
```
Compare the containers walked by the first against the containers declared by the
second. Any container holding objects and not walked is a leak of correctness.
**Guardrail.** Prefer reflected properties. Where manual collection is
unavoidable, put the hook immediately adjacent to the declarations it mirrors and
add a comment at each declaration pointing at it.
---
### UI-07 - Context matched by pointer, held weakly
**Mechanism.** A contribution is scoped to a context object — usually a local
player — and matching compares pointers. The stored reference is weak.
**Why it is silent.** When the context dies, the contribution stops matching
anything, which looks exactly like a contribution that was correctly removed. No
stale widget appears, so nothing draws attention.
**Why the obvious check misses it.** The weak pointer is the *right* choice and
prevents the dangerous failure. What it does not do is remove the record, and
"does this leak memory?" is answered by the weak pointer while "does this leak
records?" is not asked at all.
**Symptom.** Registration maps that grow across sessions with entries that can
never match again. Only visible if something counts them.
**Detect.** Find weak context fields and check for a cleanup path keyed on their
expiry:
```bash
rg -n "TWeakObjectPtr<UObject>\s+ContextObject|ContextObject ==" --glob "*.h" --glob "*.cpp" .
rg -n "IsExplicitlyNull|IsValid\(\)" --glob "*.cpp" <ui-extension-module>/
```
The finding is a comparison path with no eviction path. Confirm by logging map
sizes across a local-player add/remove cycle.
**Guardrail.** Evict on context expiry, or key the map by context so an entire
context can be dropped in one operation.
---
## Diagnosis surface
### UI-08 - Swapped logging branches
**Mechanism.** Two log statements in the two arms of a conditional have their
format strings exchanged: the branch that has a context logs the format without
it, and the branch that has none logs the format with it — printing a safe-name
of null.
**Why it is silent.** It is a log line. It costs nothing at runtime, breaks no
behaviour, and is emitted at verbose level, so it is invisible unless someone
turns the category up.
**Why the obvious check misses it.** Reviewers read log statements for the
message, not for which branch they are in. Both lines are individually plausible.
The defect only exists in the pairing, and the pairing is exactly what the eye
skips.
**Symptom.** When someone finally enables verbose logging — necessarily during a
difficult investigation — the log lies about which registrations have a context.
The cost is paid at the worst possible moment.
**Detect.** Read both arms of every logging conditional together:
```bash
rg -n -B3 -A8 "if\s*\(\s*ContextObject\s*\)" --glob "*.cpp" . | rg "UE_LOG|GetNameSafe"
```
Look for a format string mentioning the variable inside the branch where it is
null. In the audited project this is present and unmistakable once the two lines
are read side by side.
**Guardrail.** Log through one statement with a conditional argument rather than
two statements with divergent formats. One format string cannot disagree with
itself.
---
### UI-09 - Missing focus target caught by a warning, not an error
**Mechanism.** A screen that does not declare a focus target compiles with a
Blueprint warning.
**Why it is silent.** Warnings scroll. A project with any warning debt has
normalised them, and this one appears only when the specific widget is compiled.
**Why the obvious check misses it.** The check exists — someone wrote it
deliberately and worded it well. It is diligence that has been graded as
optional. The failure is not the absence of a check but its severity, which no
review of the checking code would flag.
**Symptom.** On a gamepad, opening the screen leaves focus nowhere: no
navigation, no visible selection, and often no way back. It is one of the most
expensive UI bugs to receive from a player and one of the cheapest to prevent.
**Detect.** Find the validation and check its severity, then find the screens
that would trip it:
```bash
rg -n -B4 -A8 "ValidateCompiledWidgetTree|ValidateCompiledDefaults" --glob "*.cpp" . \
| rg "Warning|Error|Note"
rg -n "GetDesiredFocusTarget|BP_GetDesiredFocusTarget" --glob "*.cpp" --glob "*.h" .
```
A validation that emits a warning for a gamepad-blocking condition is the
finding.
**Guardrail.** Promote it to an error, or gate packaging on it. A check that
cannot fail a build is documentation with extra steps.
---
### UI-10 - The loading screen's only diagnostic is a string nobody must break
**Mechanism.** Arbitration polls a set of providers, each of which returns
whether it needs the screen and why. The accumulated reason is the sole
explanation for a stuck loading screen.
**Why it is silent.** A permanent loading screen *is* the failure, and it is
loud. The silence is in the cause: the screen is a correct response to a provider
that never releases.
**Why the obvious check misses it.** There is no artefact to inspect. The screen
is showing because at least one of a dozen conditions is true, and only one of
them names itself. A team that bypasses the interface with its own flag removes
even that.
**Symptom.** A build that hangs on the loading screen with no information, in a
configuration that cannot be attached to a debugger.
**Detect.** Enumerate the providers and check that nothing shows the screen
outside them:
```bash
rg -n "ShouldShowLoadingScreen" --glob "*.cpp" --glob "*.h" .
rg -n "AddViewportWidgetContent|bShowLoadingScreen|LoadingWidget" --glob "*.cpp" . \
| rg -v "LoadingScreenManager"
```
The second command should be empty. Any hit is a path that shows or hides the
screen without contributing a reason, and it is the one you will be unable to
diagnose. In the audited project the arbitration enforced a non-empty reason with
an assertion — a design worth copying exactly.
**Guardrail.** One owner presents; everyone else reports need plus reason.
Require the reason with an assertion, and expose the current reason to a console
command.
---
### UI-11 - Forced collection at the moment control returns to the player
**Mechanism.** Hiding the loading screen forces a full garbage collection.
**Why it is silent.** It is deliberate, it is defensible, and it is not a bug. The
cost lands as a hitch precisely when the screen disappears, which is a moment the
player already expects to be uneven.
**Why the obvious check misses it.** Nobody profiles the loading screen. It is
infrastructure that ran before the thing you are measuring. The hitch is
attributed to the level, to shader compilation, or to streaming.
**Symptom.** A consistent stall at the first frame of gameplay that resists
explanation from gameplay profiling.
**Detect.** Find global side effects in the hide path:
```bash
rg -n -B6 -A6 "ForceGarbageCollection|SetBatchMode|SuspendHeartBeat|bDisableWorldRendering" \
--glob "*.cpp" .
```
Every hit is a process-wide effect owned by the loading screen. Each may be
correct; all of them are worth knowing about before copying the subsystem.
**Guardrail.** Measure before adopting. If a forced collection is wanted, do it
while the screen is still up, not as it comes down.
---
### UI-12 - Behaviour that differs in the editor by design
**Mechanism.** The loading screen's input blocker declines to consume input when
running under the editor.
**Why it is silent.** It makes editor iteration bearable, which is why it was
written. The divergence is invisible because the editor is where everyone looks.
**Why the obvious check misses it.** Testing happens in the configuration that
has the exception. Confirming the difference requires running a packaged build
specifically to compare input behaviour during loading, which is not on anyone's
list.
**Symptom.** Input handled during loading behaves differently in a packaged
build. Bugs that only reproduce for QA, in a build the developer cannot iterate
on.
**Detect.** Find editor-conditional behaviour in shared runtime paths:
```bash
rg -n "GIsEditor|WITH_EDITOR" --glob "*.cpp" <loading-and-ui-modules>/ \
| rg -v "^.*Editor.*\.cpp"
```
Each hit is a place where the editor is not representative. List them and decide
which matter; the point is to hold the list, not to remove it.
**Guardrail.** Keep a written list of deliberate editor divergences in the UI and
loading paths, and test each one in a packaged build at least once per milestone.
---
## Structure
### UI-13 - Layers used as z-order buckets
**Mechanism.** New layers are added to place widgets relative to each other,
rather than to express modality.
**Why it is silent.** It works. A new layer is one registration and one tag, and
the widget appears where intended.
**Why the obvious check misses it.** Each addition is individually reasonable and
locally minimal. The cost is structural and accrues over releases: input and
focus policy is per layer, so every added layer multiplies the modality matrix
that must be reasoned about and tested.
**Symptom.** Focus and input behaviour that nobody can predict, because there are
nine layers and the policy interaction between them was never designed.
**Detect.** Count layers against modalities:
```bash
rg -o -N 'Tag="(UI\.Layer\.[^"]+)"' -r '$1' Config/ Plugins/*/Config/ | sort -u
```
More than a handful, or names that describe position rather than modality, is the
finding. In the audited project there were exactly four, named for modality.
**Guardrail.** Layers express modality: gameplay, game menu, menu, modal. Relative
placement within one modality belongs to slots inside a layout, not to new layers.
---
### UI-14 - The composition is knowable only from binary assets
**Mechanism.** Layers are registered, and slots are placed, from widget assets.
Source contains the vocabulary and the API but not the arrangement.
**Why it is silent.** It is the intended design and its benefit is real: the
shell's layout is authored by designers without engineering involvement.
**Why the obvious check misses it.** Reading all the source and finding no
arrangement feels like an incomplete search rather than a property of the system.
Time is lost widening greps that cannot succeed.
**Symptom.** "Which layers exist?" and "which slots does the HUD have?" cannot be
answered from a repository checkout. Onboarding, audits and automated validation
all stop at this boundary.
**Detect.** Get what source can give, and mark the rest as open:
```bash
rg -o -N 'Tag="(UI\.(Layer|Slot)\.[^"]+)"' -r '$1' Config/ Plugins/*/Config/ | sort -u
rg -n "RegisterLayer|ExtensionPointTag" --glob "*.cpp" --glob "*.h" .
```
The first command yields the declared vocabulary — the set of names that *may*
exist. The mapping from those names to actual containers requires the editor.
**Record that as an open question rather than concluding from the empty result**;
this is the same rule as any other empty search, and it is the honest form of the
answer.
**Guardrail.** Emit the registered layer and slot inventory to the log at
startup, or to a console command. One dump converts a permanently unanswerable
question into a cheap one.
@@ -0,0 +1,331 @@
# UI architecture patterns: a worked analysis
This is the shell-and-contributor model applied end to end in one shipped
product, with the parts worth copying separated from the parts worth knowing
about.
Individual defects live in [failure modes](failure-modes.md), one entry each with
a detection recipe. This file is about the shape: what each layer owns, why the
seams are where they are, and what it costs.
---
## 1. Three things that are usually conflated
Most UI trouble in a modular project comes from treating these as one concern:
| Concern | Owner | Changes when |
|---|---|---|
| **Modality** — does this block input, capture focus, dismiss on back | the layer stack | a screen opens or closes |
| **Placement** — where on screen this element sits | the slot inside a layout | the layout is redesigned |
| **Content** — what the element is | the contributor | a feature ships |
A single root widget with direct child references collapses all three into one
asset. That asset then belongs to everyone, which means it belongs to nobody, and
every feature branch touches it.
The split that works: **the shell owns modality and placement; features own
content and reach the shell only through tags.**
---
## 2. Layers: modality, not z-order
Four layers cover almost every product:
```text
UI.Layer.Game HUD and persistent gameplay presentation
UI.Layer.GameMenu pause and in-game menus
UI.Layer.Menu front-end navigation
UI.Layer.Modal confirmations, errors, blockers
```
Each is an activatable widget stack, not a canvas slot. Pushing a screen
activates it and deactivates the one below; popping restores the previous
activation and its focus.
**The rule that keeps this honest:** if you are tempted to add
`UI.Layer.HUDLeft`, you are using layers as z-order buckets. Two widgets in one
HUD composition belong in extension slots inside the HUD layout, not in separate
layers. Layers are for things that change what input means.
### The root layout is per local player
One root layout per local player, each with its own layer map, its own focus
state and its own modal stack. A single global HUD root cannot support
split-screen and cannot give two players independent confirmation dialogs. This
is cheap to build in from the start and expensive to retrofit.
### Registration comes from data, not from code
Layers register themselves by tag from the layout asset rather than being
hardcoded. That keeps the shell designer-editable — and it means no C++ search
can tell you which layers actually exist. Write them down somewhere a human
reads, because the tag list in configuration is the closest thing to a registry
you will have.
---
## 3. Extension points: dependency inversion by tag
The subsystem holds two maps:
```text
ExtensionPointTag → registered points (slots that exist)
ExtensionPointTag → registered extensions (content wanting a slot)
```
A **point** is owned by a shell widget: a tag, an optional context object, a
whitelist of accepted classes, a match mode, and a callback. An **extension** is
owned by a feature: a target tag, an optional context, a widget class or data
object, and a handle it must keep.
Neither side names the other. The only shared vocabulary is the tag.
### Registration must be order-independent
This is the property that makes the pattern usable, and it costs two lines:
- a newly registered point immediately receives all matching extensions that
already exist;
- a newly registered extension immediately notifies all matching points.
Without both directions, whether a feature's widget appears depends on whether
the feature activated before or after the HUD was constructed — which in a
plugin-based project is not deterministic.
### The contract has three clauses
An extension matches a point when all of these hold:
1. **tag** — exact, or the point matched a parent of the extension's tag;
2. **context** — both explicitly global, or the same object;
3. **class** — the data derives from, or implements, something on the point's
whitelist.
The class check has one subtlety worth copying: if the registered data *is* a
class object, use it directly; otherwise use the class of the instance. That one
branch is what lets "a widget class" and "a data object" travel through the same
registry.
### Context tiers are what make split-screen work
A slot widget registers the same tag three times: once globally, once for its
local player, once for that player's player state (subscribing for the player
state if it has not arrived yet, and firing immediately if it has).
The result is that one slot tag serves global HUD elements and player-specific
elements with no cross-player leakage, and no feature has to know which kind it
is contributing.
---
## 4. What the slot widget owns
The container that renders a slot owns registration and unregistration, the
conversion of extension data into entry widgets, entry creation and removal,
context discovery, the class whitelist, and empty-state layout.
The feature does not find the slot and does not add children to it. It registers
and keeps a handle.
Two modes fall out of this naturally:
- **class mode** — the extension is a widget class; the slot instantiates it;
- **data mode** — the extension is a data object; the slot asks a factory which
widget class fits, creates it, and then configures it with the data.
Data mode is what makes a HUD list data-driven — the same slot renders whatever
the feature describes, and the widget choice is a lookup rather than a branch.
### Register in the build step, not the constructor
Registration belongs where the widget's underlying Slate widget is built, and
unregistration belongs where those resources are released. Registering in a
constructor registers a design-time object; registering in construction-time
callbacks that do not pair with a release path leaks.
---
## 5. The feature action has two different jobs
A feature that contributes UI does two unrelated things, and conflating them is a
common source of teardown bugs:
```text
LayoutClass → UI.Layer.Game pushed onto a stack
WidgetClass → HUD.Slot.Reticle registered into a slot
```
They need separate bookkeeping — handles to pushed layouts, extension handles for
slots, actor extension request handles — and they come off by **different
mechanisms**. In the measured reference, slot extensions unregister synchronously
and completely, while pushed layouts are asked to deactivate and left for the
stack to dispose of. Both are defensible; treating them as the same operation is
not.
### Attach to the shell actor through the extension protocol
Do not search the world for the HUD. Register a handler for the HUD actor class
with the component manager and react to both the generic extension event and the
shell's own ready event. That covers both orderings — feature before shell, shell
before feature — without a retry loop.
### Assets must already be loaded
Feature actions typically resolve soft class references with a non-loading
accessor, relying on the client asset bundle to have loaded them. That is the
right performance choice and it has a failure mode: if the bundle did not load,
the widget silently does not appear. Mark the bundle at the declaration, and
prefer a code path that logs when the accessor returns null over one that passes
null onward.
---
## 6. Ordering is a contract or it is nothing
If the API exposes a priority field, the implementation sorts by it. If it does
not sort, the field is a lie with a plausible name — and in the measured
reference it is exactly that: stored, copied into the request, never compared
(recipe UI-01).
Before shipping an ordering field, define:
- whether higher or lower wins;
- what happens for equal values;
- whether ordering is per point, per tag, or per context;
- what happens when an extension changes priority after registration;
- who owns the sort — the subsystem or the container.
If ordering does not matter, do not expose the field. An unimplemented ordering
knob costs more than no knob, because designers will spend a day tuning it.
---
## 7. Loading screens are arbitration, not a layer
A loading screen sits above everything, including the root layout, and it is not
pushed onto a layer. The manager polls every registered participant:
```text
ShouldShowLoadingScreen(out Reason) → bool
```
Participants: the experience or mode loader, the front-end flow, travel and
session state, and explicitly registered tasks. The manager owns presentation;
participants only report need and reason.
**Two properties are worth copying verbatim.**
*Every reason is a human-readable string, and the helper asserts it is non-empty.*
This is the single best diagnostic in the whole UI stack: when a loading screen
hangs, one query answers "why", and it answers in words.
*The screen holds for a configurable extra period after the last blocker clears,
with world rendering re-enabled during the hold* — so streaming has time to catch
up and the player does not arrive to a texture pop. Note the documented gap: if a
new blocker appears during the hold, the rendering flag is not restored.
Costs to measure rather than inherit: the manager polls every frame through a
long chain of checks, and hiding the screen may force a full garbage collection —
a guaranteed hitch at exactly the moment control returns to the player
(recipe UI-07).
---
## 8. Input and focus belong to the screen
Each activatable screen declares its own input configuration: game-only,
menu-only or both; mouse capture behaviour; desired focus target; back action.
The stack applies the configuration of whatever is on top.
This is what makes "HUD means game input, menu means UI input" a declarative
property rather than imperative code sprinkled through open and close handlers.
### The gamepad failure nobody catches in review
A screen with no desired focus target opens, and the gamepad has nothing to move
from. The player is stuck with a visible screen and no way to interact.
The mitigation worth copying is an editor-time validation that warns when a
screen subclass does not implement the focus-target hook. Note what it is: a
**warning**, not an error. A screen with no focus target still compiles and still
ships (recipe UI-09). If gamepad support matters, promote it.
### Transitions and focus interact
Stack transition animations can break focus hand-off to the incoming screen. The
measured reference sets transition duration to zero at layer registration, with a
comment explaining exactly that. The consequence — the product has no screen
transition animations at all — is a real design cost, accepted deliberately.
Know that you are making the same trade.
---
## 9. What UI may read, and what it may not
UI may subscribe to local messages for **transient facts**: an elimination for
the feed, an accolade for a toast, an inventory delta for a notification.
UI reads **state** from an observable model: health, score, inventory contents,
settings.
The line: do not rebuild state from a stream of events. A widget created after
the third elimination must show the correct score, and a message-derived counter
cannot promise that.
In the other direction, UI sends commands through an accountable service or
controller, not as a broadcast. "Please equip weapon" with no named handler has
no failure result and no owner.
---
## 10. Costs of this architecture
Stated plainly, so the decision is informed:
- **Indirection.** Finding "what draws this HUD element" means following a tag
through a registry rather than a reference through an asset. There is no
"find references" for a tag.
- **No compile-time check on the join.** A slot with a typo in its tag renders
nothing and reports nothing. Same for a contributor.
- **Runtime debugging is push-based.** If the subsystem exposes no query of its
current registrations, the only tool is verbose logging.
- **World-scoped registries do not survive travel.** Correct for
feature-scoped UI, wrong for anything that must persist across maps.
- **Order is not guaranteed** unless you implement it (§6).
### When to simplify
A single-player game with one fixed HUD, no local multiplayer and no modular
modes does not need this. A root widget with direct children is honest and
smaller.
Introduce layers and extension points when at least one is true: plugins or modes
add UI independently; local multiplayer is required; screens need distinct input
policies; screens load asynchronously and stack; contributors must not depend on
the shell widget class; gamepad focus restoration must be systematic.
---
## Provenance
The analysis above comes from a line-by-line audit of the UI stack of Epic's Lyra
Starter Game on Unreal Engine 5.6 — its extension subsystem, its layer and policy
plugins, its loading screen plugin, and the game module that consumes them — read
as source rather than run.
Two boundaries applied to that reading and apply to these claims: the engine's
own CommonUI implementation is not part of that project, so statements about
focus and activation internals are inferred from usage rather than read; and
widget assets are binary, so the concrete layout of any screen was not inspected.
Source addresses stay in the research archive that produced this skill. Entry
identifiers in [failure modes](failure-modes.md) resolve back to the audited
locations, so any specific claim can be produced on request.
## Evidence boundary
One project, one engine version, one workspace. The architecture is transferable;
the specific defects are evidence, not guarantees about other versions. Re-run
the detection recipes against your own tree before acting on any specific claim.