How-To: Fix Movement Rubberbanding

Goal: stop the owning client from snapping back under latency when gameplay effects change movement - sprint buffs, slows, haste, anything that drives MaxWalkSpeed through an attribute. Each section below is one concrete cause and its fix.

Prerequisites
How-To: Set Up Attributes for the attribute-set basics, and Concept: Multiplayer and Prediction for where things run.

Why it happens

The Character Movement Component already predicts your movement. The owning client simulates immediately, sends each move to the server, and the server replays that move using the client's own acceleration - input direction and magnitude can never disagree. The only thing the two machines CAN disagree about is the physics the move is replayed with: MaxWalkSpeed, MaxAcceleration, GravityScale, the rotation-mode flags. When the server replays your move with a different MaxWalkSpeed than you predicted with, the positions diverge and the server sends a correction - the visible snap.

The tolerance is tiny. The engine default (AGameNetworkManager::MAXPOSITIONERRORSQUARED = 3.0) corrects anything past about 1.73 units of error. A 20 percent speed mismatch held for one round trip produces dozens of units, so every unpredicted speed change is a guaranteed, visible correction - latency only sets how big it is.

The one rule behind every fix
Every Character Movement parameter must change on the client and the server at the same point in the move timeline. Three routes achieve that:
1. A Local Predicted gameplay effect driving an attribute that BOTH machines push to the movement component.
2. Code inside a predicted ability - its instance runs on the owning client and on the server, each at the right moment.
3. Deterministic shared configuration both machines read (class defaults, an ini-backed setting).
A one-off RPC that mirrors a decision from one machine to the other is never one of these routes: it is not ordered against the move stream.

Problem 1: The client never predicts the speed change

Symptom: a hard snap the moment a movement buff or slow applies, every single time.

Cause: the attribute-to-MaxWalkSpeed push (or a derived-attribute recalculation feeding it) is gated behind HasAuthority(). A Local Predicted gameplay effect changes the attribute on the owning client immediately - but the gate skips the push, so the client keeps predicting with the old speed for a full round trip while the server already simulates the new one.

Solution: run the push in PostAttributeChange on every machine. The engine fires PostAttributeChange on clients too - for predicted effects and for replicated values alike; there is no authority gate anywhere in GAS, so the gate is always self-inflicted. Keep the OnRep_ handler pushing to the movement component as well: it is the reconciliation step if the client mispredicted.

cpp
#include "MyMovementSet.h"
#include "AbilitySystemComponent.h"
#include "GameFramework/Character.h"
#include "GameFramework/CharacterMovementComponent.h"
// MoveSpeed is declared with the four-part pattern from How-To: Set Up Attributes.
namespace
{
UCharacterMovementComponent* GetMovementComponent(const UAttributeSet* Set)
{
// The ASC may live on the PlayerState, so resolve the pawn through the
// avatar actor - never through GetOwningActor().
if (const UAbilitySystemComponent* ASC = Set->GetOwningAbilitySystemComponent())
{
if (const ACharacter* Character = Cast<ACharacter>(ASC->GetAvatarActor()))
{
return Character->GetCharacterMovement();
}
}
return nullptr;
}
}
void UMyMovementSet::PostAttributeChange(const FGameplayAttribute& Attribute, float OldValue, float NewValue)
{
Super::PostAttributeChange(Attribute, OldValue, NewValue);
// Deliberately NOT gated on HasAuthority(). The owning client must derive the
// same MaxWalkSpeed the moment its predicted effect applies, or it predicts with
// a stale speed for a full round trip and every speed change is a correction.
if (Attribute == GetMoveSpeedAttribute())
{
if (UCharacterMovementComponent* Movement = GetMovementComponent(this))
{
Movement->MaxWalkSpeed = NewValue;
}
}
}
void UMyMovementSet::OnRep_MoveSpeed(const FGameplayAttributeData& OldValue)
{
// Reconciliation: if the client mispredicted, the server's value wins here.
GAMEPLAYATTRIBUTE_REPNOTIFY(UMyMovementSet, MoveSpeed, OldValue);
if (UCharacterMovementComponent* Movement = GetMovementComponent(this))
{
Movement->MaxWalkSpeed = GetMoveSpeed();
}
}
Reference implementation
UCrimsonMovementSet (CrimsonCore - requires all Crimson plugins) applies this pattern to seven movement parameters and derives final speeds from tiered component attributes ((Base + Flat) * (1 + Additive) * Multiplier) - study its PostAttributeChange and OnRep_ handlers.
Verify
Play As Client with network emulation on. Apply the speed effect: MaxWalkSpeed changes on the owning client the same frame the effect applies, not a round trip later, and the apply edge produces no correction.

Problem 2: The buff turns off a round trip late

Symptom: the apply edge is clean, but releasing or expiring the effect (letting go of sprint) still snaps.

Cause: GAS cannot predict gameplay effect REMOVAL. Once the server's replicated effect has replaced the client's predicted one, the client is not allowed to remove it - RemoveActiveGameplayEffect on a client logs a warning and does nothing - so the modifier stays live on the client until the server's removal replicates back.

Solution: do not remove - inhibit by tag. Ability activation tags ARE predicted: a tag in Activation Owned Tags appears on the ASC the instant the ability activates on each machine and disappears the instant it ends. Gate the effect's modifiers on that tag and they switch off on the predicting client the same frame the ability ends locally.

  1. On the ability, add your gate tag (for example Status.Sprinting) to Activation Owned Tags.
  2. On the Infinite gameplay effect, under Components, add Target Tag Reqs (While GE is Active) (listed as Require Tags to Apply/Continue This Effect).
  3. Set Ongoing Tag Requirements -> Must Have Tags to the gate tag. Leave the Application and Removal groups empty.
  4. Keep removing the effect in EndAbility on the authority only - that is now just final cleanup; the inhibition already handled the timing.
Do not use Removal Tag Requirements for this
Two engine behaviors make the Removal group the wrong tool. First, removal-by-tag is authority-only on clients (the legacy escape hatch AbilitySystem.Fix.AllowPredictiveGEFlags defaults to 0 - Epic classifies client-side removal as a fixed UE5.3 bug), so it cannot predict anything. Second, an effect whose removal requirements are already met at apply time FAILS TO APPLY at all - and with this pattern the gate tag is present at apply. The Ongoing group is the predicted off-switch; the Removal group is not.
Reference implementation
GA_Sprint (/CrimsonCore/AbilitySystem/Abilities/Movement/) and GE_SprintSpeed (/CrimsonCore/AbilitySystem/GameplayEffects/Movement/) (CrimsonCore - requires all Crimson plugins) wire exactly this: Status.Sprinting in Activation Owned Tags, the same tag in the effect's Ongoing requirements, authority-only removal in EndAbility.
Verify
Under emulation, the release edge is now as clean as the press edge: speed drops on the owning client the frame the input is released.

Problem 3: The Blueprint stomped your tags

Symptom: the Ongoing gate from Problem 2 never turns on - the effect applies already inhibited, so the buff appears to do nothing - even though the C++ constructor clearly adds the tag to Activation Owned Tags.

Cause: a Blueprint subclass saves overrides per property. Touching the Tags category in the Blueprint editor even once can save an EMPTY container over the C++ constructor's value - silently, with no warning anywhere.

Solution: open the ability Blueprint -> Class Defaults -> Tags and confirm Activation Owned Tags actually lists your gate tag (and that Activation Blocked Tags still lists what the C++ set). If a container is unexpectedly empty, re-add the tags there. Check this FIRST whenever a constructor-set tag seems to have no effect at runtime and the runtime class is a Blueprint subclass.

Verify
While the ability is active, run showdebug abilitysystem and confirm the gate tag appears in the ASC's owned tags on both the server and the owning client.

Problem 4: Parameters changed outside the move timeline

Symptom: rubberbanding tied to specific actions rather than to buffs - most often attacks: root-motion montages drift or snap.

Cause: some code path writes a movement parameter on one machine only, or mirrors it across with a one-off RPC. The rotation-mode flags (bUseControllerRotationYaw, bOrientRotationToMovement, bUseControllerDesiredRotation) are the classic case: they look cosmetic, but root motion applies translation in actor space, so a yaw disagreement between client and server becomes a position disagreement and the montage rubberbands.

Solution: route the change through one of the three routes from The one rule behind every fix. A predicted ability is usually the right vehicle: plain code in ActivateAbility / EndAbility runs on the owning client and on the server, each aligned with the move stream, with nothing extra sent over the wire. Audit for Server_... RPCs whose only job is to copy a movement-affecting flag from one machine to the other - each one is a desync waiting for a root-motion montage.

Prove it with corrections on screen

Corrections are measurable - never tune blind. In the CLIENT window's console run p.NetShowCorrections 1 (each correction draws debug capsules and logs to LogNetPlayerMovement; add Log LogNetPlayerMovement Verbose for detail). Turn on network emulation in Editor Preferences -> Level Editor -> Play (add latency AND packet loss, both directions), use 2+ players, and test each scenario: plain walking, buff apply, buff release, a root-motion attack.

Read the client window, not the host
On a listen server the host pawn IS the authority - it never takes the correction path, so the host window shows nothing regardless of how broken the netcode is. Only a real client exercises prediction.
Verify
Walking in a straight line is your floor - near zero corrections. After each fix above, its scenario (apply, release, montage) sits at that same floor.

What latency still costs

Server-initiated changes - a slow another player lands on you - cannot be predicted by anyone. The cost is exactly one correction per event, smoothed by the engine, with your unacknowledged inputs replayed on top. That is the system working as designed; it is rare and acceptable. What is never acceptable is a correction on every change YOUR OWN client initiates - that is always one of the four problems above.

Do not widen the tolerance to hide bugs
Raising MAXPOSITIONERRORSQUARED (AGameNetworkManager) while any of the problems above exist does not fix the desync - it only delays the correction until the error is bigger. Fix the prediction first; only then consider tolerance tuning, and only for residual noise.

See also

  • Concept: Multiplayer and Prediction
  • How-To: Set Up Attributes
  • How-To: Block and Cancel by Tag