Files
ue-toolchain/plugins/ue-design-skills/skills/ue-modular-gameplay/SKILL.md
T
MagentaDolphin ecd87ac96d 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>
2026-09-05 23:48:55 +07:00

426 lines
15 KiB
Markdown

---
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.