Skip to content
nkh.do
Go back

12.8 Pixels of Jank: Smoothing Movement in a Server-Authoritative Game

Characters in Medieval Boom — the multiplayer Bomberman clone I’ve been writing about — moved in visible jumps. At low speeds, the sprite would freeze for what felt like a full second, then snap forward like it had been caught stealing. At higher speeds, the jank was subtler, but still there: a faint stutter, a moment where the movement hitched and your brain went “something’s wrong” even if you couldn’t articulate what.

This affected everything. The local player. Bots. Remote players over the network. Every entity on screen had the same stutter, and the higher your monitor’s refresh rate, the worse it looked.

Here’s the story of how I measured it, what I tried, and what actually worked.

The problem

My game grid uses 64px tiles. Each tile is divided into 5 sub-cells (12.8px each). The server accumulator advances the sub-cell position by one step every N ticks, where N = ceil(1 / (speed × 0.05)). At speed 4, that’s every 5 ticks (83ms).

The client received these sub-cell positions and rendered the sprite directly at the server’s coordinates. No in-between positions existed — the sprite snapped from one sub-cell to the next.

On a 125Hz display, 83ms between steps means ~10 frozen frames followed by a 12.8px teleport. That’s a 3% jank rate (frames where movement dropped to zero after a moving frame).

Key insight: this wasn’t a networking problem. It was a sub-cell resolution problem. The server’s discrete positions were too coarse for smooth rendering. A network engineer would look at the ping, see 30ms, and say “nothing wrong here.” They’d be right. The problem was between the server’s positions, not between the server and the client.

Measuring the invisible

Before fixing anything, I built a debug overlay (toggled with F12) that tracks:

The debug overlay showing FPS, ping, Δ/frm, jank %, and the movement histogram. The gap in the histogram is the teleport.

This gave me real numbers instead of vibes. At speed 4 with no smoothing: 3% jank, Δ alternating between 0.0 and 12.8.

If you’ve ever tried to explain to someone that a game “feels wrong” and gotten the response “it looks fine to me,” you understand why I built this. The overlay made the invisible visible. You can’t fix what you can’t measure, and you can’t measure what you can’t see.

Approaches I tried

Extrapolation (rejected)

Constant-speed extrapolation from the last known position, with drift correction when the server update arrived. Simple in theory: the client predicts where the entity is heading and renders there, then snaps to the truth when it arrives.

The problem: oscillation. The render would overshoot the target, then get yanked back by the drift correction. Snap-back every few steps. I tried letting the interpolation parameter exceed 1.0 by up to 17ms worth of overshoot — this eliminated idle frames (3% → 0.5% jank), but introduced velocity oscillation. Δ alternated between ~0.9px and ~1.8px. The overshoot (~3.5px past target) made the next segment’s distance shorter, then the following segment had to catch up.

Two different flavors of the same problem: extrapolation guesses, and when it guesses wrong, the correction is visible. The fundamental issue is that extrapolation assumes constant velocity, but server updates arrive at variable intervals due to network jitter. The guess and the reality diverge just enough to oscillate.

Interpolation with duration buffer (final)

Linear interpolation between consecutive server sub-cell positions, with a 34ms buffer added to the interpolation duration.

At speed 4: the server advances every 83ms, but the interpolation takes 117ms (83 + 34). The render is always mid-interpolation when the next server update arrives — it never reaches the target before being interrupted by the next segment.

Why 34ms? It covers ~2 frames at 125Hz/144Hz plus typical network jitter (~15ms at 30-50ms ping). Not device-dependent, not ping-dependent — just a fixed buffer that’s wide enough to absorb timing variance without adding perceptible lag.

The final version

// In updateSnapshot() — when server sub-cell position changes:
interpDuration = ticksPerStep * TICK_MS + INTERP_BUFFER_MS
interpFrom = currentRenderPosition
interpTo = newServerPosition
interpElapsed = 0

// In smoothPosition() — every render frame:
t = min(elapsed / duration, 1.0)
renderX = lerp(interpFrom, interpTo, t)

The buffer ensures:

Trade-off: A small remaining Δ oscillation (~1.03 vs ~1.54 in alternating segments) when server update timing doesn’t perfectly align with interpolation duration. This is inherent to position-based interpolation with variable network timing. Eliminating it would require a velocity-based model or server-side changes to sub-cell resolution.

Result

All four approaches measured on the same speed-4 walk:

ApproachJank %Δ rangeOvershoot
No smoothing3.0%0.0 – 12.8
Extrapolation + drift correction0.5%0.9 – 1.8Yes
Extrapolation past t=1.00.5%0.9 – 1.8Yes
Duration buffer interpolation0.0%1.0 – 1.5No

Client-only change. Zero shared or server code modified. All entities smoothed identically — the local player, bots, and remote players all use the same interpolation path.

The takeaway

If you’re building a server-authoritative game with discrete positions: don’t render at the server’s coordinates. Interpolate, add a buffer that covers your worst-case timing variance, and let the render always be “almost there” instead of “exactly here.” Your players won’t notice the 2.5px lag. They will notice the teleporting.


This work is part of Medieval Boom, a multiplayer Bomberman clone I’ve been building. The full devlog series starts with devlog #1.


Share this post on:

Previous Post
Boom clone devlogs #6: Monsters & Traps
Next Post
Boom clone devlogs #5: The Grind