Unreal Engine 5 GAS: building an ability system right
My dash ability fired once on the client and then went silent. The bug was not GAS itself, it was touching an attribute without a GameplayEffect.
Why the cooldown never refilled on clients
Last year I wrote the dash ability for a four-player co-op prototype. It behaved perfectly in the editor and on a listen server. In a packaged build with two clients connected, dash fired exactly once and then the key went dead. The cooldown meter stayed full on screen forever.
The bug was mine. I was draining stamina directly with SetStamina() inside ActivateAbility and tracking the cooldown in my own float counter. The server never saw that counter, and the client never asked authority about anything. This is exactly the problem the Unreal Engine 5 Gameplay Ability System exists to solve, and I had convinced myself I was using GAS while bypassing it.
Why did it take three days to find? Because nothing crashes. The client looks at its own counter and says "still cooling down", the server keeps no record of that ability at all, and both sides look internally consistent. The first time I opened the showdebug abilitysystem console, seeing zero active effects on the client was all the evidence I needed.
The conclusion I took from it: GAS is not an ability library, it is an accounting system. It exists so that "who changed what, and when" has exactly one answer. Every line that touches an attribute outside a UGameplayEffect quietly corrupts that ledger, and it does so without producing a single warning.
How the four classes wire together
UAbilitySystemComponent (ASC) is the hub; it owns the granted abilities, the active effects and the tags. For player characters I put the ASC on APlayerState, because an ASC that dies with the pawn takes every buff with it on respawn. For AI, living on ACharacter is fine — they do not respawn anyway.
UAttributeSet is where the numbers live. Every field is an FGameplayAttributeData declared through the ATTRIBUTE_ACCESSORS macro, which generates the getter, setter and init helpers together. Rules like keeping health between 0 and MaxHealth belong in PreAttributeChange, not in an ability — because an ability is not the only thing that will ever modify that attribute.
UGameplayAbility only answers the question "what happens". You hand it to the ASC with GiveAbility and trigger it through an input tag or an FGameplayAbilitySpecHandle. It never writes an attribute; it applies effects. I set the instancing policy to Instanced Per Actor, because holding member state inside an ability is not safe otherwise.
The fourth piece, UGameplayEffect, is the only one with no code in it at all — it is really a data asset. It has three duration policies: Instant applies and disappears, Has Duration lives for a set time, and Infinite stays until you remove it. One sentence covers all four: the ability decides, the effect applies, the attribute stores, the ASC keeps the books.
Why cost and cooldown are separate effects
Cost is an Instant effect: it applies a -25 modifier to the Stamina attribute and its life ends there. Cooldown is a Has Duration effect that touches no attribute at all; it simply grants the Cooldown.Ability.Dash tag to the owner for four seconds. CanActivateAbility checks both, so if stamina is short or the tag is still present the ability never starts.
The payoff of that split shows up in the CommitAbility call. A single line pays the cost and starts the cooldown, and either half can fail on its own. A designer feeds the duration in from a data table through SetByCaller, so I never touch the C++ side and nobody waits on a compile.
I did try my own float LastUsedTime counter, and it collapsed in two places. First, when I added an item granting 20% cooldown reduction, I had to write multiplier logic per ability; on the effect side that is one modifier with the stacking rules already handled. Second, since the UI could not call GetCooldownTimeRemaining(), I had to open a separate replication path just to feed a progress bar.
The third benefit shows up in prediction. CommitAbility runs inside a prediction window on the client, so cost and cooldown appear on screen immediately and both roll back together if the server rejects the activation. Hand-rolling that means hand-rolling the rollback too, and the rollback is the hard half.
Replication mode: Full, Mixed or Minimal
You pick the mode with SetReplicationMode() before actor info is initialised, and it is a decision that looks cheap and bills you later. In Full mode the ASC replicates every effect to every connected client. For a single-player game or a four-player session that is harmless, and it is by far the easiest mode to debug. The trouble starts with scale.
Mixed is the right choice for player-controlled characters: full effects go only to the owner, while everyone else receives tags and GameplayCues. It has one condition — the ASC must live on APlayerState. Use Mixed with an ASC on the pawn and effects vanish entirely on non-owning clients.
A second trap sits in the same place: PlayerState replicates once per second by default. If you do not raise NetUpdateFrequency to 30, the cooldown meter visibly lags behind; in my build the gap between the key press and the meter moving was around 400 ms. After raising the value, that gap dropped below 40 ms.
Minimal is for AI: no effect is replicated, only tags and cues. In a 32-enemy arena test, moving from Full to Minimal dropped per-player ASC traffic from about 58 KB/s to about 11 KB/s. Nothing on screen changed, because the only thing a client needed to know was that the enemy was stunned.
Managing state with GameplayTag
Before GAS my character carried seven booleans — bIsStunned, bIsDashing, bCanAttack and friends — each replicated on its own. A GameplayTag setup replaces all of them with a single FGameplayTagContainer. If State.Stunned is present, the attack ability simply does not start thanks to Activation Blocked Tags; I write no checking code at all.
Tags are hierarchical. State.Debuff.Stun answers yes to MatchesTag(State.Debuff), which turns "cannot sprint while any debuff is active" into one line. Fix the naming scheme on day one: mine has five roots — Ability.*, Cooldown.*, State.*, Event.* and Cue.* — all declared in DefaultGameplayTags.ini.
Do not look tags up by name every time. RequestGameplayTag(FName("State.Stunned")) performs a table lookup on every call, and I have watched that cost become measurable while profiling code that did it inside Tick. Native tags declared with UE_DEFINE_GAMEPLAY_TAG_STATIC resolve once, after which a comparison is a single integer compare.
The sneakiest trap is AddLooseGameplayTag. The name sounds harmless, but loose tags are not replicated and are not cleaned up when an effect is removed. A loose tag I added on the server never appeared on clients, and I chased a broken animation transition for two days because of it. The rule is simple: if the state comes from an effect, the tag should come from that effect's Granted Tags field.
The four traps that cost me the most time
The most expensive one is ordering: calling InitAbilityActorInfo in a single place. It has to run in PossessedBy on the server and in OnRep_PlayerState on the client. Write only one of them and the client-side ASC stays empty, no ability ever fires, and not one warning reaches the log. That single mistake cost me about a week across two separate projects.
Second, setting initial attribute values by hand in PostInitializeComponents; hand them over through an Instant effect instead, or replication will overwrite them during OnRep. Third, running gameplay logic inside a GameplayCue: cues can be skipped on clients, so they should carry audio, particles and camera shake and nothing else.
The fourth is quieter: forgetting that abilities must be granted with GiveAbility on the server only. An ability granted on the client never enters the real spec list, what looks like a granted ability is a local copy, and it fails silently on the first activation. I moved all of it into one InitializeAbilities() function called from PossessedBy and never hit it again.
Starting from zero today, I would build in this order: the AttributeSet and its init effect, then one deliberately useless test ability, then verification through showdebug abilitysystem that tags and effect durations look right. Run PIE with a dedicated server and two clients from day one as well. The GAS learning curve is not steep, its feedback is just silent, and the moment you turn the visibility on, three-day bugs shrink to three minutes.