Tactical Action · Architecture

Gameplay Ability System (GAS) Layout

A focused page within the Tactical Action Architecture documentation. The full map and every sibling page live in the Architecture hub.

8sections12 minread1diagram2tables

On this page

V4 is one game that has to feel like six different ones. A tactical-FPS operator, a social-stealth assassin, a real-time-tactics specialist, a Wukong-class action- RPG hero, an RTS unit-commander, and a 2D run-and-gun arcade pilot all run on the same actor and the same Ability System Component — they differ only in which stat block, which verbs, and which effects are mounted at spawn. That is the job of V4's GAS layer: give every "cell" a clean, mutually exclusive slice of Unreal's Gameplay Ability System while sharing one base for attributes, one base for abilities, one base for effects, and one gameplay-tag namespace. Unlike V2's combat core, this layer is not rollback-deterministic or frame-data-driven; it is a conventional, replicated, server-authoritative GAS stack whose value is in how cleanly it partitions six rulesets behind shared machinery. This page is the architecture-side companion for V4's GAS, part of the Combat, Character & Input Systems group; the section hub is ../V4_ARCHITECTURE.md.

The source of truth is Unreal C++ under V4/ue/Source/V4Gameplay (the shared base), the per-cell ability modules (V4Tactical, V4Stealth, V4Tactics, V4ActionRPG, V4RTS, V4Arcade), and the native tag table at V4/ue/Config/DefaultGameplayTags.ini. There is no TypeScript contract package for any of it.

What ships, honestly#

The GAS layer is real, substantive, and tested — not a skeleton. Six concrete attribute sets, a thirty-two-class ability family, twenty-three gameplay-effect classes, a resolve-and-apply library, the operator pawn that wires it together, and five automation specs all exist as working C++ with domain-specific clamps, armor mitigation, and periodic decay. The V4.Gameplay.* automation specs spawn a real AV4Pawn_Operator into a UWorld, switch cells, apply effects, and assert computed attribute values (armor-mitigated health of 85.0, suppression decaying 40 → 20 → 0 over two seconds, RTS unit health stacking 100 → 65 → 45) — these would fail against placeholders.

Five honest qualifications, so the rest of the page can be read at face value:

  1. The abilities and effects are C++ classes, not authored Blueprint assets. The monolith says "GameplayAbility blueprints implement the verb library"; in the repo the verbs are UCLASS(Blueprintable) C++ types (UGA_Fire, UGA_LightAttack, …) that tests instantiate directly via StaticClass(). They are Blueprintable — designers can subclass them — but V4 ships zero binary .uasset ability or effect assets. The C++ classes are canonical.
  2. Effects are thin SetByCaller appliers, not MMC/ExecCalc math. V4 has no UGameplayModMagnitudeCalculation / UGameplayEffectExecutionCalculation pairs like V2's combat layer. Every effect is a single additive modifier fed a SetByCaller magnitude. The one piece of real "execution math" — armor mitigation — is hand-coded in PostGameplayEffectExecute, not in an exec calc.
  3. Armor mitigation is Tactical-only. The mitigation hook keys specifically on UV4Attr_Tactical::GetHealthAttribute(). The RTS set has a UnitArmor attribute, but nothing in the effect path consumes it yet — it is a stored stat, not a damage reducer.
  4. The ASC is a thin cell-tracking subclass. UV4AbilitySystemComponent adds an ActiveCell field and a cell-event delegate and nothing else. There is no integer SimulationFrame, no rollback snapshot, no input-tag queue — V4's determinism/netcode lives in V4Netcode, separate from this layer.
  5. The doc under-counts. The monolith lists six Stealth and three Tactics abilities; the code has seven and four (each adds a takedown ability). Counts below come from the headers, not the prose.

The shared spine#

Every controllable character is an AV4Pawn_Operator (V4Gameplay/Public/V4Pawn_Operator.h:13), which implements IAbilitySystemInterface and owns exactly one UV4AbilitySystemComponent created as a default subobject, replicated in EGameplayEffectReplicationMode::Mixed (V4Pawn_Operator.cpp:10). On BeginPlay it calls InitializeForCell(DefaultCell) with DefaultCell = Tactical (V4Pawn_Operator.h:34). That single method is the whole cell-switching mechanism: it NewObjects the one attribute set for the requested cell, hands it to the ASC via SetSpawnedAttributes, calls InitAbilityActorInfo, and records the cell on the ASC (V4Pawn_Operator.cpp:27-67). Because ActiveAttributeSet is a single pointer that is overwritten on each switch, a pawn is either a Tactical operator or an ARPG hero, never both at once — the "mutually exclusive at run-time" contract the monolith promises is enforced by construction.

The cell identity itself is EV4RulesetCell (V4Core/Public/V4CellState.h:7): None, Tactical, Stealth, Tactics, ActionRPG, RTS, Arcade. It is the join key for the entire layer — attribute sets, abilities, and effects all carry it, and the ASC stores the live one (UV4AbilitySystemComponent::ActiveCell, V4AbilitySystemComponent.h:31).

flowchart TD Pawn["AV4Pawn_Operator<br/>(IAbilitySystemInterface)"] Pawn -->|InitializeForCell| ASC["UV4AbilitySystemComponent<br/>ActiveCell : EV4RulesetCell"] ASC -->|SetSpawnedAttributes| AttrPick{Cell?} AttrPick -->|Tactical| AT["UV4Attr_Tactical"] AttrPick -->|Stealth| AS["UV4Attr_Stealth"] AttrPick -->|Tactics| AC["UV4Attr_Tactics"] AttrPick -->|ActionRPG| AR["UV4Attr_ARPG"] AttrPick -->|RTS| AY["UV4Attr_RTS"] AttrPick -->|Arcade| AA["UV4Attr_Arcade"] ASC -->|GiveAbility| Abil["UGA_* : UV4GameplayAbilityBase<br/>RequiredCell gate"] Abil -->|ExecuteV4Ability| Domain["domain components<br/>(combo, cover, grenade, build, …)"] Abil -->|ApplyAttributeEffect| Lib["UV4GameplayEffectLibrary"] Lib -->|ResolveAttributeEffectClass| GE["UGE_V4_* : UV4GameplayEffectBase<br/>SetByCaller additive modifier"] GE -->|modifies| AT AT -->|PreAttributeChange / PostGameplayEffectExecute| Clamp["clamp + Tactical armor mitigation"]

Attribute sets — one base, six stat blocks#

All attribute sets derive from UV4AttributeSetBase (V4Gameplay/Public/V4AttributeSets.h:16), an Abstract set that holds the replicated OwningCell, overrides PreAttributeChange and PostGameplayEffectExecute, and exposes a virtual GetClampForAttribute that subclasses fill in. The clamp machinery is genuinely defensive: V4Clamp (V4AttributeSets.cpp:26) treats any non-finite value as the floor before FMath::Clamp, so an effect can never push an attribute to NaN. PreAttributeChange clamps the incoming value; PostGameplayEffectExecute re-clamps the resulting value after an instant effect, and runs the armor mitigation pass first (V4AttributeSets.cpp:80-94).

The six concrete sets, with their replicated defaults (each field uses ReplicatedUsing = OnRep_* with REPNOTIFY_Always):

Set Cell Attributes (default)
UV4Attr_Tactical (:36) Tactical Health 100, Armor 0, Suppression 0, StaminaSprint 100, AmmoCurrent 30, AmmoReserve 90
UV4Attr_Stealth (:92) Stealth Health 100, Detection 0, DisguiseClass 0, Noise 0
UV4Attr_Tactics (:134) Tactics Health 100, ActionPoints 2, Vision 1
UV4Attr_ARPG (:169) ActionRPG Health 100, Stamina 100, Posture 0, Will 100, Mana 50
UV4Attr_RTS (:218) RTS UnitHealth 100, UnitArmor 0, UnitAttack 10, UnitRange 600, UnitMovementSpeed 400, UnitSightRange 1200
UV4Attr_Arcade (:274) Arcade Health 1, Lives 3, ScoreMultiplier 1

That is 27 replicated FGameplayAttributeData fields across six sets, each exposed through the V4_ATTRIBUTE_ACCESSORS macro (:9) that synthesizes the standard GAS getter/setter/initter quartet. The clamps are domain-specific, not boilerplate: Tactical Health is 0–100 but Armor is 0–200 (V4AttributeSets.cpp:137), ammo is 0–999, Tactics ActionPoints cap at 12 and Vision at 10, RTS unit attributes share a wide 0–100000 ceiling, and Arcade Health caps at 9, Lives at 99, and ScoreMultiplier has a floor of 1.0 (V4AttributeSets.cpp:429) so a score multiplier can never drop below ×1. The V4.Gameplay.Attributes.Defaults spec (AttributeSetSpec.cpp:13) asserts every one of these defaults and owning-cell values.

Abilities — one gated base, thirty-two verbs#

UV4GameplayAbilityBase (V4Gameplay/Public/V4GameplayAbilities.h:9) extends UGameplayAbility with three things a multi-cell roster needs. First, a cell gate: RequiredCell (:52) plus IsAbilityCellCompatible (V4GameplayAbilities.cpp:80), which returns true for None but otherwise casts the actor's ASC to UV4AbilitySystemComponent and checks GetActiveCell() == RequiredCell. This is folded into CanActivateAbility (:12) so a Tactical GA_Fire simply cannot fire on a pawn currently mounted as an ARPG hero. Second, a fail-loud activation seam: ActivateAbility (:23) checks the cell, calls CommitAbility, then dispatches to a protected ExecuteV4Ability(..., FString& OutFailureReason), recording bLastActivationSuccessful and a human-readable LastActivationReason ("wrong-cell", "commit-failed", or the verb's own reason) before EndAbility. Third, sane defaults: the constructor sets InstancedPerActor, LocalPredicted net execution, and ReplicateYes (V4GameplayAbilities.cpp:5-10), so every cell inherits client-predicted, replicated activation without restating it.

Each cell module subclasses that base into its verb library. The full count is 32 concrete UGA_* classes:

Cell module Abilities Count
V4Tactical Fire, Reload, ADS, Lean, ThrowGrenade, BreachStack, Revive, Heal 8
V4Stealth Crouch, BodyDrag, LightKill, StealthTakedown, StickyCam, MarkAndExecute, Disguise 7
V4Tactics PlanQueueWaypoint, FireQueued, TacticsTakedown, ShowdownToggle 4
V4ActionRPG LightAttack, HeavyAttack, Dodge, Parry, Transform, CastCharm 6
V4RTS BuildStructure, TrainUnit, ResearchTech, AttackMove 4
V4Arcade Shoot, Jump, DropPlatform 3

The verbs are not stubs: each ExecuteV4Ability delegates to a real domain component on the avatar and reports honest failure. UGA_Fire (V4Tactical/Private/V4TacticalAbilities.cpp:44) pulls the Tactical attribute set and calls Weapon.ConsumeRound, failing with "no-ammo"; UGA_Heal (:206) routes through the effect library and fails with "heal-failed"; UGA_LightAttack (V4ActionRPG/Private/V4ActionRPGAbilities.cpp:30) advances a UV4HeroComboComponent, and UGA_Transform (:122) defaults to the ARPG.Form.Tiger form (300 HP, 12 s, WillCost = 1) and activates it through a UV4TransformationComponent. The V4.Gameplay.Abilities.ActivationClasses spec (AbilityClassSpec.cpp:170) spawns an operator per cell, attaches the needed components, gives-and-activates each ability, and asserts the domain side effect — ammo 30 → 29, reload back to 30, Tactical heal 50 → 85, ARPG combo step, dodge i-frames, RTS build/train/tech queues, arcade jump raising the pawn. A companion V4.Gameplay.Abilities.Classes spec (:124) pins every ability's RequiredCell, and V4.Gameplay.Ability.Activation (AbilitySpec.cpp:28) verifies the base activation path end-to-end through TryActivateAbility.

How input reaches these is deliberately out of scope here: named input actions map to the GA.Activation.Tag.* namespace and are dispatched by the input router. See ./input-pipeline.md. The montages, combo state machines, and dodge/parry frame windows the verbs drive are in ./animation-pipeline.md, and the cell-specific component backbones (V4HeroComboComponent, V4CommandQueueComponent, the posture system) are detailed in ./per-cell-deep-dives.md.

Gameplay effects — a typed kind, a resolve table, a scaling applier#

Effects are organized by a single enum, EV4AttributeEffect (V4Gameplay/Public/V4GameplayEffects.h:12), with 16 kindsDamage, Heal, SuppressionApply/SuppressionDecay, StaminaCost, DetectionApply, NoiseApply, ActionPointCost, PostureDamage/PostureDecay, ManaCost, UnitDamage/UnitRepair, ArcadeDamage, ExtraLife, ScoreMultiplierBoost. The base UV4GameplayEffectBase (:33) carries Cell, EffectKind, and a MagnitudePerLevel, and offers two configurators — ConfigureInstantAdditive and ConfigurePeriodicAdditive (V4GameplayEffects.cpp:41, :62) — both of which build a single additive FGameplayModifierInfo whose magnitude is a SetByCaller keyed "V4.GameplayEffect.Magnitude" (:103). From that base descend 23 concrete UGE_V4_* classes (5 Tactical, 4 Stealth, 3 Tactics, 6 ARPG, 2 RTS, 3 Arcade), each a one-line constructor binding a cell, a kind, an attribute, and a per-level rate — e.g. UGE_V4_TacticalDamage is (Tactical, Damage, Health, -1.0) (:109) and UGE_V4_TacticalHeal is (Tactical, Heal, Health, +1.0) (:118).

The runtime entry point is UV4GameplayEffectLibrary (V4GameplayEffects.h:286). ResolveAttributeEffectClass(Cell, Kind) (:320) is an explicit nested switch that returns the right UGE_V4_* class or nullptr for any combination a cell doesn't support — so a Tactical request for NoiseApply resolves to null, which the V4.Gameplay.Attributes.Effects spec asserts as a rejected cross-cell effect (AttributeSetSpec.cpp:115). ApplyAttributeEffect (:413) is where the one genuinely subtle design decision lives. A bare FScalableFloat magnitude would be constant regardless of the requested amount; instead the library reads the class's MagnitudePerLevel and sets the SetByCaller magnitude to MagnitudePerLevel * RequestedMagnitude (:451-455), so the same Damage class scales linearly with the caller's number. That comment-as-rationale (V4GameplayEffects.h:44-50) is the kind of domain-specific reasoning that distinguishes real code from a generic wrapper.

Armor mitigation, and the decay-on-apply trick#

Two pieces of behaviour are worth tracing because they are where the layer earns its keep.

Armor mitigation (V4AttributeSets.cpp:37) runs inside PostGameplayEffectExecute. After an instant Damage effect has already subtracted its full magnitude from Tactical Health, the mitigation pass recomputes RawDamage = -Magnitude, finds MitigatedDamage = max(0, RawDamage - Armor), and credits the prevented portion back to Health. With Armor = 10 and a 25 damage request, Health lands at 100 − 25 + 10 = 85 — exactly what the effects spec asserts (AttributeSetSpec.cpp:188). Flat armor as damage subtraction, applied through the standard GAS execution path, with a final clamp behind it.

Decay-on-apply is how V4 implements "suppression decays over 2 s" and "posture decays over 3 s" without a per-frame tick. When ApplyAttributeEffect lands a SuppressionApply or PostureDamage, it measures how much the tracked attribute actually rose, then recursively applies the matching periodic decay effect for exactly that measured amount (V4GameplayEffects.cpp:431-473, V4GetStackedAttributeForDecay at :13). The decay effect is a HasDuration periodic modifier whose per-period rate is fractional and negative — -(1.0/2.0) per 1 s over 2.01 s for suppression (:136), -(1.0/3.0) per 1 s over 3.01 s for posture (:255) — so a 40-point suppression bleeds off 20/s and is gone in two seconds, and a 30-point posture bleeds 10/s over three. The spec drives the world's timer manager directly and asserts 40 → 20 → 0 and 30 → 20 → 10 → 0 (AttributeSetSpec.cpp:211-265). The 0.01 s duration padding exists precisely so the engine's strict InternalTime > ExpireTime period check fires the expected number of times — a real timing nuance, documented in the spec.

How cells share and specialize#

The whole layer is a deliberate split between one shared mechanism and six narrow specializations:

  • Shared: the operator pawn, the UV4AbilitySystemComponent and its ActiveCell, the abstract UV4AttributeSetBase (clamp + finite-guard + replication + armor hook), the abstract UV4GameplayAbilityBase (cell gate + fail-loud reason + predicted/replicated defaults), the abstract UV4GameplayEffectBase and the resolve-and-apply library, and one GA.Activation.Tag.* input namespace.
  • Specialized per cell: exactly one attribute set, one UGA_* verb family, one slice of UGE_V4_* effects, and one gameplay-tag subtree. Adding a seventh cell means a new EV4RulesetCell value, one attribute set, a verb module, its effects, a ResolveAttributeEffectClass arm, and a tag root — with no change to the shared base.

The tag side mirrors this. V4/ue/Config/DefaultGameplayTags.ini defines 595 native tags under FastReplication, including all seven cell roots (Tactical, Stealth, Tactics, RTST, ARPG, RTS, Arcade), a shared Operator.* roster family (48 tags), and a flat GA.Activation.Tag.* namespace of 47 input-to-ability activation tags (GA.Activation.Tag.Fire, .PlanWaypoint, .Parry, .BuildStructure, …). The cell-specific state tags the monolith cites are present and correctly rooted — Tactical.State.Stance.{Stand, Crouch,Prone}, Tactical.Cover.{Left,Right}, Stealth.Detection.{Hidden, Suspicious,Compromised}, Stealth.Disguise.Waiter, Tactics.Order.{Move,Attack, Sync}, ARPG.Combat.Parry.Window, ARPG.Form.{Tiger,Wolf,Bird,Stone,Wind}, RTS.Faction.*, RTS.Tech.Tier2, and Arcade.Weapon.* / Arcade.PowerUp.Active.

One last connecting note for the reader: the UV4MatchStateSubsystem (V4Gameplay/Public/V4MatchStateSubsystem.h) that lives in the same module is a lobby/flow machine (MainMenu → ModeSelect → … → MatchResult), not a combat state machine — it sequences match setup, not in-fight phases, and is unrelated to the per-hit GAS path described above. V4's combat "state" is the per-cell component state (combo, posture, cover, detection), reached through the abilities here.

How it connects#

GAS is the seam where input, animation, and per-cell systems meet. Input actions activate these abilities through the GA.Activation.Tag.* namespace (./input-pipeline.md); the abilities drive montages, combo machines, and the dodge/parry/posture frame windows (./animation-pipeline.md); and the cell-specific component backbones each verb delegates to — the ARPG combo and posture systems, the tactical cover/grenade/revive components, the RTS build/train/tech components — are unpacked cell by cell in ./per-cell-deep-dives.md. The attribute defaults and effect rates are the numbers the balance and live-ops layers tune. For where V4Gameplay and the per-cell modules sit in the overall build, and the full GAS section in prose, return to the hub: ../V4_ARCHITECTURE.md.