Files
ue-toolchain/plugins/ue-design-skills/skills/ue-gas-architecture/references/patterns.md
T
ue-toolchain dab3f35079 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

341 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Patterns: a production GAS layer read end to end
A worked reading of one real Gameplay Ability System layer — roughly 4 900 lines
across 51 files — audited as source.
This file is different in balance from the other reference documents in this
bundle. Most of them are about what a codebase got wrong. **This layer is mostly
worth copying**, and the useful output is a list of the specific decisions that
earn their keep, each with the reason it was made. The defects are in
[failure-modes.md](failure-modes.md) and are fewer than the patterns.
Markers: **[measured]** — read in source; **[derived]** — conclusion from measured
facts; **[open]** — not answerable from source, and left open.
---
## 1. The shape of the layer
Two ability system components, on different owners, for different reasons
**[measured]**:
- one on the **player state**, with the pawn as avatar — so capabilities survive
the pawn's death;
- one on the **game state**, with itself as avatar — which is what makes match
phases possible at all (§5).
The only global hooks are three config lines **[measured]**: a replacement
globals class, a cue manager class, and an explicitly empty global curve table.
**[derived]** That is a remarkably small integration surface for a layer this
size, and the reason is that everything else is composed from data assets rather
than registered globally.
---
## 2. The ability set, and why the receipt is the whole idea
An ability set is a primary data asset holding three arrays: abilities, effects,
attribute sets **[measured]**. Grants happen in the order **attributes,
abilities, effects** **[measured]** — not cosmetic, since effects in the third
group may modify attributes created by the first.
Two details make it reusable rather than merely tidy:
**The input tag lives in the grant data, not in the ability** **[measured]**. The
same ability class can be bound to different semantic inputs by different sets,
and the ability itself hides the stock input category entirely **[measured]**.
**The revocation receipt.** The grant function takes an optional out-parameter —
three parallel arrays of handles — and the revoke function removes exactly those
**[measured]**:
```text
GrantedHandles
├─ AbilitySpecHandles -> ClearAbility
├─ GameplayEffectHandles -> RemoveActiveGameplayEffect
└─ GrantedAttributeSets -> RemoveSpawnedAttribute
```
**[derived]** This is a cloakroom ticket. Whoever granted holds the ticket and can
undo exactly their own grant without touching anyone else's. It is the answer to
the question that breaks most modular gameplay implementations — *who removes
this, and how do they know it was theirs?*
Three consumers use it, and their differences are the design **[measured]**:
| Consumer | Receipt | Why |
|---|---|---|
| equipment | stored in the applied-item entry | must reverse on unequip |
| feature action | array per actor | must reverse on deactivation |
| pawn baseline on player state | **passes null** | intended to last as long as the player state |
**[derived]** The optional out-parameter is therefore an API that encodes a
lifetime decision. Passing null means "permanent, deliberately". One such call in
a project is a design; several are GA-01.
Authority is gated at the first line of both grant and revoke **[measured]**, and
invalid entries log with the asset name and index rather than crashing or
silently skipping **[measured]** — a small thing that turns a content error into a
searchable message.
---
## 3. Input as a buffered concern
Enhanced input does not call activation. It sends a semantic tag, the component
records spec handles, and one call per frame from the controller's post-process
input step does the work **[measured]**.
The ordering inside that call is the part to copy, and the reason is in the
authors' own comment **[measured]**: collect held abilities with the
while-input-active policy, then pressed abilities with the on-triggered policy,
**then activate everything in one pass**, and only then process releases. Activate
as you iterate and a held input first activates an ability and then delivers it a
press event it should never have seen.
Two more decisions worth taking:
- **A single global block tag** clears every buffer and returns early
**[measured]**. Suppressing all ability input during UI, death or stun is one
tag rather than dozens of disabled input actions.
- **Input events are replicated as events rather than directly** **[measured]**,
with comments explaining that the wait-for-input ability tasks depend on it. A
subtle choice that is easy to reverse by accident when copying.
---
## 4. Activation policy and exclusivity
Two small enums replace two large problems.
**Policy — when to activate** **[measured]**: on input triggered, while input
active, on spawn. **[derived]** Automatic fire, single fire and passives become one
enum in class defaults rather than three different implementations.
The spawn policy needs two entry points, not one — the ability granted to a live
avatar, and the avatar appearing for an already-granted ability — and the audited
implementation wires both **[measured]**, while skipping activation when the
avatar is being torn down **[measured]**.
**Group — how to relate to others** **[measured]**: independent, exclusive
replaceable, exclusive blocking. The component keeps per-group counts, cancels
other replaceable exclusives when an exclusive starts, and asserts that no more
than one exclusive is ever active **[measured]**.
**[derived]** Without this you express "firing cancels reloading, but the finisher
cancels nothing" as an N×N matrix of blocking tags on every ability. With it, one
enum plus one behaviour tag.
One invariant is enforced rather than documented: an ability in the replaceable
group **cannot** refuse cancellation — the call logs an error and does nothing
**[measured]**. **[derived]** That combination is a contradiction, and catching it
in code rather than in review is the difference between a rule and a convention.
The death ability is the worked example **[measured]**: on activation it cancels
everything not tagged as surviving death, refuses cancellation, and *moves itself*
into the blocking group for the duration.
---
## 5. Match phases as abilities
The highest return per line in the layer: about 330 lines of C++ for a
hierarchical match state machine **[measured]**.
A phase is an ability running on the game state's ability system component. While
the ability is active, the phase is active. The ability's defaults differ from the
base in exactly the two ways that matter **[measured]**: server-initiated
execution and server-only security. A client cannot start a phase.
**Hierarchy comes from tag nesting, and that is the entire rule** **[measured]**.
On starting a phase, every active phase whose tag does not match the incoming tag
is cancelled:
| Active | Starting | Result |
|---|---|---|
| `Game.Playing` | `Game.Playing.SuddenDeath` | parent stays, child starts |
| `Game.Playing`, `Game.Playing.SuddenDeath` | `Game.Playing.Overtime` | sibling ends, parent stays |
| `Game.Playing.*` | `Game.PostGame` | whole subtree ends |
| `Game.Playing` | second ability, same tag | both run |
**Parents and children coexist; siblings do not.**
What the pattern gets for free, because a phase is an ordinary ability
**[derived]**: ability tasks as phase timers and triggers, effects applied for the
phase's duration, cancellation by the same rules as anything else, and all of the
per-mode logic authored in data rather than in new C++.
One observer detail is worth copying deliberately: subscribing to a phase that is
**already active** fires the callback immediately **[measured]**. That closes the
classic race where a system subscribes after the phase started and waits forever.
The end-observer path has no equivalent **[measured]**, which is consistent —
there is no "already ended" to catch up on.
### And two limitations the authors recorded themselves
- **Observers return no handle** **[measured]**, with two consecutive comments
above the function saying so — including the honest second thought that a handle
would not help if callers ignored it. GA-05.
- **The subsystem's creation gate is commented out** **[measured]**, leaving an
unconditional `return true`. So the subsystem exists on clients, where the
active-phase map is always empty and the phase query always answers false.
**[derived]** Client code must learn about phases through replicated tags,
effects or messages — never through this subsystem. GA-04.
---
## 6. Attribute sets split by direction
Three classes on a clean axis **[measured]**:
- a base providing accessor macros and a six-parameter change delegate whose
comment honestly warns that some parameters are null on clients;
- a **receiving** set: health, max health, and the meta attributes damage and
healing;
- a **source** set: base damage, base heal.
The executions make the split structural rather than nominal **[measured]**: they
capture the source set from the instigator and write into the receiving set on the
target. **[derived]** The same actor usually owns both, but their roles in a
calculation are different, and that difference lives in the class layout instead
of a comment.
### The meta-attribute pattern
Incoming damage arrives in a non-replicated attribute, converts to a health change
in post-execution, and resets to zero in the same call **[measured]**.
**[derived]** Damage and healing become one-shot channels rather than state. They
need no replication, no manual reset, and no "current damage" that a reader might
mistake for something durable. Health and damage are additionally marked as hidden
from modifiers **[measured]** — so only an execution can change them, enforced by
the editor rather than by agreement.
### Three interception levels, each doing one job
**[measured]**: a pre-execution veto that can cancel the whole effect (where
damage immunity and a development-only god mode live, both respecting a
self-destruct exception so suicide still works); a post-execution step that
converts meta attributes, broadcasts change delegates and publishes a damage
message; and clamping applied in both change hooks with a shared helper.
A latch flag prevents a duplicate out-of-health broadcast and resets when health
recovers **[measured]** — the correct shape for GA-10.
### Where the damage number comes from
Four multiplications **[measured]**: base damage, distance attenuation, physical
material attenuation, and a team multiplier that is **0 or 1 rather than a
branch**. The two attenuation curves come from an interface implemented by the
damage *source* — the weapon — rather than from the execution. **[derived]** Falloff
therefore lives in weapon data where a designer owns it, and the execution stays
generic.
---
## 7. The death seam: three responsibilities, three classes
```text
effect execution changes Health
→ receiving set broadcasts out-of-health
→ health component sends a death gameplay event
→ death ability runs the sequence
→ health component enters DeathStarted, then DeathFinished
```
**[measured]** throughout. The division is the point:
- **GAS owns the number** — how much health, and the signal that it reached zero;
- **the health component owns the state** — a replicated enum with its own
replication callback that can roll back a predicted transition and logs invalid
ones;
- **the death ability owns the sequence** — what plays, when control returns.
**[derived]** None of the three knows the others' internals; they communicate
through delegates and a gameplay event.
Three details worth taking:
- the death trigger is configured in the ability's **class defaults**
**[measured]**, so exactly one path can start it;
- the ability's end path **always** calls the finish transition **[measured]** —
insurance against a designer-authored sequence that does not reach the end;
- death state is neither an attribute nor a tag but a replicated enum
**[measured]**; the tags exist too, set loosely, because the state is already
replicated by other means.
Alongside, a damage message and an elimination message go out over a message bus
**[measured]**, so kill feeds, statistics and accolades never reference GAS.
---
## 8. Two more things worth copying
**Composable costs.** The stock system offers one cost effect. Here, an ability
holds a list of inline cost objects, each with its own check, its own application,
its own failure tag, and an optional "only charge on hit" rule whose hit
determination is computed once, cached, and only on authority **[measured]**.
**[derived]** Ammunition, inventory items and player resources become three small
classes rather than three special cases.
**Failure reasons that reach the player.** The stock system swallows why an
activation failed. Here the component notifies the owning client when the avatar
is not locally controlled, the ability maps failure tags to user-facing text and
to animations through two data maps, and delivery goes over a message bus
**[measured]**. **[derived]** "Out of ammo" on the HUD costs no bespoke RPC, and
the UI never learns that GAS exists. GA-14.
---
## 9. Copy carefully
Two places where the audited implementation is explicitly provisional, and says so:
- **The relationship mapping is a linear array walk**, consulted on every
activation and on every block-and-cancel application, with the same "simple
iteration for now" comment in three separate functions **[measured]**.
**[derived]** Fine at the size measured; profile before assuming it scales.
GA-08.
- **Three function-static containers** are used as scratch space in a function on
the activation path **[measured]**. **[derived]** Correct on one thread, not
reentrant, and nothing at the call site says so. GA-07.
And one thing that reads as finished and is not: attribute initialization is a
placeholder that assigns health from max health in code, with a comment saying a
data-driven source will replace it **[measured]**. The global curve table is
configured to none **[measured]**, and the initialization-data field on the grant
structure has no traced consumer **[open]**. GA-11.
---
## 10. What source reading could not settle
- **Binary assets were not read.** The concrete phase abilities, the ability sets,
the contents of the relationship mapping for each archetype, and the activation
groups configured on real abilities are all **[open]**.
- **Who starts the first phase is unknown** **[open]**. A search for the start
call finds only the subsystem itself, so the caller is in a scripted asset.
- **One field could not be classified from this layer alone**: a
cancellation-logging flag is declared and assigned in the constructor and read
nowhere in the audited directories **[measured]**. Whether something outside them
reads it is **[open]** — though its own comment describes it as temporary,
added while tracking a bug. GA-06.
---
## Provenance
Measured against the GAS 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 `GA-` 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. Binary assets were not read, so
every statement about authored content is marked open rather than concluded. The
architecture is transferable; the specific defects are evidence, not guarantees
about other versions. Re-run the recipes in
[failure-modes.md](failure-modes.md) against your own tree before acting on
anything here.