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:
@@ -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.
|
||||
Reference in New Issue
Block a user