Movement that waits for a server round trip feels late. Movement that trusts the client can be wrong. Client-side prediction is the compromise: let the local player move immediately, then use the server’s state to correct the simulation when the two sides disagree.
I built this flow in a private Unity networking prototype. The repository documents the implementation and local EditMode cases, but it contains no recorded two-client session with controlled packet loss. The diagram maps the code path; it is not a measured multiplayer result.
Send intent, simulate locally
The client samples a small input record for each simulation tick: movement axes, view angles, and actions such as jump or sprint. The values are quantized before they enter the network message. The client stores that input, advances the local motor immediately, and keeps the resulting state in a second buffer.
That separation matters. The server receives what the player tried to do, rather than accepting a client-supplied transform as authoritative. It then runs the movement simulation and sends a state snapshot back to the owning client. This is a useful authority boundary, but it is not a complete anti-cheat system: movement limits, rate checks and adversarial inputs still need deliberate validation.
NETWORK MODEL / SOURCE REVIEW
Prediction is a loop, not a teleport
- 01 / CLIENTCapture intentQuantize movement, view angles and actions for one tick.INPUT BUFFER
- 02 / PREDICTMove immediatelyRun the fixed-step motor and save the predicted state.STATE BUFFER
- 03 / SERVERSimulate authorityQueue received input, advance the motor and return a snapshot.UNRELIABLE RPC
- 04 / CLIENTCompare & replayKeep close predictions; restore the snapshot and replay newer input when they diverge.RECONCILIATION
Other players take a different path. Their snapshots go through a delayed buffer and are interpolated for rendering.
The current controller uses a fixed simulation step and calls the same KinematicMotor.Simulate function on its prediction and server paths. Sharing one movement function reduces one common source of drift: two separately maintained implementations slowly behaving differently.
Reconciliation is rewind and replay
When the server snapshot arrives, the client looks up its prediction for that tick and compares position and velocity. If the difference remains within the configured tolerance, it keeps moving. If the error is larger, it replaces the predicted state with the server state and replays newer buffered inputs through the motor.
authoritative = snapshot from server at tick T
predictedAtT = stateBuffer[T]
if closeEnough(predictedAtT, authoritative):
keep current prediction
else:
state = authoritative
for each saved input after T:
state = simulate(state, input)
This avoids treating every correction as a visual snap. It also makes buffer bookkeeping part of correctness: the controller needs the matching input for every tick it intends to replay. If that history is missing, the current code resets prediction to the received snapshot and clears its buffers. That recovery path discards the pending replay history; its visible effect under buffer overrun still needs a dedicated test.
Other players use a separate path. Their snapshots are placed in a delayed buffer and sampled for rendering, so their visuals can be smoothed between network updates without changing the locally predicted player.
What the source and tests establish
The EditMode tests cover input quantization, state and ring-buffer contracts, and repeatable movement from identical inputs. They exercise local simulation properties; none creates a two-client network session or injects latency, packet loss or reordering. The shared fixed-step motor reduces one source of client/server drift, but it still queries Unity physics. The project notes that PhysX behavior can vary across platforms, so this is not evidence of cross-platform deterministic networking.
The source also exposes a recovery case that those tests do not cover. A correction can arrive after its tick has fallen out of the client’s input history. In that case, the controller resets to the server snapshot and clears its buffers instead of replaying the missing history. The table separates that implementation behavior from what the test suite has actually exercised.
| Scenario | Behavior in the current source | Evidence status |
|---|---|---|
| Snapshot tick is buffered and the error is within tolerance | Keep the current predicted state. | Source path; no network integration test. |
| Snapshot tick is buffered and the error exceeds tolerance | Restore the authoritative state, then replay newer buffered inputs. | Source path; local buffer tests do not exercise a server correction. |
| Snapshot tick has fallen out of input history | Restore the snapshot and clear prediction/input buffers; pending replay is discarded. | Source-derived recovery path; no dedicated regression test is recorded. |
| Inputs or snapshots arrive late, are lost or are reordered between two clients | No controlled network scenario is included in the repository. | Not verified. |
The next test should force buffer overrun
I would run the same recorded movement sequence in a two-client session first with a clean connection, then with controlled latency, loss and reordering. The test should force a server snapshot to arrive after its input tick has left the ring buffer, and assert separately which inputs are replayed and which recovery path runs.
For each tick, I would record the input tick, server-processed tick, position correction in world units, replayed-input count, buffer misses and reset count. A useful pass condition is not an invented “lag-free” score: it is that the client never replays inputs at or before the authoritative tick, a missing history takes the documented reset path, and the client converges within the configured tolerance after the network drains. The README suggests external delay injection, but the repository does not include a built-in network impairment harness. Until this scenario is run and recorded, the article describes the correction design and its uncovered edge case—not behavior under network faults.
For another example of making a system boundary explicit, see how I approached the PostgreSQL-to-Kafka outbox.