Real-time multiplayer games have to keep clients in agreement over a network that offers no guarantees. Latency delays input, jitter makes motion stutter, and packet loss puts players in places they never went. The usual answers are well known — an authoritative server, client-side prediction, interpolation — but knowing the techniques is not the same as knowing what each one buys you, or what it costs.
We built a multiplayer first-person shooter on the Ursina engine to measure exactly that. Alongside the synchronization work, we compared two transports: WebSocket, which rides on TCP and guarantees ordered delivery, and UDP, which is faster and lossier and needs its own machinery to cope.
When every mechanism costs something, which ones actually earn their keep as the network degrades?
We answer it with two numbers: how far the player ends up from where they should be, and how jerky the motion looks getting there.
Approach
Server authority and snapshots
The prototype is server-authoritative on a fixed 20 Hz tick. Clients send inputs, never state. Each tick the server applies the inputs it has received, advances the simulation, and produces a snapshot of the authoritative world — entity positions, velocities, bullets, obstacles — which it sends to every connected client.
Sending inputs rather than state is what makes the rest possible. The server stays the single source of truth, runs deterministically, and can validate everything; clients are left to worry only about presenting it smoothly.
Prediction and reconciliation
Waiting for a round trip before the player sees their own movement feels terrible, so the client predicts. When an input is generated it is applied locally immediately, and also sent to the server tagged with a sequence number.
When the authoritative snapshot arrives, the client compares it against what it predicted. If they disagree, it rolls its state back to the server's version and replays every input the server had not yet acknowledged. This rollback-and-replay keeps the client eventually consistent with the server without ever making the player wait for the network. Corrections are occasionally visible, and that visibility turns out to matter for smoothness later.
Interpolation
Snapshots do not arrive on a tidy schedule. Rather than rendering the newest one the moment it lands, the client deliberately renders about 100 ms in the past, interpolating between the two snapshots that bracket the render time.
This buys a buffer: late snapshots have a window to arrive before they are needed, so entities glide instead of teleporting. The cost is a fixed, controlled visual delay — trading a little latency for a lot of stability.
Server-side hit detection
All hit resolution happens on the server. Clients may play a muzzle flash optimistically, but they do not decide whether anything was hit. The server checks bullet trajectories against the authoritative state at the right tick and applies damage itself, so a client cannot manufacture hits or exploit its own latency.
Protocols
Both transports carry the same three message types — connection, input, and snapshot.
Over WebSocket, the client opens an HTTP connection on port 8000, the server answers
101 Switching Protocols, and everything afterwards is JSON over a reliable ordered channel.
The connection-completion message also hands the client a UDP endpoint, so the session can
switch transports — with WebSocket remaining as a fallback on networks where UDP is blocked by
NAT or defeated by a small MTU.
Over UDP there is no channel and no delivery guarantee, so the payloads are hand-packed binary. Every message opens with a common header giving version, message type, and payload length.
Input messages can carry several past inputs, not just the current one, so that a single
packet arriving after a gap can restore the history the server missed. Because UDP has no
built-in acknowledgement, the client confirms delivery with an ACK carrying the last sequence
number received plus a 64-bit arrival mask — a sliding window where each bit marks whether
one earlier packet arrived. If sequence 10 is current and 8 and 9 are missing, the mask reads
...111001.
Snapshots are larger and need care. With a 1500-byte MTU only 1472 bytes fit in a packet, and a snapshot holds every player and every bullet, so it is fragmented at the application layer.
Two details make fragmentation survivable. A client's own state is mandatory and always rides in the first packet — and is sent redundantly — so self-position stays accurate even when other data is lost. And every entity has a timeout, so a player whose state is missing from a snapshot holds position briefly instead of flickering out of existence.
Evaluation
Setup
We needed repeatable network conditions, so we emulated them rather than hunting for bad
networks. Client and server run on the same physical Windows host to keep everything outside the
emulator negligible — end-to-end delay without emulation averages 0.3 ms. The server runs in
a Docker container on Ubuntu under WSL, where the Linux kernel's packet scheduler is available,
and tc applies ingress and egress delay and loss on the container's interface.
To keep runs comparable, players were driven by a script rather than a human: pattern (a) moves in a straight line for 10 seconds, pattern (b) traces a square by cycling W→D→S→A at one-second intervals. The client renders at a vsync-locked 144 Hz, well above both the server tick and the input rate, and every metric is sampled per rendered frame — so rendering hitches show up in the results too, which is the honest way to measure something a player actually sees.
Accuracy
Accuracy is the Euclidean distance between the position the server reports and the position the client would have computed locally from the same inputs:
Error = sqrt( (x_actual - x_ideal)² + (y_actual - y_ideal)² + (z_actual - z_ideal)² )
Server-side collision was disabled for these runs so players always move exactly as their inputs dictate, isolating the synchronization behaviour from the physics.
This is the clearest result in the project. Prediction is what keeps error flat as latency rises; without it, error grows linearly with RTT, because the player is simply waiting for snapshots that arrive later and later.
There is a counterintuitive wrinkle: some configurations without self-position interpolation scored slightly better here. That is a measurement artifact with a real cause — interpolating your own position introduces up to one input tick of delay, since the client eases toward the target across several frames rather than snapping to it. Against a metric defined as "distance from the instantaneous ideal", easing looks like error. It also looks considerably better in motion, which is what the smoothness section measures.
Watching it frame by frame shows the mechanism: error ramps up between snapshots and collapses when one arrives, over and over. Once input stops, everything converges — the error is a property of motion, not of position.
Against packet loss the averages say almost nothing: everything sits between 0.3 and 1 with no trend. The time series explains why the average is the wrong summary.
Without prediction, each lost input is movement that never happens, so the player falls progressively behind and the error never comes back — it is still wrong after the movement ends. With prediction, loss stays bounded.
Protocols
Here is the result we least expected. The redundant input history made things slightly worse. Plain UDP outperformed both WebSocket and the variant carrying two inputs per packet.
The cause is a design flaw rather than a measurement error. Any input whose sequence number is older than the last tick the server already processed gets discarded, even if it is sitting in the queue — so only the first packet to arrive after a loss can contribute recovered history. Everything after that is redundant in the useless sense. The mechanism pays its bandwidth cost on every packet and collects on almost none of them.
Smoothness
Accuracy says where the player ended up; it says nothing about whether getting there looked right. For that we take acceleration per frame and measure how much it changes frame to frame:
Accel_t = ((P_t - P_t-Δt)/Δt - (P_t-Δt - P_t-2Δt)/Δt) / Δt
Loss of Smoothness = | Accel_t - Accel_t-Δt |
Purely local movement — no network at all — scores around 20. That is the target.
Only the complete stack produces smooth motion, and it does so at every latency we tested. The ordering in between is instructive: prediction alone is worse than no prediction at all. Without prediction the position updates once per snapshot, so there is one acceleration jump per tick. With prediction but no interpolation there are two update sources — the input tick and the arriving snapshot — and reconciliation snaps the position instantly when they disagree. More correctness, more visible discontinuities.
Protocol choice barely registers. Smoothness is decided entirely by the client-side mechanisms.
And even at its best, the full stack stays well short of the local baseline. Our interpolation is linear: it smooths position without any attempt to keep acceleration continuous, and the metric is a second derivative. Fixing that means a higher-order scheme — spline interpolation, or explicit predictive smoothing.
What we took away
Three things, in rough order of how much they surprised us.
Prediction is the load-bearing mechanism. It is what decouples perceived responsiveness from RTT, and it holds error flat across every latency and loss rate we tested. Nothing else in the stack comes close in impact.
Half a solution can be worse than none. Prediction without interpolation measurably degrades smoothness relative to no prediction at all, because it adds a second source of position updates and lets reconciliation snap the player around. These mechanisms are a set, not a menu.
Our redundancy scheme did not work. It was a reasonable idea implemented in a way that could only ever help on the first packet after a loss, and the data shows it — slightly worse than plain UDP, for strictly more bandwidth. The fix is to let the server accept recovered inputs further back than the last processed tick, which is a change to the queueing rule rather than to the packet format.
The broader point is that responsiveness, accuracy and smoothness genuinely trade against each other, and the right balance depends on the network you are actually on. That argues for adaptive synchronization — varying the interpolation delay or the tick rate with measured conditions — rather than the fixed constants we used throughout.
Left for future work: higher-order interpolation, better input recovery under heavy loss, scalability in server architecture and player count, and fault tolerance on both ends.