Files
ue-toolchain/plugins/ue-design-skills/skills/ue-gas-architecture/references/patterns.md
T
MagentaDolphin ecd87ac96d feat(skills): ship ue-design-skills bundle, licensing and delivery gate
Phase 0 of the handoff plan, as a marketplace rather than a flat skills/
directory. Content moved out of the LyraResearch archive and depersonalised:
addresses stay in the archive, recipes ship.

- plugins/ue-design-skills: 17 skills, 232 failure-mode entries, each with the
  six required fields; catalog.json as the harness-neutral source of truth and
  .claude-plugin/ as one adapter over it.
- _gate: 16 rules, one poisoned fixture per rule, plus surface coverage so a
  declared file cannot silently miss the line rules.
- ADR-0002 (harness-neutral bundle behind a marketplace) and ADR-0003 (split
  licensing: CC BY-ND 4.0 prose, Apache-2.0 code and metadata).
- LICENSE files at both levels, CONTRIBUTING.md, docs/licensing-options.md as
  the material the licence decision grew from.

Verified: gate.py 0 violations; test_gate.py 16/16 rules redden on their
fixtures with a clean baseline and 2 root files reaching the line rules.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 23:48:55 +07:00

16 KiB
Raw Blame History

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 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]:

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

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 against your own tree before acting on anything here.