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:
2026-09-05 23:48:55 +07:00
parent 80ea7b03a9
commit ecd87ac96d
67 changed files with 21630 additions and 0 deletions
@@ -0,0 +1,425 @@
---
name: ue-modular-gameplay
description: >-
Design or review modular gameplay in Unreal Engine: experience definitions,
feature plugins, reusable action sets, feature actions, actor extension
handlers, component init-state chains, primary asset bundles, and runtime
activation and deactivation. Use when adding game modes, seasonal or
downloadable features, modular components, abilities, input or UI, or when
fixing initialization-order and feature teardown bugs.
---
# UE modular gameplay
The invariant:
> A feature may select and attach behaviour without modifying the receiving base
> class, but it owns every resource it adds and must remove exactly that
> resource.
Measured patterns from a reference product: [patterns](references/patterns.md).
Eighteen detection recipes for silent modularity failures:
[failure modes](references/failure-modes.md).
Read the failure modes before adopting this architecture. Nearly all of them are
teardown defects, and teardown defects share one property: **activation is what
gets tested, so the half that is broken is the half nobody exercises.**
Related skills: `ue-data-driven-architecture`, `ue-input-architecture`,
`ue-ui-architecture`, `ue-gas-architecture`, `ue-multiplayer-authority`.
Method, not architecture — how to check any claim in this bundle before repeating it: `ue-evidence-discipline`.
---
## 1. Separate the four layers
```text
Playlist / session entry
└─ Experience definition
├─ feature plugins to activate
├─ reusable action sets
├─ mode-specific actions
└─ default pawn data
Feature data asset
└─ lifecycle and asset scanning of one plugin
Action set
└─ reusable composition slice
Feature action
└─ one apply/rollback operation
```
Do not merge them:
- the playlist owns map, session and menu metadata, not gameplay composition;
- the experience owns one runtime composition, not plugin lifecycle policy;
- the feature data asset owns plugin registration and scanning, not a game mode;
- an action set expresses reuse; it is not an inherited base experience;
- an action performs one focused mutation and owns its rollback state.
### Prefer composition over experience inheritance
A mode should read as a sum:
```text
SharedInput + StandardComponents + StandardHUD + ModeDelta
```
not as a hidden defaults chain:
```text
Base -> Shooter -> Team -> TeamDeathmatch
```
Flat action sets keep dependencies, asset loading and teardown auditable. A
reference product measured for this skill goes further and *forbids* the second
form in validation, pointing authors at composition instead.
---
## 2. Experience loading is a state machine
```text
Unloaded
→ LoadingAssets
→ LoadingGameFeatures
→ ExecutingActions
→ Loaded
→ Deactivating
→ Unloaded
```
Each transition needs an explicit completion criterion. Never track this with a
handful of booleans — `bAssetsReady && bPluginsReady && bActionsDone` cannot
express "failed", and failure is the state you will need most.
### The server selects; every peer realises locally
Replicate the selected definition, never the result of loading it:
```text
server selects experience ID
→ replicated definition reaches the client
→ server and client run the same load pipeline
→ role-specific bundles and actions decide their local work
```
This is "replicate intent, realise presentation locally" applied to composition.
### Document selection precedence
If an experience can come from matchmaking, a URL, developer settings, the
command line, world settings and a default, define the exact precedence, log the
winning source, validate the asset ID before loading, and provide an explicit
fallback.
### Delay only for a named dependency
Deferring selection by one frame so that startup settings can initialise is
legitimate **because the dependency has a name**. A next-tick timer with no
identified prerequisite is initialization-order debt with a timer wrapped around
it. Recipe: MG-12.
---
## 3. Bundle and role boundaries
Collect the experience plus every action set as primary assets, then request
named bundles: a shared bundle always, the client bundle when a client exists,
the server bundle when authority exists.
Mark soft references at their declaration:
```cpp
meta=(AssetBundles="Client")
meta=(AssetBundles="Client,Server")
```
| Action target | Expected role |
|---|---|
| HUD, layout, input icons | Client |
| scoring authority, bots, team assignment | Server |
| replicated scoring component | Client + Server |
| ability classes and effects needed for prediction | usually both |
**A client/server flag on an action does not guarantee correct cook bundles.**
Validate both, separately.
### Editor bundle loading is not shipping
A policy that loads client data when not a dedicated server and server data when
not a client-only build loads **both** in the editor. Every memory and load-time
figure taken in-editor therefore describes a configuration that ships to nobody.
Use it for direction of change only. Recipe: MG-18.
---
## 4. Extend actors through receivers and extension handlers
Do not scan the world for actors and patch them. Use the component manager's
extension protocol:
```text
receiver actor registers itself
action registers an extension handler for a target class
manager invokes the handler for existing AND future receivers
returned request handle owns the subscription
```
The receiver lifecycle is symmetric and belongs in the base class:
```text
PreInitializeComponents : add receiver
BeginPlay : send ready events
EndPlay : remove receiver
```
Note what this buys: base gameplay classes register as receivers and **know
nothing about features**. If a class does not inherit the modular base, those
three calls are written by hand — and forgetting the third is a leak with no
symptom until the second activation.
### Use semantic readiness events
A generic "extension added" event means only that the actor is known to the
manager. It may not yet have its pawn data, ability system, controller or input.
Emit named events at the real dependency boundary — abilities ready, bind inputs
now, actor ready — and have handlers accept both the generic event and the
semantic one, so activation order stops mattering. Recipe: MG-13.
### Retain request handles
Handler registration and component requests return ownership handles that are
reference counted. **Dropping the handle is the unsubscribe operation**; there is
no explicit removal call. Storing them per activation context is therefore not
bookkeeping, it is the only teardown mechanism that exists.
---
## 5. Use an init-state chain
Networked actor composition has non-deterministic arrival order. Replace
scattered checks in `BeginPlay`, possession callbacks and replication callbacks
with a monotonic chain:
```text
Spawned → DataAvailable → DataInitialized → GameplayReady
```
- **Spawned** — the actor and component exist.
- **DataAvailable** — required replicated and config data exist.
- **DataInitialized** — every participating feature reached DataAvailable and
cross-feature initialisation ran.
- **GameplayReady** — safe for active gameplay.
The load-bearing barrier is "have all features on this actor reached
DataAvailable", which synchronises components without any of them naming the
others.
### Rules
- states only advance; they reset with the actor's lifecycle, not by backtracking;
- every transition has an observable prerequisite;
- replication callbacks re-enter the same check rather than duplicating init;
- feature-specific work runs once, at a named transition;
- test server, owning client and simulated proxy separately — a proxy never
acquires a controller, and a gate that waits for one deadlocks it while working
perfectly in a single-process test.
---
## 6. Action design contract
An action class implements behaviour; its instance stores targets and
configuration. A world-aware base action should filter to game worlds, handle
worlds that already exist at activation, subscribe for future ones, and release
its world delegates on deactivation — leaving resource tracking to the concrete
action.
### State must be keyed by activation context
One action object can be active in several contexts at once — client and server
in a single-process test are the common case. **State in plain member fields is a
cross-context leak.** Key it:
```cpp
struct FPerContextData { /* ... */ };
TMap<FGameFeatureStateChangeContext, FPerContextData> ContextData;
```
Recipe: MG-11.
### One responsibility per action
Good: add components; add abilities; add input binding; add input mapping
context; add widgets; add cue path.
Bad: "set up shooter mode".
A focused action has a testable rollback and can be reused in an action set.
### The mandatory ownership ledger
Write this table **before** implementing any action:
| Added resource | Stored ownership | Removal |
|---|---|---|
| extension callback | request handle | release the handle |
| component | component request | remove the owned component |
| ability, effect, attribute set | granted handles | revoke the handles |
| input bind | bind handles per pawn | remove those exact binds |
| input mapping context | player + context | remove from the same subsystem |
| widget extension | extension handle | unregister |
| layout | widget or layout handle | deactivate and remove |
| global delegate | delegate handle | unbind |
**If the middle column is blank, reject the action.** Every teardown failure in
the measured reference is a blank middle column that shipped. Recipes: MG-01
through MG-05.
---
## 7. Deactivation is not optional
"Modular" is not proven by successful activation. The round trip is the test:
```text
inactive baseline
→ activate → verify additions exactly once
→ deactivate → verify exact baseline
→ reactivate → verify no duplicates and no stale state
```
Run it with: actors that existed before activation; actors spawned after it; an
actor destroyed before the feature deactivates; two worlds in one process; client
and server roles; a partial load failure; and a plugin required by two
experiences at once.
### Removal must be idempotent
Resources come off by two independent paths: the explicit reset at deactivation,
and reactively when the actor dies first and the manager reports it. Both must be
safe to run, in either order, and must operate on "is there a record?" rather than
assuming one. Recipe: MG-08.
### Reference-count shared plugin activation
If two experiences need one plugin, count activations. One owner deactivating
must not unload a plugin the other still requires. **Check that the count is not
editor-only** — a reference count compiled out of shipping builds is a reference
count that protects development and nothing else. Recipe: MG-16.
### Diff requirements on experience change
```text
old - new = deactivate and unload
new - old = load and activate
intersection = keep active
```
Do not unload everything and reload it immediately. If this diff is not
implemented, define experience changes as map-travel-only and say so in writing —
that is a legitimate scope decision, and leaving it undefined is not.
---
## 8. Failure handling
A production loader must not assert its way through configuration failure.
Represent failure as a state:
```text
Loaded
Failed(reason, failing asset / plugin / action)
```
Handle: an invalid asset ID; a missing plugin URL; a cancelled load; plugin
activation failure; action validation failure; timeout or partial completion; and
a deactivation pauser that never completes.
The loading screen consumes loader state and reason. **A permanent loading screen
is not error handling** — it is the absence of it, rendered.
---
## 9. Validation checklist
### Experience
- [ ] Asset ID registered and resolves.
- [ ] Default pawn data valid, or explicitly no pawn.
- [ ] Plugin names resolve to URLs.
- [ ] Action sets non-null and duplicate-free.
- [ ] Reusable behaviour in action sets; only the mode delta inline.
- [ ] Client and server bundles cover every soft reference.
### Action
- [ ] Target actor class valid; addition list non-empty.
- [ ] Existing and future actors both handled.
- [ ] Semantic readiness event used where the generic one is too early.
- [ ] Every request, delegate, grant, widget and input handle retained.
- [ ] Deactivation reverses activation exactly.
- [ ] Repeated activate/deactivate is idempotent.
- [ ] Per-context state cannot leak into another world.
- [ ] Index or key passed to a deferred callback matches the entry it names.
### Init states
- [ ] Every prerequisite is explicit.
- [ ] Every participating feature can reach every state.
- [ ] No feature waits on a signal produced only by a path it disabled.
- [ ] Authority and local-control branches tested.
- [ ] Late replication re-enters the same check.
---
## 10. Review questions
Before approving a modular feature:
1. Which definition selects it?
2. Which plugin owns its code and content?
3. Which action applies each effect?
4. Which readiness state makes that effect safe?
5. Which handle proves ownership?
6. What is the exact inverse operation?
7. What is loaded on client, on server, in the editor?
8. Can another feature share the same plugin or resource?
9. What happens on partial failure?
10. Does activate → deactivate → activate return to the same observable state?
**If questions 4 to 6 have no precise answer, the feature is dynamically attached
but not modular.** That distinction is the entire subject of this skill: attaching
is easy and demonstrable, detaching is neither, and only one of them gets
demonstrated.
---
## Provenance
The findings behind the failure modes come from a line-by-line source audit of
the feature-action layer of Epic's Lyra Starter Game on Unreal Engine 5.6, read
rather than run. The engine-side component manager and feature subsystem are not
part of that project, so every statement about them is inferred from call sites
in the audited code and is marked as such rather than presented as measured.
That audit found the architecture sound and its teardown half incomplete in five
separate actions — which is why this skill treats the ownership ledger and the
round-trip test as mandatory rather than advisory. Several of the gaps are
recorded by the original authors in their own comments, which is the strongest
confirmation a source audit can produce.
Source addresses stay in the research archive; what ships is the detection
recipe. Each entry carries a stable identifier (`MG-01`, `MG-02`, …) that
resolves back to the audited location there.
## Evidence boundary
One project, one engine version, one workspace. Binary assets were not read, so
which actions the shipped features actually configure is unknown and is recorded
as open rather than guessed. Re-run the recipes against your own tree before
trusting any specific claim.
@@ -0,0 +1,701 @@
# Failure modes: modular gameplay
Nineteen ways a feature attaches correctly and detaches incompletely.
The shared property is stated once, because it explains the whole list:
**activation is what gets demonstrated.** A feature is switched on, someone
checks the ability appeared, the widget rendered, the input worked, and the
review is done. Deactivation is exercised by nobody during development, because
in development you restart the editor instead. So the broken half is
systematically the half nobody looks at, and its symptoms — a duplicate on the
second activation, a bind that survives its feature — appear later and are
attributed elsewhere.
A second property applies to most entries: the reference implementations of these
actions **look symmetric**. There is an `Add` and there is a `Remove`, a reset
loop and a reactive removal path. The defect is almost never a missing function;
it is a function that removes something other than what was added, or that reads
a container nobody fills.
Recipes use `rg` and are written to be run from a project's source root. Most are
a pair: one search finds the addition, the other looks for the matching removal.
---
## Teardown
### MG-01 - Rollback function with an empty body
**Mechanism.** An action's removal path calls a method on a game component, and
that method's body is a comment saying it is not implemented yet.
**Why it is silent.** Every caller succeeds. The call compiles, executes and
returns; nothing distinguishes "removed the bindings" from "did nothing" at the
call site, because neither returns a result.
**Why the obvious check misses it.** The action's teardown *is* implemented and
reads correctly — it iterates its tracked pawns and calls a removal method on
each. Reviewing the action finds a complete, symmetric implementation. The empty
body is one call away, in a different file owned by a different subsystem, and it
is the game component's problem rather than the feature's.
**Symptom.** Ability input bindings survive the deactivation of the feature that
added them. The second activation adds a second set. Nothing errors.
**Detect.** Find removal functions whose body is only a comment:
```bash
rg -n -A4 "void \w+::Remove\w+\(" --glob "*.cpp" . \
| rg -B2 "^\s*//\s*@?TODO|^\s*\}"
```
Read each hit: a `Remove` whose body contains no statements is the finding. In
the measured reference the removal of an additional input config was exactly this
— a three-line function containing one TODO comment.
**Guardrail.** A removal function with no body must not compile silently. Make it
pure virtual, assert inside it, or delete the caller. An unimplemented rollback
that is *called* is worse than one that does not exist, because the call site
proves the intent was there.
---
### MG-02 - Bind handles discarded at the moment they are created
**Mechanism.** A binding call takes an out-parameter array of handles. The caller
declares that array as a local variable and lets it go out of scope.
**Why it is silent.** The bindings work. Handles are only needed to undo, and
nothing undoes during a normal session.
**Why the obvious check misses it.** The out-parameter is passed correctly and
the call is idiomatic — some codebases even name the argument in a comment at the
call site, which makes it look deliberate. The defect is the *storage duration*
of a local variable, which no search for "is the API used correctly?" examines.
This is also the root cause of MG-01: the removal function cannot be implemented,
because the information it would need was thrown away one function earlier.
**Symptom.** Rollback is not merely unimplemented but *impossible* without
changing the component that binds. An estimate of "implement the missing removal"
comes out an order of magnitude low.
**Detect.** Find handle arrays declared as locals at a binding call:
```bash
rg -n -B2 "BindAbilityActions|BindAction\(" --glob "*.cpp" . \
| rg "TArray<uint32>\s+\w+;|TArray<FInputBindingHandle>\s+\w+;"
```
Any handle container declared in the same scope as the call, rather than as a
member, is discarded. In the measured reference this appeared at two separate
call sites in one component.
**Guardrail.** Handles returned by a binding API are stored by the object whose
lifetime governs the binding, in the same change that creates the binding. If
there is nowhere to store them, the feature is not removable and that must be
stated before it ships.
---
### MG-03 - Tracking container that is read but never written
**Mechanism.** An action declares a container of the actors it has affected,
removes from it in the reactive path, iterates it during reset — and never adds
to it. The function that would add takes the per-context data as a parameter and
ignores it.
**Why it is silent.** The reset loop runs, finds the container empty, and
completes successfully. An empty loop is a legal outcome; it looks exactly like a
feature that affected nothing because no receivers existed.
**Why the obvious check misses it.** Every symptom of correctness is present: the
container is declared, an `ensure` at activation asserts it starts empty, the
reset loop pops from it correctly, and the reactive path removes from it. Four of
the five operations exist. Only the write is missing, and "is this container
used?" answers yes at four sites.
**Symptom.** Input mapping contexts are never removed at deactivation. They come
off only if the receiving actor happens to die first and the reactive path fires.
The result depends on teardown order, which makes it intermittent.
**Detect.** For every tracking container, compare reads against writes:
```bash
C='ControllersAddedTo'
rg -n "\b$C\b" --glob "*.cpp" --glob "*.h" . # all uses
rg -n "\b$C\b\s*\.\s*(Add|AddUnique|Emplace|Push)" --glob "*.cpp" . # writes only
```
Uses without writes is the finding. The measured reference is unusually clear
here: a sibling action in the same directory tracks its pawns with
`AddUnique` in exactly the place the broken one omits it, so the two files can be
diffed against each other. The original authors had also left a comment on the
broken line noting that the container is never modified — a known defect that
shipped.
**Guardrail.** A container whose only writes are removals is a container with no
producer. Assert non-empty after the add path runs, or derive the collection from
the resource itself rather than maintaining a parallel list.
---
### MG-04 - Reset path narrower than the reactive removal path
**Mechanism.** An action has two removal routes: a reset invoked at deactivation,
and a per-actor removal invoked when the manager reports a receiver going away.
The per-actor route cleans up more than the reset does.
**Why it is silent.** The reset completes and clears its own bookkeeping, so the
action's internal state is consistent afterwards. What is left behind lives in
another subsystem — a pushed layout widget still on its layer — and that
subsystem was never told.
**Why the obvious check misses it.** Both functions exist and both are correct
for what they enumerate. Comparing them requires reading two loops in different
parts of a file and noticing that one iterates two collections and the other
iterates one. Nothing names the missing collection.
**Symptom.** A layout widget pushed by a feature stays on screen after the
feature is deactivated. On reactivation there are two.
**Detect.** Enumerate what each removal path touches and diff the sets:
```bash
rg -n -A20 "::Reset\(" --glob "*.cpp" . | rg "Empty\(|Unregister|Deactivate|Remove"
rg -n -A20 "::Remove\w+\(" --glob "*.cpp" . | rg "Empty\(|Unregister|Deactivate|Remove"
```
A collection handled in the second output and absent from the first is the
finding.
**Guardrail.** One removal implementation, called by both routes. If they must
differ, the reset calls the per-actor removal in a loop rather than reimplementing
a subset of it.
---
### MG-05 - Two removal semantics for one logical resource
**Mechanism.** The same kind of resource is revoked differently depending on which
field granted it: one path clears immediately, the other defers removal until the
resource finishes naturally.
**Why it is silent.** Both are legitimate APIs with legitimate uses. Deferred
removal is correct when an ability is mid-activation; immediate removal is
correct when tearing down. Neither errors.
**Why the obvious check misses it.** Each call site is individually defensible,
and they are in different functions. The inconsistency is only visible if you ask
"what happens to a granted ability at deactivation?" and discover the answer is
"it depends which field you used", which is not a question the code invites.
**Symptom.** Deactivation behaviour differs between two configurations that a
designer considers equivalent. Timing-dependent, so it reproduces inconsistently.
**Detect.** List every revocation call for one resource kind and compare:
```bash
rg -n "ClearAbility|SetRemoveAbilityOnEnd|RemoveActiveGameplayEffect|RemoveSpawnedAttribute" \
--glob "*.cpp" .
```
Two different revocation calls for the same resource kind in one teardown path is
the finding.
**Guardrail.** Pick one semantic per resource kind and document it at the grant
site. If both are genuinely needed, the choice belongs in the data, named, not in
which code path happened to grant it.
---
### MG-06 - Removal that is not idempotent
**Mechanism.** Resources come off by two independent routes — explicit reset at
deactivation, and reactive removal when the actor dies first. If removal assumes a
record exists, running it twice or in the wrong order misbehaves.
**Why it is silent.** In the common ordering — feature deactivates while actors
are alive — only one route runs. The double-run needs an actor destroyed during
teardown, which is a shutdown-order accident.
**Why the obvious check misses it.** Each route is correct in isolation. Nothing
in either function says "this may also be reached from the other path"; the
coupling is a property of the manager's event dispatch, which is engine code.
**Symptom.** An assert or a stale entry during shutdown, in an ordering nobody can
reproduce on demand.
**Detect.** Check that removal is guarded by record existence, and that reset
loops tolerate mutation:
```bash
rg -n -A8 "::Remove\w+\(" --glob "*.cpp" . | rg "Find\(|Contains\(|if\s*\("
rg -n -A6 "while\s*\(!\w+\.IsEmpty\(\)\)" --glob "*.cpp" .
```
The second pattern is the correct shape: a reset written as "while not empty,
take the top and remove it" survives a removal that mutates the same collection.
A plain range-for over a collection that removal mutates does not.
**Guardrail.** Removal operates on the presence of a record, never on the
assumption of one. Write reset loops as drain loops.
---
## Correctness of what gets attached
### MG-07 - Index incremented inside the guard it is guarding
**Mechanism.** A loop builds one deferred callback per configuration entry,
passing the entry's index as payload. The increment sits inside the branch that
skips invalid entries, so after any skip the payload index no longer matches the
list.
**Why it is silent.** Every index remains in range and resolves to a real entry.
The callback applies a valid, well-formed configuration — just the wrong one.
There is no out-of-bounds access and nothing to assert on.
**Why the obvious check misses it.** Reading the loop shows an increment that
appears to advance per iteration; it takes reading the brace structure to see it
is inside the guard. Editor-time validation flags the invalid entry that triggers
it, so the condition is believed impossible — but that validation is an editor
check, not a runtime guarantee, and it does not run on cooked data.
**Symptom.** Actors receive another entry's abilities. Because both sets are
valid, this presents as a design or data error and gets investigated in the
content.
**Detect.** Find counters incremented inside a conditional within a loop:
```bash
rg -n -B8 "\b\w*Index\+\+|\+\+\w*Index" --glob "*.cpp" . | rg "if\s*\(|continue"
```
Read each hit's brace structure. In the measured reference the increment sat
inside a null-class guard, six lines below the `if` that opened it.
**Guardrail.** Derive the index from the loop itself rather than maintaining a
parallel counter. If a counter is needed, increment it in the loop header where
its relationship to the iteration is structural.
---
### MG-08 - Soft reference resolved with a bare accessor and no fallback
**Mechanism.** An action resolves a soft class or asset pointer with a
non-loading accessor. If the bundle did not load it, the accessor returns null
and the entry is skipped.
**Why it is silent.** The skip is unconditional and unlogged. A feature that
grants nothing looks identical to a feature whose grant list is empty, and both
are legal.
**Why the obvious check misses it.** Every call site is correct given the
precondition "bundles are loaded", which is true in the editor, where everything
is loaded. The precondition is established in a completely different subsystem —
asset bundle configuration — and the code that depends on it does not state that
it does.
**Symptom.** A packaged build silently omits abilities, widgets or mappings that
work in the editor. Bisecting finds nothing, because no code changed.
**Detect.** Compare loading resolutions against non-loading ones, and check for a
fallback:
```bash
rg -c "LoadSynchronous" --glob "*.cpp" .
rg -n "\.Get\(\)" --glob "*.cpp" . -A2 | rg -v "ensure|check|if\s*\(|UE_LOG"
```
In the measured reference the feature-action directory used three synchronous
loads against ten bare accessors, none of the latter guarded or logged. Both
numbers matter: the loads are a runtime hitch, the bare accessors are a silent
omission, and they appear in the same files.
**Guardrail.** Either load, or assert, or log — never skip silently. A soft
reference that is expected to be bundle-loaded should assert that it was.
---
### MG-09 - Unreachable code after a return
**Mechanism.** A validation function returns its result and is followed by a
second, unreachable return.
**Why it is silent.** It is genuinely harmless. The compiler may not even warn.
**Why the obvious check misses it.** It is invisible to behaviour-driven review
because it has no behaviour. It is included here for one reason: **it is evidence
about the file rather than a defect in it.** Dead code after a return means
someone edited this validation and did not read what followed. Treat it as a
marker for where attention lapsed, and read the surrounding function more
carefully than you otherwise would.
**Symptom.** None. Its value is diagnostic.
**Detect.**
```bash
rg -n -A3 "^\s*return \w+;\s*$" --glob "*.cpp" . | rg -A1 "^\s*$" | rg "return"
```
**Guardrail.** Enable and honour unreachable-code warnings. Where one appears,
re-read the function rather than only deleting the line.
---
## Context and scope
### MG-10 - Action state in plain members rather than per-context
**Mechanism.** One action object can be active in several state-change contexts
simultaneously — client and server in a single process is the ordinary case.
State kept in plain member fields is shared between them.
**Why it is silent.** With one context, which is every standalone run, the
behaviour is correct. The corruption requires two contexts, and the second
context's writes look like legitimate updates.
**Why the obvious check misses it.** Member fields on an action object are the
obvious place to keep state, and nothing in the base class signature suggests
otherwise. The requirement is transmitted only by convention — a context
parameter that every lifecycle method receives and that a single-context
implementation can safely ignore.
**Symptom.** One process's client and server tread on each other's tracking data.
Teardown removes the wrong context's resources, or removes them twice.
**Detect.** Find action state that is not keyed by context:
```bash
rg -n -A12 "class \w*GameFeatureAction\w*" --glob "*.h" . \
| rg "^\s*(TArray|TMap|TSet|TSharedPtr|bool|int32)\s+\w+" \
| rg -v "FGameFeatureStateChangeContext"
```
Any mutable member that is not inside a per-context map is a candidate. The
correct shape — a per-context struct in a map keyed by the change context —
appears in four separate actions in the measured reference and is worth copying
verbatim.
**Guardrail.** All action state lives in a per-context map. Assert at activation
that the context's slot is empty, and reset it if not — that assertion is what
turns a silent leak into a visible one.
---
### MG-11 - Activation reference count compiled out of shipping
**Mechanism.** A count that prevents one owner from deactivating a plugin another
still needs is wrapped in an editor-only guard, and additionally checked against
an editor runtime flag inside.
**Why it is silent.** In the editor, where multiple worlds share a process, the
count is present and correct. In a shipping build there is one world per process,
so the case it protects against does not arise — until it does.
**Why the obvious check misses it.** The mechanism exists, is correct, and is
tested every time anyone uses multi-world play-in-editor. Discovering that it does
not ship means reading the preprocessor guard around it, which is at the top of a
file nobody opens while reasoning about deactivation.
**Symptom.** No symptom in a single-world build. A latent hazard the moment any
build hosts two experiences, which is exactly when a project starts caring about
modularity.
**Detect.**
```bash
rg -n -B4 -A10 "RequestToDeactivate|NotifyOfPluginActivation|ActivationCount" \
--glob "*.cpp" . | rg "#if|GIsEditor|WITH_EDITOR"
```
A reference count inside an editor guard, in code that ships, is the finding.
**Guardrail.** Reference counting is a correctness mechanism, not a development
convenience. If it only exists in the editor, say so where it is declared and
state what protects shipping instead.
---
### MG-12 - Plugins leak enabled between experiences
**Mechanism.** Experience teardown deactivates the plugins it listed, but a switch
between experiences is not implemented as a diff — so plugins required by the old
experience and not the new one may stay enabled.
**Why it is silent.** An enabled plugin that nothing uses has no symptom. Its
content stays resident and its actions have already been deactivated, so the
observable state is correct.
**Why the obvious check misses it.** Deactivation exists and works for the case it
was written for: ending play. Experience-to-experience switching is a different
lifecycle that reuses the same teardown, and nothing in that teardown announces
its scope.
**Symptom.** Memory that does not return to baseline across mode changes, and a
second experience that inherits capabilities from the first.
**Detect.** Look for the diff, and for the authors' own admissions:
```bash
rg -n -i "//\s*@?TODO.*(leak|deactivat|unload|experience)" --glob "*.cpp" .
rg -n "GameFeaturePluginURLs" --glob "*.cpp" . -B4 -A8 | rg "Difference|Intersect|Diff"
```
The first search matters more than the second. In the measured reference the
experience manager carries an eight-item block of the authors' own TODOs, one of
which states plainly that plugins are leaked enabled and that diffing requirements
is the intended fix. **A defect the authors documented is the cheapest one you
will ever find** — read those blocks first in any codebase you are adopting.
**Guardrail.** Implement the requirement diff, or define experience changes as
map-travel-only and write that decision down. An undefined middle is what leaks.
---
### MG-13 - Editor loads both role bundles
**Mechanism.** The loading policy loads client data when not a dedicated server
and server data when not a client-only build. In the editor neither condition
excludes, so both load.
**Why it is silent.** It is intentional and it is correct — the editor must be
able to run either role. Nothing is wrong; the number it produces simply describes
no shipping configuration.
**Why the obvious check misses it.** Profiling produces a figure, and figures from
a profiler carry an authority their provenance does not. The condition that
inflates it is two boolean expressions in a policy class, in a different
subsystem from the one being measured.
**Symptom.** Memory and load-time budgets built on editor measurements, wrong in a
direction that feels safe until a platform limit is real.
**Detect.**
```bash
rg -n -A6 "GetGameFeatureLoadingMode|bLoadClientData|bLoadServerData" --glob "*.cpp" .
```
If neither flag excludes in the editor, both bundle sets load. In the measured
reference the authors noted the resulting editor hitching in a comment beside it.
**Guardrail.** Editor asset measurements are directional only. Any absolute budget
requires a packaged build of the target role, and any budget quoted without one
carries that caveat in writing.
---
## Timing and readiness
### MG-14 - A delay with no named dependency
**Mechanism.** Selection or initialisation is deferred by a tick to let something
else finish first.
**Why it is silent.** It works. One frame is enough today, on this machine, for
this content set.
**Why the obvious check misses it.** A next-tick deferral is a common, accepted
idiom, and the code around it is correct. Whether it is legitimate depends
entirely on whether the thing being waited for is *named* — and an unnamed wait
looks identical to a named one.
**Symptom.** Initialisation order breaks on a slower machine, a larger content
set, or after an unrelated subsystem changes its own timing. The failure appears
far from the deferral.
**Detect.** Find deferrals and check for a stated prerequisite:
```bash
rg -n -B6 "SetTimerForNextTick|GetTimerManager\(\)\.SetTimer" --glob "*.cpp" . \
| rg -i "//|comment|wait|until|so that"
```
A deferral whose surrounding comment names what it waits for is debt with a
receipt. One with no comment is debt with a timer wrapped around it. In the
measured reference the one-frame delay before experience selection *is*
documented — it waits for startup settings — which is the correct form.
**Guardrail.** Every delay names its prerequisite in a comment at the call site,
and is replaced by an explicit readiness signal when one becomes available.
---
### MG-15 - Handler attached to the generic extension event
**Mechanism.** An action registers for "extension added", which fires when the
manager first learns of the actor — before its pawn data, ability system,
controller or input exist.
**Why it is silent.** The handler runs, finds what it needs missing, and returns
without acting. Returning early on a missing prerequisite is defensive
programming, and looks like it.
**Why the obvious check misses it.** The registration is correct, the handler is
correct, and the early return is correct. Nothing is wrong at any single point.
The feature simply never applies, because the one event that would have carried it
arrived too early and no later event re-triggers it.
**Symptom.** A feature that works when activated after the actor exists and does
nothing when activated before, or the reverse — order-dependent, and the order
differs between the editor and a packaged run.
**Detect.** List which events each handler responds to:
```bash
rg -n -A12 "HandleActorExtension|FExtensionHandlerDelegate" --glob "*.cpp" . \
| rg "EventName ==|NAME_"
```
A handler that responds only to the generic added/removed events, with no
semantic ready event, is the finding. The correct shape in the measured reference
dispatches on both: the generic event *and* a named readiness event such as
"abilities ready" or "bind inputs now", so whichever arrives second does the work.
**Guardrail.** Handlers accept both the generic event and a semantic readiness
event, making activation order irrelevant. Emitting a new readiness event costs a
name and one call — it requires no change to the feature infrastructure.
---
### MG-16 - Receiver registration written by hand and left incomplete
**Mechanism.** Actors register with the component manager in three places: add
receiver early, send a ready event at begin play, remove receiver at end play.
Classes inheriting a modular base get all three. Classes that do not inherit it
have them written by hand.
**Why it is silent.** Missing the removal leaks a registration whose owner is
gone. Weak references prevent a crash; the record remains. Missing the ready
event means features silently never attach to that actor class.
**Why the obvious check misses it.** The class works. It renders, it ticks, it
does its job. The two or three lines that make it visible to features are
infrastructure boilerplate, and boilerplate absent from a file looks like
boilerplate that was not needed there.
**Symptom.** A feature attaches to every actor class except one, with no error —
or registrations that accumulate across level transitions.
**Detect.** Compare the three calls per class:
```bash
rg -l "AddGameFrameworkComponentReceiver" --glob "*.cpp" . > /tmp/add
rg -l "RemoveGameFrameworkComponentReceiver" --glob "*.cpp" . > /tmp/rem
comm -23 <(sort /tmp/add) <(sort /tmp/rem)
```
Any file that adds and never removes is the finding. In the measured reference
one HUD class inherits a non-modular engine base and therefore carries all three
calls by hand — correctly, but by hand, which is the fragile arrangement.
**Guardrail.** Put the three calls in a base class and inherit it. Where that is
impossible, add a test that asserts the receiver count returns to baseline after a
level transition.
---
## Composition
### MG-17 - Handle dropped without realising it was the unsubscribe
**Mechanism.** Extension handler and component requests return reference-counted
handles. There is no explicit removal API — releasing the last reference *is* the
removal.
**Why it is silent.** Keeping a handle costs nothing visible, and dropping one
produces no event. Both directions of the mistake are quiet: holding too long
leaks a subscription, releasing too early silently detaches a live feature.
**Why the obvious check misses it.** Nothing in the calling code says
"unsubscribe". Reviewing teardown for removal calls finds none — correctly, because
there are none — and it takes knowing the ownership model to recognise that
emptying an array of handles is the teardown.
**Symptom.** Either features that cannot be removed, or features that detach when
an unrelated container is cleared.
**Detect.** Confirm every handle-producing call has a stored destination:
```bash
rg -n "AddExtensionHandler|AddComponentRequest" --glob "*.cpp" . -A3 \
| rg -v "\.Add\(|=\s*\w+\.Add"
```
A call whose result is not stored is a subscription that ends immediately.
**Guardrail.** Name the container for what it does — a handle array is an
ownership ledger, not a cache — and comment at its declaration that clearing it is
the unsubscribe operation.
---
### MG-18 - Plugin activation order assumed
**Mechanism.** Plugin URLs from an experience and from each of its action sets
merge into one list and activate in parallel. Action *execution* order is
deterministic; plugin *activation* order is not.
**Why it is silent.** Parallel activation usually completes before anything
depends on the result, and when it does not, the dependent code has its own
readiness check that eventually passes.
**Why the obvious check misses it.** Action ordering *is* deterministic and
documented — experience actions first, then action sets in order. Having verified
that, it is natural to assume the same of plugins, and nothing contradicts it.
**Symptom.** A feature that depends on another plugin's registration works
consistently until content or load timing changes.
**Detect.** Check whether the plugin list is loaded as a batch:
```bash
rg -n -A10 "GameFeaturePluginURLs" --glob "*.cpp" . | rg "for\s*\(|LoadGameFeaturePlugin"
```
A loop issuing loads without sequencing between them means order is not
guaranteed.
**Guardrail.** Express cross-plugin dependencies as readiness states rather than
activation order. If ordering is genuinely required, sequence the loads and say
why in the code.
---
## Failure handling
### MG-19 - Asynchronous deactivation counted but not honoured
**Mechanism.** The deactivation context supports pausers for asynchronous
teardown. The implementation counts them and, if any are outstanding, logs an
error and proceeds.
**Why it is silent.** It is not entirely silent — it logs. But it logs and
*continues*, so teardown completes and the sequence appears successful. A log line
during shutdown competes with everything else logged during shutdown.
**Why the obvious check misses it.** The support looks present: the context is
constructed with a pauser callback, the count is tracked, the branch exists.
Reviewing for "is async deactivation handled?" finds all the machinery. Reading
what the branch *does* is a separate step.
**Symptom.** An action with asynchronous teardown is cut short. Whatever it was
waiting to release stays held, and the failure is attributed to whichever
subsystem later notices the leak.
**Detect.** Find pauser handling and read the branch:
```bash
rg -n -A8 "NumExpectedPausers|DeactivatingContext" --glob "*.cpp" . \
| rg "UE_LOG|Error|return|check"
```
An error log with no wait and no abort is the finding. The measured reference
states its own limitation in the log text — that asynchronous deactivation is not
fully supported — which makes it a documented gap rather than an unknown one.
**Guardrail.** Either wait for pausers before completing teardown, or reject
actions that request asynchronous deactivation at validation time. Logging and
continuing converts a design gap into a runtime one.
@@ -0,0 +1,302 @@
# Patterns: a feature-action layer read end to end
A worked reading of one real modular gameplay implementation: how a feature
attaches behaviour to actors it does not own, and what happens when it detaches.
Individual defects are in [failure-modes.md](failure-modes.md), one entry each,
with a recipe. This file is about the shapes worth copying, and about one
finding that only becomes visible when you count.
Markers: **[measured]** — read in source; **[derived]** — conclusion from measured
facts; **[open]** — not answerable from the available source, and left open.
**A scope caveat that shapes everything below.** The engine-side component
manager, request handle, feature action base and feature subsystem are not part
of the audited project — they live in engine plugins that were not in the tree.
Every statement about them here is inferred from call sites **[derived]**, and is
marked so. This is the honest version of a common situation: you can audit how a
project *uses* an engine subsystem far more cheaply than you can audit the
subsystem, and the usage is where your defects will be anyway.
---
## 1. The core idea: the action does not look for actors
The mechanism that makes this architecture work is one inversion.
A feature action does **not** scan the world for actors to modify. It registers
an extension handler for a target *class* with a component manager, and the
manager invokes that handler for every matching receiver — the ones that already
exist and the ones that appear later **[measured]**.
```text
receiver actor registers itself with the manager
action registers an extension handler for a class
manager invokes the handler for existing AND future receivers
returned request handle owns the subscription
```
The receiver side is three calls, and in the audited project they live in modular
base classes rather than in gameplay code **[measured]**: add receiver in
pre-initialize, send a ready event at begin play, remove receiver at end play.
**[derived]** The consequence is the whole point of the architecture: base
gameplay classes register as receivers and **know nothing about features at all**.
Nothing in the character, player state or game mode references the feature system.
A feature plugin can be added to the project without touching any of them.
The audited project contains one instructive exception. A HUD class inherits a
non-modular engine base, so those three calls are written out by hand in it
**[measured]**. Correct — and fragile in a way the inherited version is not,
because the third call is easy to omit and its omission has no symptom until
something counts registrations. Recipe: MG-16.
---
## 2. Ownership is a reference count, and nothing says so
Handler registration and component requests return shared handles **[measured]**.
There is no explicit unregister call anywhere in the audited code
**[measured]** — the only way to detach is to release the last reference.
Every reset path therefore begins by emptying an array of handles **[measured]**,
and that line *is* the unsubscribe operation even though it reads as ordinary
container cleanup.
**[derived]** Two failure directions follow, and both are quiet. Hold a handle too
long and the subscription leaks. Clear the array early and a live feature detaches
with no event. Reviewing teardown for "removal calls" finds none — correctly,
because there are none — so the reviewer must already know the ownership model to
see that teardown is present. Recipe: MG-17.
One detail in the audited code confirms the model from the inside: when a
component already exists, the action still issues a request for it in some cases,
with a comment explaining that requests are reference counted **[measured]**.
**[derived]** That is a project telling you, in its own code, how the engine
subsystem it depends on behaves — which is the best evidence available when the
subsystem's source is not in the tree.
---
## 3. Per-context state, and why it is not optional
One action object can be active in several state-change contexts at once — client
and server in a single process being the ordinary case. Every lifecycle method
receives a context, and four separate actions in the audited project key their
state by it **[measured]**:
```cpp
struct FPerContextData { /* tracked resources */ };
TMap<FGameFeatureStateChangeContext, FPerContextData> ContextData;
```
This is the pattern to copy verbatim. **[derived]** State in plain member fields
works perfectly with one context and corrupts silently with two, and the second
context's writes look like legitimate updates.
Two supporting habits appear consistently and are worth copying with it
**[measured]**:
- at activation, `ensure` that this context's slot is empty and force a reset if
not, **before** calling the base implementation — an assertion that converts a
leak into a visible failure;
- at deactivation, call the base implementation **first**, then reset the
context's data.
The base class itself keeps its delegate handles in a map keyed the same way
**[measured]**, which is how it supports being active in two contexts at once.
Recipe: MG-10.
---
## 4. Semantic readiness events
A generic "extension added" event means only that the manager knows about the
actor. **[derived]** It does not mean the actor has its pawn data, ability system,
controller, or initialised input — so a handler attached to that event alone will
find its prerequisites missing, return early, and never run again.
The audited project solves this by emitting its own named events at the real
dependency boundaries — abilities ready, bind inputs now **[measured]** — and by
having every handler dispatch on **both** the generic event and the semantic one
**[measured]**:
```text
extension removed / receiver removed -> remove
extension added / <semantic event> -> add
```
**[derived]** Whichever arrives second does the work, so activation order stops
mattering. This is the single most transferable idea in the file, and it costs a
`static const FName` plus one call to emit — no change to the feature
infrastructure is required. Recipe: MG-15.
---
## 5. The finding that only appears when you count
Eight actions were audited. Five have incomplete teardown **[measured]**:
| Action | Attaches | Detaches |
|---|---|---|
| add abilities | grants abilities, attribute sets, ability sets; stores every handle | mostly correct — but two revocation semantics for one resource kind, depending on which field granted it |
| add widgets | pushes layouts, registers extensions, stores handles | reset unregisters extensions and does **not** deactivate layouts; the per-actor path does both |
| add input binding | delegates to a game component | the component's removal method has an empty body |
| add input mapping context | adds contexts to the input subsystem | tracks affected controllers in a container **nothing ever writes to** |
| splitscreen config | votes to disable | correct — decrements the vote |
**[derived]** Read one at a time, each looks like an isolated oversight. Read
together, they are one pattern: **the add path is complete in all eight, and the
remove path is complete in three.** That ratio is the argument for the ownership
ledger in the skill — not a rule someone invented, but the shape the evidence
takes.
Two of these deserve their detail, because the detail is what makes them
recognisable elsewhere.
### The container nobody fills
An action declares a container of the controllers it has affected. An `ensure` at
activation asserts it starts empty. The reset loop drains it correctly. The
reactive path removes from it. **Nothing adds to it** — the function that would
takes the per-context data as a parameter and ignores it **[measured]**.
Four of five operations exist, so any search for "is this container used?"
answers yes emphatically. The one missing operation is the producer.
Two things make this the clearest entry in the file. First, a **sibling action in
the same directory** does it correctly, with `AddUnique` in exactly the place the
broken one omits **[measured]** — so the two files diff against each other.
Second, the original authors left a comment on the broken line stating that the
container is never modified **[measured]**. A known defect that shipped. Recipe:
MG-03.
### The rollback that cannot be written
The input-binding action's teardown iterates its tracked pawns and calls a removal
method on the game component. That method's body is a single TODO comment
**[measured]**.
But the cause is one level further down: the function that creates the bindings
declares the handle array as a **local variable** and lets it go out of scope
**[measured]**, at two separate call sites.
**[derived]** So the removal is not merely unimplemented, it is impossible to
implement without changing the component — the information it would need was
discarded at creation. An estimate of "implement the missing rollback" made from
reading the empty function comes out an order of magnitude low. Recipes: MG-01,
MG-02.
---
## 6. What the reference got right
Stated deliberately, because the section above is not an argument against the
architecture:
1. **Actions do not search for actors.** The extension handler protocol handles
existing and future receivers uniformly **[measured]**.
2. **Per-context state in all four attaching actions** **[measured]**, with the
activation-time emptiness assertion.
3. **Reset loops written as drain loops** — "while not empty, take the top,
remove it" **[measured]** — which survives a removal that mutates the same
collection. A range-for would not.
4. **Two removal routes, both idempotent**: explicit reset at deactivation, and
reactive removal when the actor dies first, keyed on whether a record exists
**[measured]**.
5. **Composition enforced over inheritance.** Validation rejects deriving one
scripted experience from another and points the author at action sets
**[measured]**. That is a design rule with a compiler behind it.
6. **Action sets are separate primary assets** with their own bundle data
**[measured]**, which is what allows them to be loaded alongside the experience
rather than through it.
7. **A one-frame deferral with a named reason** **[measured]** — it waits for
startup settings. A delay whose prerequisite is named is debt with a receipt;
an unnamed one is debt with a timer around it. Recipe: MG-14.
8. **A policy class as the place for pre-activation work.** Logic that must run
before activation, is process-global rather than per-world, or needs to see all
of a plugin's actions at once, belongs in a lifecycle observer rather than in an
action **[measured]**. One action in the audited set is pure data with a getter,
executed entirely by such an observer **[measured]** — a clean separation worth
copying.
---
## 7. The authors' own TODO block
The experience manager carries a block of eight TODOs from its authors
**[measured]**, covering: asynchronous experience loading; explicit error
handling instead of assertions; running actions in phases rather than all at
once; support for deactivating an experience; and — stated plainly — that plugins
are **leaked enabled** between experiences, with diffing requirements named as the
intended fix.
**[derived]** This is the cheapest finding in any audit, and the most reliable.
The authors know; they wrote it down; nobody adopting the code reads it, because
adoption reads the class list rather than the comments.
Two practical consequences:
- **Read the TODO blocks first** in any codebase you are adopting. They are a
defect list written by the people best placed to write one.
- Treat "the authors documented this gap" as *stronger* evidence than a static
finding of your own, not weaker. It removes the possibility that you have
misread the intent.
Recipe: MG-12. The same reading also surfaces asynchronous deactivation: pausers
are counted, and when any are outstanding the code logs an error and proceeds
**[measured]**. The limitation is stated in the log text itself. Recipe: MG-19.
---
## 8. Editor loading is not shipping loading
The loading policy loads client data when not a dedicated server, and server data
when not a client-only build **[measured]**. **[derived]** In the editor neither
condition excludes, so both bundle sets load — and the authors noted the resulting
hitching in a comment beside it **[measured]**.
Every in-editor memory and load-time figure therefore describes a configuration
that ships to nobody. Directionally useful, absolutely wrong. Recipe: MG-13.
---
## 9. What source reading could not settle
- **The engine subsystems were not in the tree.** Component manager, request
handle, feature action base, feature subsystem, and the add-components action
**[open]**. Everything above about them is inferred from call sites.
- **Binary assets were not read.** Which actions the shipped feature plugins
actually configure, and with what data, is **[open]**. What *is* measured: those
plugins contain no feature-action C++ of their own, so they use only actions
from the base module and the engine **[measured]**.
- **One teardown question is genuinely undecidable from this tree.** Whether
clearing the handle array causes the manager to dispatch removal events — which
would make the narrower reset path in MG-04 harmless — depends on engine code
that was not available **[open]**. It is recorded as an open question rather than
resolved in either direction, because a plausible answer either way would be a
guess.
That last item is worth its space. It would have been easy to state MG-04 as a
confirmed defect; the honest form is "this is a defect unless an engine behaviour
we could not read compensates for it", and a reader adopting the code can settle
it in ten minutes with the engine source that they have and the audit did not.
---
## Provenance
Measured against the feature-action layer of Epic's Lyra Starter Game on Unreal
Engine 5.6, read as source in a single workspace. Source addresses stay in the
research archive that produced this skill; each `MG-` identifier resolves back to
the audited location there, so any specific claim above can be produced on
request.
## Evidence boundary
One project, one engine version, one workspace, with the engine-side subsystems
outside the readable tree. These are examples and failure evidence, not
guarantees about other versions. Claims marked open stay open. Re-run the recipes
in [failure-modes.md](failure-modes.md) against your own tree before acting on
anything here.