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,502 @@
# Failure modes: GAS architecture
Fourteen ways a Gameplay Ability System layer misbehaves without reporting
anything.
The shared property, stated once: **GAS failures present as absence.** An ability
that does not activate, a phase query that answers false, a grant that is never
revoked, a cost that is never charged. The framework is built to tolerate a
refused activation, because a refused activation is a normal gameplay state. So
the diagnostic burden is entirely yours, and the useful question is never "did it
error" but "which side of the seam owns this fact".
Each entry gives the mechanism, why it stays silent, why the obvious check misses
it, the symptom a human reports, a `Detect` recipe, and the guardrail.
Identifiers (`GA-01` and up) are stable and resolve back to the audited source in
the research archive.
Recipes use `rg` and run from a project's source root. They were executed against
the audited project while this file was written.
---
## Ownership and receipts
### GA-01 - Grant with no receipt, permanent by omission
**Mechanism.** An ability set is granted with the out-parameter for handles left
null. The grant then lasts as long as the ability system component.
**Why it is silent.** Permanent is a legitimate lifetime, and the API is designed
to offer it. Nothing distinguishes "permanent because we decided" from "permanent
because the argument was easy to omit".
**Why the obvious check misses it.** The call is correct, the API is used as
documented, and the reviewer sees a grant that works. The defect only exists
relative to an intention that lives in nobody's head at review time.
**Symptom.** A capability that should have been removed with the feature, the
equipment or the round survives it. Usually noticed after a respawn, when the
player has two of something.
**Detect.** List every grant site and classify it by whether it keeps a receipt:
```bash
rg -n -B3 "GiveToAbilitySystem" --glob "*.cpp" .
```
Any call passing `nullptr` for the handles parameter is a permanent grant. In the
audited project this is exactly one site — the pawn baseline on the player state —
and the equipment path beside it passes a receipt stored in its applied-item
entry. One permanent grant with a stated reason is a design; several are a leak.
**Guardrail.** Make permanence explicit at the call site, in a comment naming the
lifetime the grant is tied to. Review new grant sites against the table of
allowed permanent grants rather than against the API signature.
---
### GA-02 - Revocation that removes more than it granted
**Mechanism.** Teardown clears abilities by class, by tag, or by clearing all
abilities on the component, instead of by the handles the grant returned.
**Why it is silent.** The abilities the caller wanted removed *are* removed. The
extra removals affect capabilities another owner granted, and that owner is not
watching.
**Why the obvious check misses it.** The teardown function looks thorough. "Remove
everything matching this class" reads as more robust than "remove these three
handles", not less.
**Symptom.** Unequipping one item removes an ability granted by a different
feature. Reproduces only when two owners granted overlapping capabilities, which
is rare in a test and common in a shipped game.
**Detect.** Find removals that are not keyed on stored handles:
```bash
rg -n "ClearAbility\(|ClearAllAbilities|RemoveActiveGameplayEffect" --glob "*.cpp" . -B5 \
| rg -n "(GetAssetTags|StaticClass|ForEach|\.Class)"
```
Removal keyed on anything other than a handle produced by the matching grant is
the finding.
**Guardrail.** Every revoke path takes a receipt and removes exactly its contents.
If a receipt was not kept, the correct fix is to keep one, not to widen the
removal.
---
### GA-03 - Grant order that applies effects before their attributes exist
**Mechanism.** An ability set grants effects before it grants attribute sets, so
an effect modifies an attribute that has not been added yet.
**Why it is silent.** Applying a modifier to a missing attribute is not an error
in the framework — the modifier simply finds nothing to modify. The effect is
applied, the handle is valid, the log is clean.
**Why the obvious check misses it.** Each block of the grant function is correct
in isolation, and the whole function reads as a straightforward three-part loop.
Order dependencies between the parts are invisible unless you know the effects
reference the attributes.
**Symptom.** An attribute sits at its default value despite an effect that should
have initialized it. Investigated as a bad effect asset.
**Detect.** Read the grant function and confirm the sequence:
```bash
rg -n -A40 "::GiveToAbilitySystem" --glob "*.cpp" . \
| rg -n "(AddAttributeSetSubobject|GiveAbility|ApplyGameplayEffect)"
```
The line numbers must run attributes, then abilities, then effects. The audited
implementation gets this right and the ordering is worth copying verbatim.
**Guardrail.** State the order as a comment in the grant function, and add a test
that grants a set whose effect initializes an attribute the same set provides.
---
## State that lies
### GA-04 - Server-only subsystem queried from clients
**Mechanism.** Phase abilities are configured server-only, and the subsystem that
tracks them is created on every client anyway. Its map of active phases is
therefore always empty on a client, and the "is this phase active" query always
returns false there.
**Why it is silent.** False is a valid answer to that question. The client is not
told that it asked a question it cannot answer; it is told "no".
**Why the obvious check misses it.** The subsystem exists on the client, is
correctly initialized, and answers immediately. Every structural check passes.
The creation gate that would have prevented it exists too — commented out, with
the unconditional `return true` left in place. Reading that function tells you
someone thought about it, not what they concluded.
**Symptom.** Client-side UI that reacts to match phase never reacts. Blamed on
replication, on the widget, on the tag — rarely on the query itself.
**Detect.** Find subsystem creation gates that were considered and abandoned:
```bash
rg -n -A12 "ShouldCreateSubsystem" --glob "*.cpp" . | rg -n "^\s*//.*(return|check|World)"
```
Then, for every server-authoritative subsystem, check whether any client code
path queries its state:
```bash
rg -n "IsPhaseActive|IsRoundActive|GetCurrentPhase" --glob "*.cpp" .
```
**Guardrail.** A subsystem whose state is authority-only either refuses to be
created without authority, or its queries return a tri-state that distinguishes
"no" from "I cannot know". Clients observe phase through replicated tags,
replicated state or messages — never through the authoritative tracker.
---
### GA-05 - Observer registration with no handle
**Mechanism.** A subscription API appends a callback to an array and returns
nothing. There is no way to unsubscribe.
**Why it is silent.** Subscriptions work. The list grows, and a growing list has
no symptom until it is large or until a captured object needed to die.
**Why the obvious check misses it.** The API is complete from the caller's
perspective: subscribe, get called back. The missing half is a function that does
not exist, and reviews do not notice absent functions.
**Symptom.** Callbacks firing on objects that logically ended, and a list that
grows until the world resets. In the audited project the authors documented this
themselves, in two consecutive comments above the function, and shipped it —
including the honest second thought that a handle would not help if callers
ignored it.
**Detect.** Find subscription functions that return void:
```bash
rg -n "void \w+::When\w+|void \w+::(Register|Subscribe|Observe)\w*\(" --glob "*.cpp" .
```
Any registration whose return type is void is a subscription that cannot be
undone.
**Guardrail.** Registration returns a handle; the handle unregisters. If callers
cannot be trusted to hold handles, bind weakly *and* sweep expired entries — but
the sweep is an addition to the handle, not a substitute for it.
---
### GA-06 - Write-only field
**Mechanism.** A member is declared and assigned in the constructor, and read
nowhere.
**Why it is silent.** It is a correctly initialized member of a working class. It
costs nothing at runtime and breaks nothing.
**Why the obvious check misses it.** Searching for the name finds two hits — a
declaration and an assignment — which reads as "declared and used". An
unused-variable warning does not fire, because the write *is* a use.
**Symptom.** No runtime symptom. The cost is comprehension: the field's name
promises behaviour, and a future engineer will implement against a flag that
nothing consumes. In the audited project the field is a cancellation-logging flag
whose own comment describes it as temporary, added while tracking a bug that has
since closed.
**Detect.** Compare writes against reads for suspicious members:
```bash
F='bLogCancelation'
rg -n "\b$F\b" --glob "*.h" --glob "*.cpp" .
```
Two hits — declaration and assignment — with no comparison, no branch and no
pass-by-value is the finding. This is the mirror image of the missing-writer
check: here the writer exists and the reader does not.
**Guardrail.** Delete it. A flag with no consumer is a comment that pretends to be
code, and it will eventually be believed.
---
## Performance and correctness on the activation path
### GA-07 - Function-static containers on a per-activation path
**Mechanism.** A function called during every ability activation declares
function-local `static` containers as scratch space to avoid reallocation.
**Why it is silent.** It is correct on a single thread, and every activation
happens on the game thread today. The reuse is invisible because each call clears
before use.
**Why the obvious check misses it.** The pattern looks like a deliberate
optimization, and it is one. Nothing at the call site says "this function is not
reentrant", and reentrancy through an ability that activates another ability is
plausible but not obvious.
**Symptom.** Nothing, until the function is called from a second thread or
reentrantly — at which point tag requirements are computed from another call's
scratch data and an ability activates when it should not.
**Detect.** Find statics in functions on the activation path:
```bash
rg -n "static F\w+Container|static TArray" --glob "*.cpp" . -B10 \
| rg -n "(CanActivate|SatisfyTagRequirements|ActivateAbility)"
```
The audited project has three such statics in one activation-path function.
**Guardrail.** Use a member scratch buffer on an instanced object, or accept the
allocation. If the static stays, document the single-thread and
non-reentrancy assumption at the declaration, where the next reader will see it.
---
### GA-08 - Linear scan marked provisional, on the hot path
**Mechanism.** A relationship or lookup table is walked linearly on every
activation, with a comment acknowledging that a real index would be needed at
scale.
**Why it is silent.** It is correct at every size. Only the cost changes, and cost
does not report itself.
**Why the obvious check misses it.** The comment is the check, and it is
addressed to a future that has not arrived. Reviewers read "for now" as "someone
is tracking this".
**Symptom.** Activation cost grows with the size of a designer-authored data
asset, discovered during a late-project profiling pass when the table has grown
by two orders of magnitude.
**Detect.** Find provisional comments on lookup paths:
```bash
rg -n "Simple iteration|for now|O\(n\)|linear" --glob "*.cpp" . -A4 \
| rg -n "(for\s*\(|ForEach)"
```
In the audited project the same provisional comment appears three times in one
mapping class, and the mapping is consulted on every activation and on every
block-and-cancel application.
**Guardrail.** Record the size at which the structure must change, and add an
assertion or a test that fails when the data crosses it. "For now" without a
number never ends.
---
## Attributes and damage
### GA-09 - Meta attribute treated as durable state
**Mechanism.** An incoming-damage or incoming-healing attribute — designed as a
one-shot channel that converts to a health change and resets to zero — is read,
replicated or stored as if it held a value between executions.
**Why it is silent.** The attribute exists and always has a value. Reading it
outside an execution returns zero, which is a plausible number.
**Why the obvious check misses it.** Nothing in the type distinguishes a meta
attribute from a state attribute. The distinction lives in a comment and in the
post-execution code that zeroes it.
**Symptom.** UI or logic that reads "current damage" and always sees zero, or a
replicated attribute that costs bandwidth and carries nothing.
**Detect.** Confirm each meta attribute is zeroed after conversion and excluded
from replication:
```bash
rg -n "SetDamage\(0|SetHealing\(0" --glob "*.cpp" .
rg -n "DOREPLIFETIME.*\b(Damage|Healing)\b" --glob "*.cpp" .
```
A meta attribute that appears in the replication list, or one that is never
zeroed, is the finding.
**Guardrail.** Group meta attributes under an explicit comment banner in the
header, keep them out of the replication list, and mark state attributes that
executions alone may change so the editor enforces it rather than the team
remembering it.
---
### GA-10 - Out-of-health fired more than once for one death
**Mechanism.** The zero-health signal is emitted from a change handler that can
run several times as clamping, max-health changes and replication settle.
**Why it is silent.** Each individual emission is correct. Listeners are expected
to handle a broadcast; nothing says "at most once per life".
**Why the obvious check misses it.** The emitting code is a single line in a
single place. The multiplicity comes from how many paths reach it, which is
visible only by enumerating callers of the enclosing handler.
**Symptom.** Two death sequences, doubled elimination messages, score credited
twice. Often first noticed in the kill feed rather than in gameplay.
**Detect.** Find the emission and check for a latch:
```bash
rg -n -B12 "OnOutOfHealth.Broadcast" --glob "*.cpp" . | rg -n "bOutOfHealth|bAlready|if\s*\(!"
```
An emission with no guarding flag, or a flag that is never reset when health
recovers, is the finding. The audited implementation has both the latch and the
reset — copy that shape.
**Guardrail.** Latch the signal, reset the latch when the value recovers, and
assert in development that the death transition runs once per life.
---
### GA-11 - Placeholder attribute initialization
**Mechanism.** Attributes are initialized by assigning one to another in code —
health set to max health — with a comment saying a data-driven initialization
will replace it.
**Why it is silent.** The result is a live, plausible character. Nothing about a
full-health spawn looks provisional.
**Why the obvious check misses it.** The data-driven mechanism usually *exists*
alongside it — an initialization-data field on the grant structure, a curve table
setting — so a reviewer looking for "is initialization data-driven?" finds the
machinery and stops.
**Symptom.** Designer-authored initialization values have no effect, because the
code path that would consume them is bypassed by the placeholder.
**Detect.** Find initialization marked as temporary, then check whether the
data-driven path has any consumer:
```bash
rg -n "TEMP|placeholder|Eventually this will" --glob "*.cpp" . -A3 \
| rg -n "(SetNumericAttributeBase|InitFromMetaDataTable|InitStats)"
rg -n "GlobalCurveTableName" Config/
```
In the audited project the placeholder is present, the global curve table is set
to none, and the initialization-data field on the grant structure has no traced
consumer.
**Guardrail.** A placeholder initialization is acceptable only with the
data-driven path deleted or disabled, so nobody can author data that silently
does nothing.
---
## Lifecycle
### GA-12 - Activation group counters that do not balance
**Mechanism.** A per-group active count is incremented on activation and
decremented on end. Any path that ends an ability without notifying the component
leaks a count.
**Why it is silent.** A leaked count does not error. It makes the group look
occupied, so later activations are refused — which is indistinguishable from
correct exclusivity.
**Why the obvious check misses it.** Both the increment and the decrement exist
and are correctly paired in the normal path. The leak comes from an abnormal end:
a destroyed avatar, a cancelled ability that refuses cancellation, an early return.
**Symptom.** After some sequence involving death or a cancelled ability, exclusive
abilities stop activating for that pawn. Restarting the match fixes it, which
makes it look like corruption rather than arithmetic.
**Detect.** Find increments and decrements and confirm they are in paired
notification hooks:
```bash
rg -n "ActivationGroupCounts" --glob "*.cpp" . -B4
```
Any mutation outside the activated/ended notification pair is a leak candidate.
Then assert the invariant directly: the exclusive counts must never exceed one in
total.
**Guardrail.** Assert the invariant in development builds at every mutation, and
test the abnormal ends explicitly — avatar destroyed mid-ability, cancel refused,
ability ended from a task callback.
---
### GA-13 - Death represented only as a health comparison
**Mechanism.** Consumers ask "is health at or below zero" instead of reading a
durable death state.
**Why it is silent.** The comparison is true at the right moments. It is also true
during the window before the death sequence starts, and true again if health is
restored and re-zeroed within one sequence.
**Why the obvious check misses it.** The comparison is simple, local and obviously
correct. The state machine it replaces lives in another component, and using it
requires knowing it exists.
**Symptom.** Systems that disagree about whether a character is dead, especially
across the network, and a predicted death that cannot be corrected because there
is no state to roll back.
**Detect.** Find health comparisons used as death tests:
```bash
rg -n "GetHealth\(\)\s*<=?\s*0|Health\s*<=\s*0\.?0?f?" --glob "*.cpp" .
```
Every hit outside the attribute set itself should be reading a death state
instead.
**Guardrail.** One replicated, monotonic death state with an explicit
start and finish, owned by a domain component, with the ability system supplying
only the numeric signal that triggers it.
---
### GA-14 - Failure reason discarded at the seam
**Mechanism.** Activation fails, the framework records a reason, and the project
does not forward it — or forwards it only on the server, where no UI exists.
**Why it is silent.** A refused activation is normal. The player sees nothing,
which is exactly what a refused activation looks like when the reason is missing.
**Why the obvious check misses it.** The failure path exists and is exercised
constantly. What is absent is the delivery of the reason to the machine that can
render it, and absence of delivery has no call site to review.
**Symptom.** The player presses a button and nothing happens, with no feedback
distinguishing "on cooldown" from "out of ammo" from "you are dead". Designers
compensate with generic click sounds.
**Detect.** Check that failure tags are both produced and delivered to the owning
client:
```bash
rg -n "OptionalRelevantTags|NotifyAbilityFailed|ActivateFail" --glob "*.cpp" . -A6 \
| rg -n "(Client|Broadcast|Message)"
```
Failure tags produced with no client-notification path is the finding. The audited
project does this correctly: a client notification carries the tags, the ability
maps them to text and animation in data, and delivery goes over a message bus so
the UI never references GAS.
**Guardrail.** Every failure reason reaches the locally controlled player as a
tag, never as a log line, and the mapping from tag to feedback lives in data.
@@ -0,0 +1,340 @@
# 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.