feat(skills): ship ue-design-skills bundle, licensing and delivery gate
Phase 0 of the handoff plan, as a marketplace rather than a flat skills/ directory. Content moved out of the LyraResearch archive and depersonalised: addresses stay in the archive, recipes ship. - plugins/ue-design-skills: 17 skills, 232 failure-mode entries, each with the six required fields; catalog.json as the harness-neutral source of truth and .claude-plugin/ as one adapter over it. - _gate: 16 rules, one poisoned fixture per rule, plus surface coverage so a declared file cannot silently miss the line rules. - ADR-0002 (harness-neutral bundle behind a marketplace) and ADR-0003 (split licensing: CC BY-ND 4.0 prose, Apache-2.0 code and metadata). - LICENSE files at both levels, CONTRIBUTING.md, docs/licensing-options.md as the material the licence decision grew from. Verified: gate.py 0 violations; test_gate.py 16/16 rules redden on their fixtures with a clean baseline and 2 root files reaching the line rules. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,452 @@
|
||||
---
|
||||
name: ue-gas-architecture
|
||||
description: >-
|
||||
Design or review production Gameplay Ability System architecture in Unreal
|
||||
Engine: ability packages and revocation receipts, buffered ability input on
|
||||
the ability system component, activation policies and exclusivity groups,
|
||||
attribute set responsibility boundaries, gameplay effect contexts, tag
|
||||
relationship policy, match phases as abilities, and the health/death seam.
|
||||
Use when adding combat, abilities, equipment-granted powers, phases or rounds,
|
||||
damage and healing, or diagnosing ability activation and lifecycle bugs.
|
||||
---
|
||||
|
||||
# UE GAS architecture
|
||||
|
||||
The invariant:
|
||||
|
||||
> GAS owns capabilities and effects; domain components own durable gameplay
|
||||
> state; data packages compose capabilities; every temporary grant keeps a
|
||||
> receipt.
|
||||
|
||||
Worked analysis of a production GAS layer, including the parts worth copying
|
||||
verbatim: [patterns](references/patterns.md).
|
||||
Detection recipes for silent GAS failures: [failure modes](references/failure-modes.md).
|
||||
|
||||
Related skills: `ue-data-driven-architecture`, `ue-input-architecture`,
|
||||
`ue-modular-gameplay`, `ue-cosmetics-and-teams`, `ue-multiplayer-authority`.
|
||||
|
||||
Method, not architecture — how to check any claim in this bundle before repeating it: `ue-evidence-discipline`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Package grants as ability sets
|
||||
|
||||
Do not scatter calls to grant abilities, apply effects and add attribute sets
|
||||
across pawns, equipment and modes. Define one immutable capability package:
|
||||
|
||||
```text
|
||||
AbilitySet
|
||||
├─ abilities: class + level + optional semantic input tag
|
||||
├─ gameplay effects: class + level
|
||||
└─ attribute sets: class
|
||||
```
|
||||
|
||||
The same package can be granted by pawn data at spawn, by an equipment
|
||||
definition while equipped, by a feature action for a mode, by a pickup, or by a
|
||||
test fixture.
|
||||
|
||||
### Grant order is not cosmetic
|
||||
|
||||
Attributes, then abilities, then effects. Effects in the third group may modify
|
||||
attributes created by the first; the reverse order silently applies modifiers to
|
||||
attributes that do not exist yet.
|
||||
|
||||
### Grant only on authority
|
||||
|
||||
The package's grant function must reject non-authority mutation at its first
|
||||
line. Clients receive replicated specs, effects and attributes; they do not
|
||||
author grants.
|
||||
|
||||
### Return a grant receipt
|
||||
|
||||
```text
|
||||
GrantedHandles
|
||||
├─ AbilitySpecHandles
|
||||
├─ ActiveGameplayEffectHandles
|
||||
└─ GrantedAttributeSet pointers
|
||||
```
|
||||
|
||||
The revoke function removes exactly those resources. This is what prevents one
|
||||
equipment item from revoking another owner's grants — and it is the single most
|
||||
reusable piece of a GAS layer.
|
||||
|
||||
### Decide permanence explicitly
|
||||
|
||||
Passing no receipt means "grant for the lifetime of the ability system
|
||||
component". That is a legitimate design choice, and it must be a choice rather
|
||||
than an omission. Review every caller:
|
||||
|
||||
| Caller | Receipt required? |
|
||||
|---|---|
|
||||
| pawn baseline from pawn data | optional, permanent by design |
|
||||
| equipment | yes |
|
||||
| feature action | yes, per actor and per activation context |
|
||||
| temporary pickup | yes, or rely on effect duration handles |
|
||||
| test or cheat command | yes |
|
||||
|
||||
A single reference implementation demonstrates both: the pawn baseline passes
|
||||
nothing because it is meant to last as long as the player state, and equipment
|
||||
passes a receipt stored in its own applied-item entry.
|
||||
|
||||
---
|
||||
|
||||
## 2. Treat input as a buffered concern of the ability system component
|
||||
|
||||
Input events should not call activation directly. Send a semantic input tag to
|
||||
the ability system component and process once per frame after player input:
|
||||
|
||||
```text
|
||||
InputAction
|
||||
→ InputTag pressed/released
|
||||
→ ASC records spec handles
|
||||
→ PlayerController.PostProcessInput
|
||||
→ ASC.ProcessAbilityInput
|
||||
→ activation policy chooses the activation set
|
||||
→ TryActivateAbility
|
||||
```
|
||||
|
||||
What this buys:
|
||||
|
||||
- simultaneous inputs resolve together instead of in binding order;
|
||||
- held input is distinguishable from newly pressed input;
|
||||
- activation can be suppressed globally for a frame;
|
||||
- activation policy belongs to the ability rather than to each binding site.
|
||||
|
||||
### Required buffers
|
||||
|
||||
Held spec handles, pressed this frame, released this frame. Clear the transient
|
||||
buffers after processing, and drop stale handles when specs are removed.
|
||||
|
||||
### Order within the frame matters
|
||||
|
||||
Collect held abilities first, then pressed, then activate everything in one pass,
|
||||
and only then process releases. Activating as you iterate means a held input can
|
||||
activate an ability and then immediately deliver it a press event it should never
|
||||
have seen.
|
||||
|
||||
### Add a global input block tag
|
||||
|
||||
When a project-defined "ability input blocked" tag is present on the component:
|
||||
clear the buffers, activate nothing, and define explicitly whether already-active
|
||||
held abilities receive a release or a cancel. One tag is cheaper and far more
|
||||
reviewable than disabling dozens of input actions during UI, death or stun.
|
||||
|
||||
---
|
||||
|
||||
## 3. Put activation rules on the ability
|
||||
|
||||
### Activation policy — when
|
||||
|
||||
- `OnInputTriggered` — activate once when pressed;
|
||||
- `WhileInputActive` — activate and retry while held;
|
||||
- `OnSpawn` — activate when granted and the avatar is ready.
|
||||
|
||||
Do not infer these from the input event type at the binding site. The ability
|
||||
knows its own lifecycle; the binding does not.
|
||||
|
||||
`OnSpawn` needs two entry points, not one: the ability may be granted to a live
|
||||
avatar, or the avatar may appear for an already-granted ability. Handle both, and
|
||||
skip activation when the avatar is being torn down.
|
||||
|
||||
### Activation group — concurrency
|
||||
|
||||
- `Independent` — unaffected by others;
|
||||
- `Exclusive_Replaceable` — exclusive, but another exclusive may displace it;
|
||||
- `Exclusive_Blocking` — exclusive and prevents other exclusives from starting.
|
||||
|
||||
The component tracks active counts per group and enforces transitions. This
|
||||
replaces an N×N web of per-ability blocking tags for broad concurrency rules.
|
||||
|
||||
Rules:
|
||||
|
||||
- an ability may enter a group only if not blocked;
|
||||
- entering an exclusive group cancels other replaceable exclusives;
|
||||
- group counts must balance across activate and end;
|
||||
- an ability in a replaceable group must not be able to refuse cancellation —
|
||||
that combination is a contradiction and should log rather than obey;
|
||||
- changing group at runtime is explicit and validated.
|
||||
|
||||
Death is the clearest worked example: on activation it cancels everything not
|
||||
explicitly marked as surviving death, refuses cancellation, and moves itself into
|
||||
the blocking group for the duration.
|
||||
|
||||
---
|
||||
|
||||
## 4. Move tag relationships into a policy asset
|
||||
|
||||
Abilities carry their intrinsic tags. A pawn or archetype selects a relationship
|
||||
map:
|
||||
|
||||
```text
|
||||
AbilityTag
|
||||
├─ tags to block
|
||||
├─ tags to cancel
|
||||
├─ activation required tags
|
||||
└─ activation blocked tags
|
||||
```
|
||||
|
||||
This removes repeated policy from N ability defaults and lets the same ability
|
||||
classes run under different archetype rules. It matters most when abilities
|
||||
arrive from separate feature plugins that must not know about each other.
|
||||
|
||||
### Relationship validation
|
||||
|
||||
- [ ] the key tag is valid and appropriately scoped;
|
||||
- [ ] parent-tag matching is intentional, not incidental;
|
||||
- [ ] block and cancel are not confused with each other;
|
||||
- [ ] required and blocked sets are not contradictory;
|
||||
- [ ] cycles are documented and tested;
|
||||
- [ ] every archetype that needs policy actually selects a map.
|
||||
|
||||
A linear scan is acceptable for a small matrix. Profile before assuming it scales:
|
||||
in the measured reference the lookup is a linear array walk performed on every
|
||||
activation and on every block/cancel application, and its authors marked it as
|
||||
provisional in three separate places.
|
||||
|
||||
---
|
||||
|
||||
## 5. Split attribute sets by direction of responsibility
|
||||
|
||||
Do not create one character-attributes dumping ground.
|
||||
|
||||
**Receiving set.** Health, max health, the meta attributes damage and healing,
|
||||
clamping, and the out-of-health signal. Damage and healing are transient inputs
|
||||
to execution, not durable replicated state: they arrive, convert into a health
|
||||
change, and reset to zero in the same call.
|
||||
|
||||
**Source set.** Base damage, base heal, outgoing coefficients — the values
|
||||
captured *from the instigator*.
|
||||
|
||||
Execution calculations capture the source set from the instigator and the
|
||||
receiving set from the target. The split makes "who contributes this value"
|
||||
structural rather than a comment.
|
||||
|
||||
### Gates on the receiving set
|
||||
|
||||
- clamp in the correct hook, and in all of them that can change the value;
|
||||
- guard against duplicate out-of-health events with an explicit flag that resets
|
||||
when the value recovers;
|
||||
- record pre-execution values when messages need a before-and-after;
|
||||
- notify domain components through delegates;
|
||||
- keep game-flow state — death started, death finished — out of the attribute set
|
||||
entirely.
|
||||
|
||||
### Mark what modifiers must not touch
|
||||
|
||||
Attributes that may only be changed by an execution should say so in metadata.
|
||||
This is the difference between "we agreed not to modify health directly" and a
|
||||
rule the editor enforces.
|
||||
|
||||
---
|
||||
|
||||
## 6. Extend the gameplay effect context for combat metadata
|
||||
|
||||
When damage needs source or target data beyond the stock context, derive a
|
||||
project context and implement allocation, script-struct identity, duplication and
|
||||
network serialization.
|
||||
|
||||
Store only what must survive execution and replication. A context is not a place
|
||||
to hang a mutable combat object graph.
|
||||
|
||||
Validation:
|
||||
|
||||
- [ ] the context survives duplication with its custom fields intact;
|
||||
- [ ] custom fields serialize deterministically;
|
||||
- [ ] object references are network-safe, and fields that are deliberately not
|
||||
replicated are documented as server-only at their declaration;
|
||||
- [ ] execution calculations handle absent hit data;
|
||||
- [ ] script exposure does not permit invalid mutation.
|
||||
|
||||
---
|
||||
|
||||
## 7. Separate numeric death detection from the death lifecycle
|
||||
|
||||
```text
|
||||
effect execution changes Health
|
||||
→ receiving set emits out-of-health
|
||||
→ health component sends a death gameplay event
|
||||
→ death ability starts
|
||||
→ health component enters DeathStarted, then DeathFinished
|
||||
→ game mode handles respawn
|
||||
```
|
||||
|
||||
Responsibilities: GAS owns the *number*; the health component owns the *state*
|
||||
and its replication; the death ability owns the *sequence*; the game mode owns
|
||||
the player lifecycle.
|
||||
|
||||
Do not represent death only as "health is zero". Consumers need a monotonic state
|
||||
they can observe over the network, and a predicted transition needs somewhere to
|
||||
be corrected.
|
||||
|
||||
### The death trigger must be unambiguous
|
||||
|
||||
Configure it in the ability's class defaults so exactly one path can start it, and
|
||||
have the ability's end path force the final transition — insurance against a
|
||||
designer-authored sequence that does not reach the end.
|
||||
|
||||
---
|
||||
|
||||
## 8. Model match phases as abilities when the lifecycle fits
|
||||
|
||||
A phase ability runs on the ability system component of the game state:
|
||||
|
||||
```text
|
||||
Game.Playing
|
||||
├─ Game.Playing.Warmup
|
||||
├─ Game.Playing.SuddenDeath
|
||||
└─ Game.PostGame
|
||||
```
|
||||
|
||||
Starting a phase grants and activates the ability, registers its tag, cancels
|
||||
incompatible siblings, and notifies observers. Ending the ability ends the phase.
|
||||
|
||||
Hierarchical tags give the rule for free: **parents and children coexist, siblings
|
||||
do not.** Starting `Game.Playing.SuddenDeath` while `Game.Playing` is active
|
||||
leaves the parent running; starting `Game.PostGame` ends the whole subtree.
|
||||
|
||||
### Use this pattern when
|
||||
|
||||
- the phase has a real enter/active/exit lifecycle;
|
||||
- ability tasks, effects and tags are natural fits for its logic;
|
||||
- the server owns transitions;
|
||||
- observers want tag-based matching rather than an enum switch.
|
||||
|
||||
### Do not use it when
|
||||
|
||||
- a replicated enum fully describes the state;
|
||||
- the phase must never be cancellable;
|
||||
- clients must query authoritative phase state and no replication path has been
|
||||
designed for it. This is the trap: if the phase abilities are server-only, a
|
||||
client-side query of "is this phase active" answers false forever, and it
|
||||
answers it without an error.
|
||||
|
||||
### Observer handles are mandatory
|
||||
|
||||
Registration returns a handle and supports unsubscribe. An array of callbacks
|
||||
with no handles grows until the world resets, and every entry pins whatever it
|
||||
captured.
|
||||
|
||||
---
|
||||
|
||||
## 9. Failure feedback belongs at the ability system seam
|
||||
|
||||
Activation failure should produce structured tags, not a log line:
|
||||
|
||||
```text
|
||||
Ability.ActivateFail.Cooldown
|
||||
Ability.ActivateFail.Cost
|
||||
Ability.ActivateFail.TagsBlocked
|
||||
Ability.ActivateFail.IsDead
|
||||
```
|
||||
|
||||
For a locally controlled avatar, route them straight to feedback. For a
|
||||
server-side rejection of a locally predicted action, notify the owning client with
|
||||
the ability and the failure tags. Map tags to user-facing text and to animations
|
||||
in data, and deliver them over a message bus so the UI never learns about GAS.
|
||||
|
||||
Never parse log strings in UI.
|
||||
|
||||
### Compose costs rather than accepting one
|
||||
|
||||
A single cost effect is rarely enough. 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 — turns cost into data. The hit determination should be
|
||||
computed once and cached, on authority only.
|
||||
|
||||
---
|
||||
|
||||
## 10. Equipment and feature integration
|
||||
|
||||
Equipment grants ability sets when equipped and retains the receipt in its
|
||||
applied-item entry. Unequip reverses exactly that receipt.
|
||||
|
||||
A feature action that grants abilities retains, per actor: direct ability handles,
|
||||
attribute instances, ability-set receipts, and the extension request handle.
|
||||
|
||||
Every integration answers:
|
||||
|
||||
- Who owns the ability system component — the pawn or the player state?
|
||||
- Which object is the source object for the spec?
|
||||
- When is avatar info initialized?
|
||||
- Is the grant permanent or reversible?
|
||||
- What happens if the actor is destroyed before the feature deactivates?
|
||||
|
||||
See `ue-modular-gameplay` for actor-extension timing and rollback.
|
||||
|
||||
---
|
||||
|
||||
## 11. Review checklist
|
||||
|
||||
### Ability set
|
||||
|
||||
- [ ] authority-only grant, checked at the top of the function;
|
||||
- [ ] grant order is attributes, abilities, effects;
|
||||
- [ ] every class reference non-null and of the expected type;
|
||||
- [ ] semantic input tag valid and constrained;
|
||||
- [ ] receipt retained for every temporary grant;
|
||||
- [ ] revoke removes only owned resources;
|
||||
- [ ] source object deliberately chosen.
|
||||
|
||||
### Ability system component
|
||||
|
||||
- [ ] input buffers processed once per frame, after player input;
|
||||
- [ ] blocked-input behaviour explicit for held abilities;
|
||||
- [ ] activation group counters balance;
|
||||
- [ ] avatar change activates spawn-policy abilities exactly once;
|
||||
- [ ] relationship map selected per archetype;
|
||||
- [ ] failure tags reach local feedback.
|
||||
|
||||
### Attributes and damage
|
||||
|
||||
- [ ] source and target responsibilities separated into different sets;
|
||||
- [ ] meta attributes are not treated as durable state;
|
||||
- [ ] clamping occurs in every path that can change the value;
|
||||
- [ ] team and friendly-fire rules applied in one authoritative calculation;
|
||||
- [ ] out-of-health cannot fire twice for one death;
|
||||
- [ ] custom effect context serializes.
|
||||
|
||||
### Phases and death
|
||||
|
||||
- [ ] the server owns transitions;
|
||||
- [ ] observers can unregister;
|
||||
- [ ] sibling and parent tag semantics are tested, not assumed;
|
||||
- [ ] clients have a defined observation path that does not depend on a
|
||||
server-only subsystem;
|
||||
- [ ] death start and finish are durable states, not health comparisons.
|
||||
|
||||
---
|
||||
|
||||
## 12. Decision summary
|
||||
|
||||
Use GAS for a concern when at least one is true: it is a grantable and revocable
|
||||
capability; prediction or networked activation matters; it is naturally expressed
|
||||
through effects, tags, cost and cooldown; it benefits from task-based lifecycle
|
||||
with cancel and end.
|
||||
|
||||
Keep it out of GAS when the concern is durable domain state better served by a
|
||||
replicated component, presentation only, static definition composition, or a
|
||||
global lifecycle with no ability semantics.
|
||||
|
||||
A production GAS architecture is not "everything is an ability". It is a clean
|
||||
seam between capability lifecycle, numeric effects, domain state and data-driven
|
||||
composition.
|
||||
|
||||
---
|
||||
|
||||
## Provenance
|
||||
|
||||
The patterns and failure modes in `references/` come from a line-by-line audit of
|
||||
the GAS layer of Epic's Lyra Starter Game on Unreal Engine 5.6 — roughly 4 900
|
||||
lines across 51 files — read as source rather than run.
|
||||
|
||||
Source addresses stay in the research archive that produced this skill; what ships
|
||||
is the detection recipe. Each entry carries a stable identifier (`GA-01` and up)
|
||||
that resolves back to the audited location, so any specific claim can be produced
|
||||
on request.
|
||||
|
||||
## Evidence boundary
|
||||
|
||||
One project, one engine version, one workspace. The architecture described here
|
||||
is transferable; the specific defects are evidence, not guarantees about other
|
||||
versions. Re-run the detection recipes against your own tree before acting on any
|
||||
specific claim.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user