In the last post I killed the movement jank in Medieval Boom — the multiplayer Bomberman clone I’ve been building — by interpolating between the server’s discrete sub-cell positions. I ended that post with a line I was rather pleased with:
The problem was between the server’s positions, not between the server and the client.
That was true. It was also only half the story. There’s a second kind of lag, and it lives in exactly the place I’d waved away: between the server and the client.
I should say upfront: I’m new to game development. Medieval Boom is my first multiplayer game, and I’m discovering these patterns by running into them head-first. If you’ve shipped a netcode-heavy title, none of this will be surprising — but if you’re also figuring it out as you go, here’s the map I wish I’d had.
The bug report was me
I tested the interpolation fix on localhost and over a LAN, at 20–30ms ping. Buttery smooth. Shipped it. Then I played online against a friend at ~60ms ping and my own character felt like it was wading through syrup. Not stuttering — the jank was gone — just late. I’d press right, and a beat later the warrior would start moving. Dodging a bomb felt like steering a boat.
Here’s the uncomfortable part: the interpolation fix made this worse for the local player, and I’d written the reason into the last post without noticing.
Why interpolation can’t fix your own lag
My architecture is server-authoritative. The client sends input, the server simulates, the server broadcasts positions, the client renders them. The interpolation fix smoothed every entity the same way — local player, bots, remote players — by easing the sprite between the server’s reported positions over a small buffer.
For remote players that’s perfect. You’re watching someone else; a little smoothing delay is invisible.
For your own character it’s a tax you can feel:
perceived local input lag ≈ round-trip time + interpolation buffer
At 30ms ping that’s ~60ms RTT + ~34ms buffer ≈ 94ms — masked by the jank I’d just removed, so it felt fine. At 60ms ping the RTT term grows and starts to dominate. Every press of a key now had to fly to the server, wait for a simulation tick, fly back, and then clear the interpolation buffer before your warrior twitched.
No amount of buffer tuning touches this. The buffer isn’t the problem — the round trip is. The only way to make your own character respond instantly is to stop waiting for the server: move locally, the moment you press the key, and reconcile with the server afterward. That’s client-side prediction.
The attempt I’d already abandoned
I’d tried prediction once before, early on, and ripped it out. The naive version — call it fire-and-forget — is simple:
- Run the movement code locally the instant input arrives. The sprite responds now.
- Every frame, compare your predicted position to the latest position the server sent. If they diverge by more than a threshold, snap to the server.
It feels great in a straight line and falls apart on corners. You turn; the server hasn’t seen the turn yet (it’s a round trip behind), so its latest broadcast still shows you going straight; the divergence blows past the threshold; the client yanks you back onto the old path. Turn, snap back, turn again. A rubber band tied to your face.
You can’t tune your way out of it. Raise the threshold to stop the snapping and you can now walk through walls for a few frames before a correction catches up. The flaw is structural: you’re comparing your present against the server’s stale present. Of course they disagree — the server is reporting the past.
Tier two: reconcile against your own past
The fix is to stop comparing against the latest server state and start comparing against what you predicted for the moment the server is actually reporting. This is the classic “client prediction + server reconciliation” loop, and it goes like this:
- Tag every input you send with a sequence number. Keep a buffer of the inputs you haven’t heard back about yet.
- Predict locally, immediately, as before.
- The server tells you, in each snapshot, the sequence number of the last input it has executed, plus the authoritative position at that point.
- On each snapshot: rewind to that authoritative position, throw away the inputs it has acknowledged, and replay the rest of your buffered inputs forward from there.
The corner now survives because you reconcile against your own prediction at the acknowledged input, not against the latest stale broadcast. When the server says “I’ve processed input #100, you were here,” you replay #101, #102, … (your own newer inputs, including the turn) on top of it. You land exactly where you already were. No snap.
The reconciliation only produces a visible correction when the server genuinely disagrees with what you predicted — you predicted through a tile the server says is a wall, or a trap shoved you. Those are rare and small, and they ease out through the same interpolator the remote players use. Straight lines and corners, the common case, cost nothing.
The three things that actually bit me
The textbook version above is clean. Getting it right in this engine took three corrections, all surfaced by testing on the real remote server — because, as with the jank, localhost hides everything (at ~0ms RTT there’s no lead, so nothing to get wrong).
1. The acknowledgement has to match execution, not receipt
My first cut kept the server’s existing input model: it stored the latest held direction per player and applied it every tick (it “coasts” on your last input until a new one arrives). For the ack, I set “last executed input” to the latest input received.
That’s subtly broken, and it produced two symptoms I could measure on my debug overlay: corrections that climbed steadily while running in a straight line (worse the faster I moved), and occasional snap-backs of several tiles.
The cause: the server moves one step per tick, but it was acknowledging every sequence number it had received — which, on any network burst or between state broadcasts, ran ahead of what it had actually moved through. The client trusted the ack, dropped those inputs from its buffer, and snapped back to a position the server hadn’t reached yet. Higher speed meant bigger steps meant bigger snaps.
The fix was to make the server consume inputs as a FIFO queue, one per tick, in order, and acknowledge exactly the sequence number it executed. Now the acknowledgement advances at the same rate the server actually moves, so the client’s buffer drains in lockstep. An empty queue just means the next input is still in flight — the entity waits a tick rather than coasting ahead of its own acknowledgement.
Coasting on the latest input is fine when nothing is predicting against you. The moment a client is reconciling by sequence number, “what did you execute” and “what did you receive” stop being the same question.
2. Replay has to be bit-exact, which means syncing a hidden variable
Entities move in fractional sub-cell steps driven by an accumulator: each tick adds speed × scale to a running total, and a whole step is taken when it crosses 1.0. That accumulator is internal server state — it was never on the wire.
But replay has to reproduce the server’s math exactly, or the client lands a fraction of a sub-cell off after every reconciliation and you get a 12.8px micro-correction 60 times a second — the very jank the last post existed to kill, now flickering. After rewinding to the server’s position, the client needs the server’s accumulator phase too.
So I put the accumulator on the wire. Two floats per entity per tick. It’s a deliberate leak of sim-internal state, and it’s the difference between “perfectly smooth” and “smooth with a permanent shimmer.”
3. Server-authoritative actions are aimed from a predicted position
Movement is predicted; bomb placement is not — bombs stay fully server-authoritative (placement, collision, chain reactions). But once your visual position leads the server by a round trip, a server-authoritative action placed from the server’s lagged position lands behind where you aimed.
Boom has a Crazy-Arcade-style “half-bomb” rule: the bomb drops on the tile you just stepped off, not the one you’re standing on. That rule flips its result across a tile boundary — and prediction lag was reliably putting the server on the wrong side of that boundary, so bombs dropped a tile back from where I meant them. The fix: the bomb message carries the client’s predicted position, and the server uses it only if it’s within one tile of the authoritative position (a cheap anti-cheat bound on how far you can fling a bomb), falling back to the authoritative position otherwise. The half-bomb math and every other check stay server-side.
There was also a non-prediction win hiding here: my bots aim their own bombs assuming the bomb lands where they stand, but the half-bomb offset was quietly placing it a tile away — so bots occasionally walked into their own blasts. Bots now skip the offset entirely.
A note on tick rate
While I was here: the server simulates at 60Hz but, by default, only broadcasts state at 20Hz (Colyseus’s default patch rate — a different knob from the simulation rate, which I’d conflated). Reconciliation is correct at any patch rate now, but a 20Hz broadcast means the acknowledgement the client reconciles against is up to 50ms stale, so the prediction “lead” jumps in coarse chunks. Bumping the patch rate to 60Hz to match the simulation keeps the ack fresh every frame. It costs ~3× the (delta-only, still small) downstream bandwidth.
Before and after

Same walk, same corner, measured at ~60ms ping:
| Interpolation only (last post) | + Client prediction (this post) | |
|---|---|---|
| Remote players | smooth | smooth (unchanged) |
| Local input latency | RTT + buffer (~90–130ms, felt) | ~0 (instant, ping-independent) |
| Corner behaviour | n/a (no prediction) | no snap-back |
| Straight-line corrections | n/a | ~0 |
| Bomb placement | on the tile you aimed | on the tile you aimed (despite lead) |
| Server authority | full | full (movement reconciled, not surrendered) |
The interpolation post made other people look smooth. This one made you feel responsive. They’re complementary: prediction drives the local player’s target, interpolation still smooths the path, and remote players are untouched.
The takeaway
If you’ve got a server-authoritative game and movement feels laggy only for the player who’s actually pressing the keys, no amount of smoothing will save you — smoothing operates between positions you’ve already received, and the lag is in the receiving. You need prediction. And the part that’s easy to underestimate isn’t the predicting, it’s the reconciling:
- Reconcile against your own past prediction at the acknowledged input, never against the latest stale state. That’s the whole difference between tier-1 rubber-banding and tier-2 stability.
- Make the server acknowledge what it executed, not what it received.
- If your replay isn’t bit-exact, the corrections you can’t see individually will add up to a shimmer you can.
- Actions aimed by a predicted position should be placed from it too — validated, but from it.
Localhost will lie to you about every one of these. Test against real latency, and put the numbers on screen — the same debug overlay from last time, now also showing prediction lead and correction count, was the only reason I could tell a real fix from a plausible one.
This work is part of Medieval Boom, a multiplayer Bomberman clone I’ve been building. The previous post, on smoothing movement jank, is here.