Let's talk

Cost and cooldown without rubber-banding

Stamina drops, then jumps back up, then drops again. The cooldown icon resets for a moment. The ability works, but it feels broken at 150 ms. Four setup details cause almost all of it.

How prediction should look

With NetExecutionPolicy = LocalPredicted, the owning client activates the ability at once and sends the activation to the server with a prediction key. When the client calls CommitAbility, it applies the cost and cooldown effects locally under that key. The server does the same. When the server's result replicates back, the client's predicted effects are matched to the key and removed, and the server's values take over.

Done right, the player sees one smooth change. Rubber-banding means one step of that chain is broken.

1. Commit inside the window

The prediction key is valid during the activation call. Commit there:

C++RSExampleDashAbility.cpp
void URSExampleDashAbility::ActivateAbility(
    const FGameplayAbilitySpecHandle Handle,
    const FGameplayAbilityActorInfo* ActorInfo,
    const FGameplayAbilityActivationInfo ActivationInfo,
    const FGameplayEventData* TriggerEventData)
{
    if (!CommitAbility(Handle, ActorInfo, ActivationInfo))
    {
        EndAbility(Handle, ActorInfo, ActivationInfo, true, true);
        return;
    }

    // ... the ability's work
}

Commit after a latent step (a delay, a montage wait, target data) and you are outside that window. The client cannot predict the cost, so it waits for the server, and the UI jumps a round trip later. If you need to commit late, for example a charge-up that only costs on release, do it in a callback that runs with a fresh prediction key, such as a WaitInputRelease task, and test it at latency.

2. Prediction-safe attribute OnReps

C++RSExampleAttributeSet.cpp
void URSExampleAttributeSet::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);
    DOREPLIFETIME_CONDITION_NOTIFY(URSExampleAttributeSet, Stamina, COND_None, REPNOTIFY_Always);
}

void URSExampleAttributeSet::OnRep_Stamina(const FGameplayAttributeData& OldValue)
{
    GAMEPLAYATTRIBUTE_REPNOTIFY(URSExampleAttributeSet, Stamina, OldValue);
}

Two details:

  • REPNOTIFY_Always. If the server's value equals the client's predicted value, a plain OnRep does not fire, and the ASC never learns the prediction was confirmed.
  • GAMEPLAYATTRIBUTE_REPNOTIFY. It tells the ASC about the new base value so any still-pending predicted modifiers are re-applied on top. Setting the value yourself in the OnRep wipes the prediction, and you see the jump.

3. Mixed mode on player ASCs

With Minimal replication mode, the owning client never receives the gameplay effects, including its own cooldown. Prediction of cooldown tags still runs locally, but the confirmed effect never arrives, so UI that reads remaining time shows zero or flickers. Use Mixed for player-controlled ASCs, as in the PlayerState setup.

4. Cooldown duration from data

One cooldown effect per ability gets messy. A common pattern is one generic cooldown effect whose duration is a SetByCaller value, set per ability:

C++
void UMyGameplayAbility::ApplyCooldown(
    const FGameplayAbilitySpecHandle Handle,
    const FGameplayAbilityActorInfo* ActorInfo,
    const FGameplayAbilityActivationInfo ActivationInfo) const
{
    const UGameplayEffect* CooldownGE = GetCooldownGameplayEffect();
    if (!CooldownGE)
    {
        return;
    }

    FGameplayEffectSpecHandle Spec = MakeOutgoingGameplayEffectSpec(CooldownGE->GetClass(), GetAbilityLevel());
    Spec.Data->SetSetByCallerMagnitude(TAG_Data_Cooldown, CooldownSeconds);
    Spec.Data->DynamicGrantedTags.AppendTags(CooldownTags);
    ApplyGameplayEffectSpecToOwner(Handle, ActorInfo, ActivationInfo, Spec);
}

If you use per-ability cooldown tags this way, also override GetCooldownTags to return them, so CheckCooldown looks for the right tags.

Outside abilities, MakeSpecWithSetByCaller from the free GAS Helpers builds a spec and fills several SetByCaller values in one call. A missing SetByCaller value is the usual reason an effect applies with magnitude 0.

Short cooldowns at high latency

GAS cannot predict the removal of a cooldown. The server starts its cooldown half a round trip after the client did, so it also ends later. On a 0.3 second cooldown at 200 ms, the client's cooldown ends, the player presses again, and the server still sees the cooldown and rejects the activation. The client rolls back. High-ping players get a slower fire rate.

Options, in order of preference:

  1. Do not use a GAS cooldown for very fast actions. Rate-limit a weapon in the weapon's own fire logic and use GAS for the effects it applies.
  2. Give the server a small tolerance. Override CheckCooldown and, on the server only, accept the activation if the remaining time is under a cap you choose, for example 100 ms.
C++
bool UMyGameplayAbility::CheckCooldown(
    const FGameplayAbilitySpecHandle Handle,
    const FGameplayAbilityActorInfo* ActorInfo,
    FGameplayTagContainer* OptionalRelevantTags) const
{
    if (Super::CheckCooldown(Handle, ActorInfo, OptionalRelevantTags))
    {
        return true;
    }

    // Server only: forgive the last few milliseconds the client could not see.
    const bool bIsServer = ActorInfo && ActorInfo->IsNetAuthority();
    return bIsServer && GetCooldownTimeRemaining(ActorInfo) <= ServerCooldownTolerance;
}

The tolerance is a small window a cheater could use. Keep it below your worst acceptable ping and log how often it is used.

Check it at latency

Every change here behaves the same in PIE without emulation. Test at 150 ms with two clients and a dedicated server, as in the real latency tutorial.

Get the code

The predicted Dash ability and the prediction-safe AttributeSet are in GAS Helpers v0.1.0. Browse RSExampleDashAbility.cpp, free under the MIT license.

Cost and cooldown jitter that survives these four fixes usually comes from a custom movement or montage setup fighting the ASC. The Diagnostic traces it.

Still desyncing in packaged builds?

Free code fixes one symptom. A Diagnostic finds the cause across your project in two days.