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.
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.
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.
#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();}}
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.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.
- On the ability, add your gate tag (for example
Status.Sprinting) toActivation Owned Tags. - On the Infinite gameplay effect, under Components, add
Target Tag Reqs (While GE is Active)(listed as Require Tags to Apply/Continue This Effect). - Set
Ongoing Tag Requirements -> Must Have Tagsto the gate tag. Leave the Application and Removal groups empty. - Keep removing the effect in
EndAbilityon the authority only - that is now just final cleanup; the inhibition already handled the timing.
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.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.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.
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.
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.
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