Example: Network-Predicted Ability¶
Advanced
A melee attack implemented with proper client-side prediction, showing how GAS handles the predict-confirm-reject cycle. This walks through what happens on the client vs server, how mispredictions are handled, and best practices for responsive multiplayer abilities. If you're building a networked game and want abilities to feel instant without waiting for server round-trips, this is the page.
This is a C++-only example focused on networking concepts. Blueprint ability logic works the same way at a high level, but understanding prediction requires seeing what happens under the hood.
What We're Building¶
- A melee attack with
LocalPredictednet execution policy - Client predicts: ability activation, cost deduction, cooldown application, montage playback
- Server confirms or rejects the prediction
- On rejection: client rolls back all predicted effects
- Proper use of
FScopedPredictionWindowfor effects applied outside the initial activation window - Proper use of
CommitAbility(which is prediction-aware)
Prerequisites¶
Everything from Project Setup, plus:
- A multiplayer project (dedicated server or listen server)
- Familiarity with the Melee Attack example (this builds on it)
- Basic understanding of Unreal's client-server replication model
How Prediction Works in GAS¶
Before looking at code, let's understand the mechanism. GAS prediction is built around prediction keys — unique IDs generated on the client that the server uses to match predicted actions with authoritative results.
The Prediction Key (FPredictionKey)¶
Every predicted action gets a FPredictionKey. This key:
- Is generated on the client when an ability attempts to activate
- Is sent to the server alongside the activation request
- Is stored on any predicted effects (GEs, cues, attribute changes)
- Is used to match client predictions with server-confirmed results
- Replicates client-to-server, but when replicating server-to-clients, it only goes back to the originating client (other clients receive an invalid key)
The Prediction Window¶
A prediction key is only valid during a prediction window — essentially the initial callstack of ActivateAbility. Once ActivateAbility returns (or any latent action starts), the prediction window closes. This is why you cannot predict across multiple frames or after a timer fires.
Prediction does not cross frame boundaries
Anything that happens in the initial ActivateAbility call (before it returns) is inside the prediction window. Anything after — timers, delays, montage callbacks, ability task delegates — is outside the window and requires a new FScopedPredictionWindow if you need to predict those actions.
What GAS Predicts Automatically¶
From the UE 5.7 engine source comments on FPredictionKey:
| Predicted | Not Predicted |
|---|---|
| Ability activation | GameplayEffect removal |
| Triggered events | Periodic effects (DoT ticks) |
| GE application (attribute mods, tag changes) | Execution Calculations |
| Gameplay Cues (from predicted GEs or standalone) | |
| Montage playback | |
| Movement (via UCharacterMovement) |
Step 1: The Predicted Ability Class¶
Class Defaults¶
| Property | Value | Why |
|---|---|---|
| Net Execution Policy | LocalPredicted |
Client predicts, server confirms |
| Instancing Policy | InstancedPerActor |
Required for storing state across the prediction lifecycle |
| Net Security Policy | ClientOrServer |
Both can attempt activation |
| Replication Policy | ReplicateYes |
Ability state replicates to owner |
The key setting is NetExecutionPolicy = LocalPredicted. This tells GAS:
- When the owning client wants to activate, predict locally AND send a request to the server
- The server receives the request, validates it, and confirms or rejects
- If confirmed: the client's predictions were correct, clean up prediction state
- If rejected: roll back all predicted side effects
Header¶
// GA_PredictedMeleeAttack.h
#pragma once
#include "CoreMinimal.h"
#include "YourProjectGameplayAbility.h"
#include "GA_PredictedMeleeAttack.generated.h"
UCLASS()
class UGA_PredictedMeleeAttack : public UYourProjectGameplayAbility
{
GENERATED_BODY()
public:
UGA_PredictedMeleeAttack();
virtual void ActivateAbility(
const FGameplayAbilitySpecHandle Handle,
const FGameplayAbilityActorInfo* ActorInfo,
const FGameplayAbilityActivationInfo ActivationInfo,
const FGameplayEventData* TriggerEventData) override;
virtual void EndAbility(
const FGameplayAbilitySpecHandle Handle,
const FGameplayAbilityActorInfo* ActorInfo,
const FGameplayAbilityActivationInfo ActivationInfo,
bool bReplicateEndAbility,
bool bWasCancelled) override;
protected:
UPROPERTY(EditDefaultsOnly, Category = "Damage")
TSubclassOf<UGameplayEffect> DamageEffectClass;
UPROPERTY(EditDefaultsOnly, Category = "Damage")
float BaseDamageAmount = 25.0f;
UPROPERTY(EditDefaultsOnly, Category = "Animation")
TObjectPtr<UAnimMontage> AttackMontage;
private:
UFUNCTION()
void OnMontageCompleted();
UFUNCTION()
void OnMontageInterrupted();
UFUNCTION()
void OnDamageEventReceived(FGameplayEventData Payload);
};
Source¶
// GA_PredictedMeleeAttack.cpp
#include "GA_PredictedMeleeAttack.h"
#include "AbilitySystemComponent.h"
#include "Abilities/Tasks/AbilityTask_PlayMontageAndWait.h"
#include "Abilities/Tasks/AbilityTask_WaitGameplayEvent.h"
UGA_PredictedMeleeAttack::UGA_PredictedMeleeAttack()
{
InstancingPolicy = EGameplayAbilityInstancingPolicy::InstancedPerActor;
NetExecutionPolicy = EGameplayAbilityNetExecutionPolicy::LocalPredicted;
}
void UGA_PredictedMeleeAttack::ActivateAbility(
const FGameplayAbilitySpecHandle Handle,
const FGameplayAbilityActorInfo* ActorInfo,
const FGameplayAbilityActivationInfo ActivationInfo,
const FGameplayEventData* TriggerEventData)
{
// -------------------------------------------------------
// PREDICTION WINDOW IS OPEN HERE
// Everything in this callstack (before return) is predicted.
// -------------------------------------------------------
if (!HasAuthorityOrPredictionKey(ActorInfo, &ActivationInfo))
{
return;
}
// CommitAbility is prediction-aware:
// - On the CLIENT: predicts cost deduction and cooldown application
// - On the SERVER: actually applies cost and cooldown
// - If the server rejects, the client's predicted cost/cooldown
// are automatically rolled back
if (!CommitAbility(Handle, ActorInfo, ActivationInfo))
{
EndAbility(Handle, ActorInfo, ActivationInfo, true, true);
return;
}
// Play montage — this is also predicted.
// The client starts the montage immediately (feels responsive).
// The server also plays it. If the server rejects the ability,
// the client's predicted montage is cancelled.
UAbilityTask_PlayMontageAndWait* MontageTask =
UAbilityTask_PlayMontageAndWait::CreatePlayMontageAndWaitProxy(
this,
NAME_None,
AttackMontage,
1.0f);
MontageTask->OnCompleted.AddDynamic(
this, &UGA_PredictedMeleeAttack::OnMontageCompleted);
MontageTask->OnInterrupted.AddDynamic(
this, &UGA_PredictedMeleeAttack::OnMontageInterrupted);
MontageTask->OnCancelled.AddDynamic(
this, &UGA_PredictedMeleeAttack::OnMontageInterrupted);
MontageTask->ReadyForActivation();
// Wait for the hit event from the anim notify.
// This fires OUTSIDE the prediction window (it's a callback
// that happens later in the montage). That's fine — we'll
// handle the damage application on the server only.
UAbilityTask_WaitGameplayEvent* EventTask =
UAbilityTask_WaitGameplayEvent::WaitGameplayEvent(
this,
FGameplayTag::RequestGameplayTag(
FName("Event.Montage.MeleeHit")));
EventTask->EventReceived.AddDynamic(
this, &UGA_PredictedMeleeAttack::OnDamageEventReceived);
EventTask->ReadyForActivation();
// -------------------------------------------------------
// PREDICTION WINDOW CLOSES when this function returns.
// After this point, anything we do is NOT automatically
// predicted unless we open a new FScopedPredictionWindow.
// -------------------------------------------------------
}
void UGA_PredictedMeleeAttack::OnDamageEventReceived(
FGameplayEventData Payload)
{
// This callback fires when the AnimNotify sends the hit event.
// We are OUTSIDE the original prediction window.
//
// IMPORTANT: We apply damage on the SERVER ONLY.
// The client should NOT predict damage application because:
// 1. Execution Calculations don't support prediction
// 2. The target's health is server-authoritative
// 3. Predicting damage creates complex rollback scenarios
if (!GetActorInfo().IsNetAuthority())
{
// Client: don't apply damage, let the server handle it.
// The target's health change will replicate down normally.
return;
}
// Server: apply the damage effect
if (Payload.Target && DamageEffectClass)
{
FGameplayEffectSpecHandle SpecHandle =
MakeOutgoingGameplayEffectSpec(
DamageEffectClass, GetAbilityLevel());
if (SpecHandle.IsValid())
{
SpecHandle.Data->SetSetByCallerMagnitude(
FGameplayTag::RequestGameplayTag(
FName("SetByCaller.Damage")),
BaseDamageAmount);
UAbilitySystemComponent* TargetASC =
UAbilitySystemBlueprintLibrary::GetAbilitySystemComponent(
const_cast<AActor*>(
Cast<AActor>(Payload.Target.Get())));
if (TargetASC)
{
ApplyGameplayEffectSpecToTarget(
GetCurrentAbilitySpecHandle(),
GetCurrentActorInfo(),
GetCurrentActivationInfo(),
SpecHandle,
TargetASC);
}
}
}
}
void UGA_PredictedMeleeAttack::OnMontageCompleted()
{
EndAbility(
GetCurrentAbilitySpecHandle(),
GetCurrentActorInfo(),
GetCurrentActivationInfo(),
true, false);
}
void UGA_PredictedMeleeAttack::OnMontageInterrupted()
{
EndAbility(
GetCurrentAbilitySpecHandle(),
GetCurrentActorInfo(),
GetCurrentActivationInfo(),
true, true);
}
void UGA_PredictedMeleeAttack::EndAbility(
const FGameplayAbilitySpecHandle Handle,
const FGameplayAbilityActorInfo* ActorInfo,
const FGameplayAbilityActivationInfo ActivationInfo,
bool bReplicateEndAbility,
bool bWasCancelled)
{
Super::EndAbility(Handle, ActorInfo, ActivationInfo,
bReplicateEndAbility, bWasCancelled);
}
Step 2: Understanding the Timeline¶
Here's what happens frame-by-frame when a client activates this ability with 100ms network latency:
sequenceDiagram
participant C as Client
participant S as Server
Note over C: t=0ms
C->>C: TryActivateAbility()
C->>C: Generate PredictionKey #42
C->>C: ActivateAbility()
C->>C: PREDICT: Stamina 100 -> 85
C->>C: PREDICT: Cooldown tag applied
C->>C: Play montage (predicted)
Note over C: Client sees montage,<br/>stamina at 85, on cooldown
C->>S: Activation request (t=50ms)
S->>S: ServerTryActivateAbility()
S->>S: Validate: can this activate?
S->>S: AUTHORITY: Stamina 100 -> 85
S->>S: AUTHORITY: Cooldown applied
S->>S: Play montage (authority)
S->>C: Confirm key #42 (t=100ms)
C->>C: OnRep: key #42 confirmed
C->>C: Remove predicted GEs
C->>C: Server replicated GEs take over
Note over C: Seamless transition,<br/>player noticed nothing
What Happens on Rejection¶
sequenceDiagram
participant C as Client
participant S as Server
Note over C: t=0ms
C->>C: TryActivateAbility()
C->>C: PredictionKey #42
C->>C: PREDICT: Stamina 100 -> 85
C->>C: PREDICT: Cooldown applied
C->>C: PREDICT: Montage plays
C->>S: Activation request
S->>S: ServerTryActivateAbility()
S->>S: FAILS (stunned, silenced, etc.)
S-->>C: REJECT key #42
C->>C: ClientActivateAbilityFailed()
C->>C: ROLLBACK: Stamina 85 -> 100
C->>C: ROLLBACK: Cooldown tag removed
C->>C: ROLLBACK: Montage cancelled
Note over C: Player sees brief animation<br/>that snaps back -- "misprediction"
Step 3: FScopedPredictionWindow¶
If you need to predict effects outside the initial activation window (like applying a buff mid-montage), use FScopedPredictionWindow:
void UGA_SomeAbility::OnSomeDelayedCallback()
{
// We're outside the original prediction window.
// Open a new one to predict this effect application.
UAbilitySystemComponent* ASC = GetAbilitySystemComponentFromActorInfo();
if (!ASC)
{
return;
}
// This creates a new dependent prediction key
FScopedPredictionWindow ScopedPrediction(ASC, true);
// Now we can apply effects that will be predicted
FGameplayEffectSpecHandle SpecHandle =
MakeOutgoingGameplayEffectSpec(BuffEffectClass, GetAbilityLevel());
ApplyGameplayEffectSpecToOwner(
GetCurrentAbilitySpecHandle(),
GetCurrentActorInfo(),
GetCurrentActivationInfo(),
SpecHandle);
// When ScopedPrediction goes out of scope, the window closes.
// The new prediction key is sent to the server as a dependent key.
}
Use FScopedPredictionWindow sparingly
Every new prediction window creates a new prediction key that must be synchronized with the server. Overusing them increases network traffic and rollback complexity. The best practice is to only predict things that directly affect the player's perceived responsiveness (movement, stamina, cooldowns) and let the server handle everything else (damage to other players, spawned actors).
Step 4: Test¶
Simulate Latency¶
Open the console and add artificial network latency:
This adds 200ms round-trip latency, making prediction behavior visible.
What to Test¶
| Test | Expected Behavior |
|---|---|
Attack with net pktlag=0 |
Feels identical to single-player |
Attack with net pktlag=200 |
Montage starts instantly (predicted), stamina drops instantly, cooldown applies instantly |
| Attack while stunned (server sees stun, client doesn't yet) | Client predicts attack, server rejects, client snaps back |
| Attack with insufficient stamina | CommitAbility fails on both client and server — no misprediction |
| Rapidly attack near cooldown boundary | May see occasional mispredictions if client thinks cooldown expired but server disagrees |
Observing Prediction in Action¶
- Run a listen server with one client
- On the client, open
showdebug abilitysystem - With
net pktlag=200, attack and watch:- Stamina drops instantly (predicted)
- Cooldown tag appears instantly (predicted)
- After ~200ms, predicted GEs are silently replaced by server-replicated GEs
- The player sees no visual difference — that's correct prediction working
Forcing a Misprediction¶
To deliberately trigger a rollback:
- On the server console, apply a stun tag to the player:
AbilitySystem.Debug.ApplyTag CrowdControl.Stun - Immediately on the client, try to attack (before the stun tag replicates)
- The client predicts the attack (montage starts, stamina drops)
- ~100ms later, the server rejects (player is stunned)
- The client rolls back: montage stops, stamina returns, cooldown removed
The Full Flow¶
sequenceDiagram
participant C as Client
participant S as Server
C->>C: Player presses attack
C->>C: TryActivateAbility()
C->>C: Generate FPredictionKey
C->>C: ActivateAbility()
C->>C: CommitAbility() [PREDICTED]
Note over C: Cost GE applied (predicted)<br/>Cooldown GE applied (predicted)
C->>C: PlayMontage (predicted)
C->>C: WaitGameplayEvent started
C->>S: RPC: ServerTryActivateAbility
S->>S: Validate activation
S->>S: CommitAbility() [AUTHORITY]
S->>S: PlayMontage [AUTHORITY]
alt Confirmed
S->>C: Confirm PredictionKey
C->>C: OnRep catches up
C->>C: Predicted GEs silently removed
C->>C: Server GEs take over via replication
else Rejected
S-->>C: Reject PredictionKey
C->>C: Rollback all predicted GEs
C->>C: Cancel predicted montage
C->>C: EndAbility (bWasCancelled=true)
end
Note over C,S: Later: AnimNotify fires
C->>C: OnDamageEventReceived() -- skip (not authority)
S->>S: OnDamageEventReceived() -- apply damage GE to target
Note over C,S: Montage completes
C->>C: EndAbility()
S->>S: EndAbility()
Common Pitfalls¶
Don't spawn actors in a predicted context
If you spawn a projectile inside the prediction window, both the client and server will try to spawn it. You'll get duplicate actors. Instead, either:
- Spawn cosmetic-only projectiles on the client (no gameplay logic) and real projectiles on the server
- Use
FScopedPredictionWindowcarefully withSpawnActorDeferredand track by prediction key - Use the
AbilityTask_SpawnActortask, which handles this correctly
Don't predict effects that shouldn't roll back
If you apply a GE that modifies another player's attributes inside a prediction window, that change will be rolled back on misprediction — but the other player might have already reacted to it. Only predict effects on the owning player's ASC.
Execution Calculations are not predicted
From the engine source: "Executions do not currently predict, only attribute modifiers." If your damage GE uses an ExecCalc, apply it on the server only (check IsNetAuthority()). This is why our example applies damage only on the server in OnDamageEventReceived.
Periodic effects are not predicted
DoT effects won't tick predictively on the client. The client sees the effect applied (tag granted) but individual ticks only execute on the server. The resulting attribute changes replicate down normally.
Variations¶
Predictive projectile spawn
For projectiles, use a split approach: the client spawns a cosmetic-only projectile (visual mesh, particles, sound) immediately for responsiveness. The server spawns the real projectile with collision and damage. When the server projectile's position replicates, smoothly blend the cosmetic one to match. On misprediction, destroy the cosmetic projectile.
WaitNetSync for hit confirmation
Use UAbilityTask_NetworkSyncPoint::WaitNetSync with EAbilityTaskNetSyncType::OnlyServerWait to synchronize at the point where damage should be applied. The server waits for the client's "I hit something" signal before processing damage. The client signals immediately and continues. This ensures the server has the client's targeting data before processing.
LocalOnly abilities
For abilities that are purely cosmetic (emotes, inspect weapon), use NetExecutionPolicy = LocalOnly. These run only on the owning client and never involve the server. No prediction needed, no confirmation needed.
Related Pages¶
- Prediction — full deep dive on the prediction system
- Net Execution Policies — LocalPredicted vs ServerOnly vs LocalOnly vs ServerInitiated
- Replication Modes — how ASC state replicates
- Melee Attack — simpler version without prediction focus
- Ability Tasks —
WaitNetSyncand other network-aware tasks - Lifecycle and Activation — CommitAbility and the activation flow