Animations are tricky in a Client-Side Prediction (CSP) model. Do you play them instantly and risk them being wrong? Or do you wait for the server and risk them feeling laggy? The answer depends on ownership and the gameplay context.
This guide explains the advanced logic used in our project to handle every animation case correctly.
The 4 Horsemen of CSP Context: isOwner
, isServer
, isVerified
, isVerifiedAndReplaying
To make smart decisions about animation, you first need to understand the context you’re in. PurrNet provides several boolean properties that tell you exactly what’s happening.
isOwner
:- What it means: “Is this game instance the one that directly controls this object?”
- Client:
true
for your own player character.false
for everyone else’s character and all AI. - Server:
true
for all AI (since the server controls them).false
for all player-controlled characters (even though it has authority, it doesn’t “own” their inputs).
isServer
:- What it means: “Is this code running on the server/host?”
- Client: Always
false
. - Server/Host: Always
true
.
isVerified
:- What it means: “Is the simulation tick we are currently processing based on data that has been confirmed by the server?”
- Client: Only
true
during a rollback and re-simulation (a.k.a. “replaying”). It’sfalse
during normal, live prediction. - Server/Host: Always
true
during its normal simulation, because the server is the source of truth.
isVerifiedAndReplaying
:- What it means: This is a convenient property that essentially combines
isVerified && isReplaying
. It’s the most reliable way for a client to know: “Am I currently in a re-simulation process based on a server-corrected state?” - Client:
true
only during a replay. This is your go-to flag for triggering “correctness-first” logic. - Server/Host:
false
during normal simulation.
- What it means: This is a convenient property that essentially combines
The Great Divide: UpdateView()
vs. Simulate()
As mentioned in the previous guides, our project uses an architectural split to manage animations based on our needs.
ShouldUpdateAnimationsInView()
→ For Owned Objects
- When it’s true: When the object is yours (
isOwner
is true). - Why: This is for maximum responsiveness. When you press a button, you want to see your character animate instantly. Since you are the source of the inputs, your predictions are highly likely to be correct. We run this in
UpdateView()
which uses the smooth, interpolated state, making your character’s movement feel fluid. The system architecture guarantees thatUpdateView()
is never called during a rollback, so we only need to check for ownership.
ShouldUpdateAnimationsInSimulate()
→ For Non-Owned Objects
- When it’s true: When the object is not yours AND the server is running the code OR you are a client replaying a verified tick.
- Why: This is for maximum correctness. You have no idea what another player or an AI is going to do. Their state is constantly being predicted (extrapolated) and corrected. If you animated them based on your potentially wrong prediction, you’d see them stutter and pop constantly. By waiting for a
isVerifiedAndReplaying
tick, you guarantee the animation you are about to play corresponds to what the server actually said happened.
The Trade-Off: Responsiveness vs. Correctness
You can’t always have both. Sometimes you must choose.
The Problem with Animating Non-Owned Objects in UpdateView()
UpdateView()
runs every visual frame and uses an interpolated state. This state is a smooth “guess” between two past authoritative states sent by the server. If a client’s prediction about that object was wrong, the interpolated state is also a lie.
Scenario:
- Your client predicts an enemy AI will move left.
- The
UpdateView()
interpolates this predicted movement and plays the “Run Left” animation. - The server’s state arrives and says, “Actually, the AI moved right.”
- Reconciliation happens. The AI model snaps to its correct position.
- Result: The player sees the AI animate left for a few frames before instantly appearing on the right and running right. It looks like a bug.
By animating non-owned objects only on verified ticks inside Simulate()
, we avoid this visual lie. We accept a tiny delay (~50-100ms, the player’s latency) in exchange for the animation always matching the character’s true actions.
When to Break the Rules: The Case for Predicted Effects
Sometimes, instant feedback is more important than 100% accuracy. This is a deliberate design choice.
- Hit/Damaged Animations: When you hit an enemy, you want to see them react now. In our project, the
PlayerDamaged
state is allowed to transition and play its animation immediately, even on a prediction. If the server later says the hit didn’t happen, the animation might be cut short by the rollback, but the initial responsive feedback is worth that small risk. It makes combat feel crunchy and impactful. - Death Animations: This is the opposite. A death animation is a final, critical state change. It would be incredibly weird and frustrating for a player to see an enemy start dying, only for it to snap back to life because the prediction was wrong (e.g., the server determined your shot missed).
- Therefore, in our project, the
PlayerDead
state change and its animation MUST wait for server verification. We sacrifice a little responsiveness for absolute correctness on critical gameplay events.
- Therefore, in our project, the
📋 Animation Checklist
- Is this my character? (
isOwner
)
[✅] Animate in UpdateView()
for maximum responsiveness.
- Is this another player’s character or an AI? (
!isOwner
)
[✅] Animate in Simulate()
but guard it with isVerifiedAndReplaying
(or isServer
) for correctness.
- Is this a low-impact, feedback-oriented animation (like a hit flash)?
[✅] Consider playing it predictively in UpdateView
for all players to make the game feel more impactful. Accept the risk of it being rolled back.
- Is this a critical, gameplay-defining animation (like death, a stun, or a powerful ultimate ability)?
[✅] Never play this predictively for non-owned objects. Always wait for the server’s authority.
Leave a Reply