For Client-Side Prediction to work effectively, we have one primary goal:
Our gameplay code must strive to be 100% deterministic.
What does this mean? It means that for the same set of inputs, your code should produce the exact same outcome, every time, on any machine. If your client’s code calculates that Input A
results in State B
, the server’s code must do the same.
Now, you might be thinking,
But isn’t perfect determinism impossible in Unity because of things like floating-point math or physics?
And you’d be absolutely right. Tiny inaccuracies can and do occur between the client and server simulations.
This is where the magic of a good prediction system like PurrNet comes in. It’s designed to handle these minor discrepancies. When the server sends a corrected state, the client doesn’t just “snap” to the new position. It smoothly interpolates the correction over a fraction of a second, making tiny errors completely invisible to the player.
So, while the system can handle small mistakes, our job as developers is to not make its life harder. The “unbreakable rule” is really about our own code: we must avoid sources of major non-determinism. If our code is deterministic, the reconciliation system only has to correct tiny floating-point errors. If our code is not deterministic (e.g., using UnityEngine.Random
), the errors will be large and frequent, leading to a jumpy, jittery experience for the player. This is a desync.
Let’s explore the common “determinism killers”—the major sources of non-determinism that we, as developers, must control.
1. The Randomness Problem
Computers can’t generate truly random numbers; they use complex algorithms seeded by a starting value. The problem is where that seed comes from.
❌ The Wrong Way: UnityEngine.Random
UnityEngine.Random
(and System.Random
) is often seeded by the system clock or other non-predictable values. This means Random.Range(0, 10)
will produce a different sequence of numbers on your machine versus the server’s.
// ❌ NON-DETERMINISTIC: This will cause desyncs!
void Simulate(ref PlayerState state, float delta)
{
// Every client and the server will get a DIFFERENT number.
if (UnityEngine.Random.Range(0f, 1f) < 0.1f)
state.health += 10;
}
C#✅ The Right Way: PredictedRandom
The solution is to use a random number generator where the seed itself is part of the game state. When the game state is rolled back and reconciled, the RNG’s seed is also rolled back. This guarantees that for a given tick, the “random” number will be the same for everyone.
PurrNet provides PredictedRandom
for this exact purpose.
✅ A Perfect Example from my Project:
In AIPatrol.cs
, a deterministic seed is generated based on the AI’s unique ID and position. This PredictedRandom
instance is then used to calculate a patrol direction. Because the ID and position are the same for all players, the resulting “random” angle will be identical everywhere.
private static Vector2 GetRandomDirection(AIController aiController)
{
// Generate a seed that is the same on all clients for this specific AI.
var deterministicSeed = (uint)math.abs(aiController.gameObject.GetInstanceID());
var position = aiController.transform.position;
deterministicSeed = math.hash(new float3(position.x, position.y, deterministicSeed));
// Ensure seed is never 0 (a Unity.Mathematics.Random requirement)
if (deterministicSeed == 0) deterministicSeed = 1;
// Create a deterministic RNG with this shared seed.
var random = PredictedRandom.Create(deterministicSeed);
float angle = random.NextFloat(0f, 360f) * Mathf.Deg2Rad;
return new Vector2(math.cos(angle), math.sin(angle));
}
C#Note: In a more robust system, this PredictedRandom
instance would be stored in the AI’s state struct and advanced with each use, rather than being re-created.
2. The Time Problem
Time seems simple, but in game engines, it’s tied to how fast your computer can render frames.
❌ The Wrong Way: Time.deltaTime
Time.deltaTime
is the time elapsed since the last frame. If you are running at 144 FPS, Time.deltaTime
will be small (0.0069s). If your friend is running at 60 FPS, their 0.0166s).Time.deltaTime
will be larger (
If you calculate movement using this, the player running at 144 FPS will move a shorter distance per frame, but more frequently, while the 60 FPS player will move a larger distance less frequently. Over many frames, rounding errors in floating-point math will accumulate, causing a desync.
// ❌ NON-DETERMINISTIC: Player movement will differ based on FPS.
void Simulate(ref PlayerState state, float delta)
{
// This is a desync waiting to happen!
state.position += state.velocity * Time.deltaTime;
}
C#✅ The Right Way: The Fixed delta
PurrNet operates on a fixed tick rate (e.g., 30 ticks per second). This means the simulation always advances in fixed time steps, completely independent of the framerate. In my project, this is 1/30 = 0.0333...
seconds per tick.
PurrNet provides this fixed time step as the delta
parameter in its Simulate
methods. You must always use this delta
.
✅ A Good Example from Our Project:
PlayerMovementModule.cs
correctly uses the delta
provided by PurrNet to calculate the new velocity, ensuring the physics calculations are identical regardless of framerate.
private void ApplyMovementPhysics(Vector2 inputVector, float speedMultiplier, float lerpSpeed, float delta)
{
var currentVelocity = playerController.PredictedRb.linearVelocity;
var newVelocity = MovementUtility.CalculateMovementVelocity(
currentVelocity,
inputVector,
data.maxSpeed,
speedMultiplier,
lerpSpeed,
delta // ✅ USING THE DETERMINISTIC, FIXED DELTA
);
playerController.PredictedRb.linearVelocity = newVelocity;
}
C#3. The Collection Order Problem
When you ask Unity for a list of objects, like FindObjectsByType
, there is no guarantee about the order in which you’ll get them. If the server gets [PlayerA, PlayerB]
and a client gets [PlayerB, PlayerA]
, and your code processes them in that order, the simulation will diverge.
❌ The Wrong Way: Unordered Collections
// ❌ NON-DETERMINISTIC: The order of this array can be different for everyone.
var scenePlayers = FindObjectsByType<PlayerController>(FindObjectsSortMode.None);
foreach (var player in scenePlayers)
{
// Processing players in a random order will lead to desyncs if they interact.
}
C#✅ The Right Way: Stable Sorting
You must always enforce a stable, deterministic sort order on any collection you iterate over. The best way is to sort by a value that is unique and consistent for each object, like a NetworkID
.
✅ A Critical Fix from my Project:
PlayerManager.cs
correctly finds all PlayerController
objects and then immediately sorts them by their id
‘s hash code before processing them. This ensures the list of players is in the same order on the server and all clients.
// Player.PlayerManager.cs
private void DiscoverScenePlayers(ref PlayerManagerState state)
{
// 1. Get the players in whatever order Unity provides.
var scenePlayers = FindObjectsByType<PlayerController>(FindObjectsSortMode.None)
// 2. ✅ CRITICAL: Immediately sort them into a predictable order.
.OrderBy(p => p.id.GetHashCode())
.ToArray();
state.activePlayerIds.Clear();
// 3. Now, process them in a guaranteed-deterministic order.
foreach (var player in scenePlayers)
{
state.activePlayerIds.Add(player.id);
}
}
C#Next Up: Part 3 – Reconciliation & State Management
Now that we understand how to keep our simulation deterministic, we’ll explore what happens when it breaks, and how the magic of reconciliation fixes it.
Leave a Reply