MediumMaking 10 Packets a Second Feel Like 120 Frames | by Hemanth R | Nov, 2026 | Medium
Sitemap
Hemanth R

Hemanth R

Consultant Developer at Thoughtworks

Making 10 Packets a Second Feel Like 120 Frames

13 min read2 days ago

--

Building Solo World, Part 2 of 3: multiplayer networking, interpolation and the race conditions you only meet in production.

Press enter or click to view image in full size

In Part 1 I covered Solo World’s architecture: a React Three Fiber client on Vercel, one Node process running Fastify and Colyseus on Render, and Postgres as the source of truth.

Once real players arrived, the same two reports kept coming in. The deployed game got badly laggy once more than two players were in the village. And dragging the camera felt delayed, as if it were wading through something.

The tempting reaction is to upgrade the server instance and move on. I didn’t, because I didn’t yet know what was slow. This post is about the multiplayer layer: what goes over the wire, who gets it, how the receiving side turns ten updates a second into smooth motion, and the race conditions that only appear once real people reload pages and open second tabs.

The spoiler, which sets up Part 3: the server was almost never the bottleneck. But the network layer still had plenty to fix.

One room, explicit messages

The whole world is one Colyseus room called village, capped at 40 clients. Every interaction is a typed message, parsed with Zod on arrival: player:move, chat:message, vehicle:move, player:emote and, at last count, nearly 290 message types across 60 handler files. Bad input is dropped without crashing the room or disconnecting the sender.

Colyseus has a headline feature: automatic state synchronization. You decorate fields on a schema class with @type, mutate them on the server, and Colyseus sends binary patches to every client on a patch tick (50 ms by default). It’s elegant, and for position data I turned it off.

Here’s why. My client never reads room.state for players. Every position already arrives in an explicit player:move message, because I wanted control over who receives it and how often. So the decorated x, y, z and rotationY fields were being diffed and broadcast a second time, to everyone, every patch tick, through a channel nothing listened to.

Removing @type from the hot fields (position, rotation, vehicle seat, connection flags) deleted that duplicate stream without changing a single message shape. The players map itself still syncs, so clients still learn who joined and left.

The one place I kept schema sync is weather. It changes rarely (an admin starts the rain), and schema sync gives a player who joins mid-shower the current weather for free as part of the initial state. That’s exactly what the feature is good at.

Rule of thumb: schema sync for slow, shared state that late joiners need. Explicit messages for anything that moves.

Make every byte count

With the duplicate channel gone, I measured what was left, per message type, with a passive observer counting every inbound byte.

Press enter or click to view image in full size

The compact move

The steady-state player:move used to be a friendly JSON-ish object: session id, name, colour, position and rotation as floating-point numbers. Colyseus encodes messages with msgpack, where a small integer costs 1 to 3 bytes and a double costs 9. So the move became a positional array of integers:

// position in whole centimetres, rotation in milliradians,
// optional flags only while set, plus the sender's sample time t
{ s: sessionId, p: [x, y, z], r: rotationY, t, v?, f?, b? }

Name and colour moved out entirely: they’re sent once, in player:joined, and never again. Measured with Colyseus’s own getMessageBytes, the message went from 171 bytes to 43, a 75% cut on the most frequent message in the game.

Silence when nothing moves

A player standing still used to keep broadcasting their unchanged position ten times a second. Now an unchanged pose isn’t forwarded at all. The one subtlety: the first unchanged report after movement is forced to every viewer, bypassing any throttling, so nobody is left holding a stale final position. The client still sends a 2 Hz heartbeat to the server (the coin sweep from Part 1 needs it), but it costs zero fan-out.

The stream nobody was watching

The biggest single cost wasn’t players at all. The birdwatching feature streamed every bird within 70 metres, around 80 of them at the village, five times a second, as ten-key objects of doubles. That was about 95% of every client’s downstream traffic, roughly 12 KB per message.

Two fixes. Guests don’t get the stream at all, because their client never registers a listener and never draws the birds. Signed-in players get each bird as an integer tuple: [id, species, x, y, z, yaw, pitch, state, flap, scale]. The client accepts both the old and new shapes, so the server and client could deploy in either order.

Result for a guest in a two-player room: 62.5 KB/s down to 2.9 KB/s.

The lesson I keep relearning: you can’t guess which message is expensive. Count bytes per message type, then fix the top line.

Interest management: not everyone needs every packet

The original broadcast was the one in every Colyseus tutorial: room.broadcast(‘player:move’, payload, { except: client }). Every move goes to every player, whether they’re standing next to you or on an island across the ocean, deep in the fog.

The world is big and the fog is thick: by around 380 units, everything has faded out. So a viewer far away doesn’t need ten updates a second about you. They need just enough that if you walk into view, you’re roughly where you should be.

Press enter or click to view image in full size

The implementation is a loop and a counter. There’s no spatial index: with at most 40 players in a room, a linear scan with squared distances is cheap.

export function broadcastToInterestedClients(
room, excludeSessionId, moverX, moverZ, counter, type, payload,
) {
counter.farBroadcastCounter += 1;
const tick = counter.farBroadcastCounter;
const dueForFar = tick % INTEREST_CONFIG.farUpdateStride === 0; // every 3rd
const dueForVeryFar = tick % INTEREST_CONFIG.veryFarUpdateStride === 0; // every 10th
for (const client of room.clients) {
if (client.sessionId === excludeSessionId) continue;
const viewer = room.state.players.get(client.sessionId);
if (!viewer) { client.send(type, payload); continue; } // unknown: send, never drop
const dx = viewer.x - moverX;
const dz = viewer.z - moverZ;
const d2 = dx * dx + dz * dz; // squared: no sqrt
const due =
d2 <= NEARBY_RADIUS_SQUARED ||
(d2 <= VERY_FAR_RADIUS_SQUARED ? dueForFar : dueForVeryFar);
if (due) client.send(type, payload);
}
}

Three decisions in there are worth calling out.

  • One counter per mover, not per mover-viewer pair. Every far viewer of a given player shares the same “every third update” cadence. That’s indistinguishable at this scale, needs no bookkeeping, and has nothing to clean up when someone disconnects.
  • The stride is chosen against the client’s teleport threshold. Even a sprinting player’s worst-case movement across three skipped updates stays under the distance at which the receiver would snap rather than slide. Far players move smoothly, just less precisely.
  • One-off events use a different rule. A position stream can be sampled, because missing two updates costs a far viewer nothing visible. A fishing catch, an emote or a water-gun spray is a single moment. Sampling one in three would make it stutter, which is worse than not showing it. Those go to nearby viewers, all of them, every time, and to nobody else.

Drawing other players slightly in the past

Each client sends its position ten times a second. The screen refreshes 60, 120 or 144 times a second. If you draw a remote player exactly where their last packet said, they move in visible 100 ms hops.

The standard fix is entity interpolation: keep a short buffer of snapshots and draw each remote player a little in the past, blending between the two snapshots that bracket that moment. Between packets, they keep moving at the velocity they actually had. My first version used a fixed 120 ms delay and stamped each snapshot with the time it arrived. It was smooth on localhost and visibly jittery on weaker machines.

Press enter or click to view image in full size

The problem was the timestamp. Arrival time carries three kinds of noise, and every one of them becomes a speed change on screen:

  1. Network jitter. Two poses taken 100 ms apart can land 30 ms apart. Played back by arrival, that gap runs at 3× speed, and the next one crawls.
  2. The sender’s frame rounding. A “send every 100 ms” timer actually fires on the next frame boundary.
  3. The receiver’s own frame time. A message is only noticed between frames. On a machine running at 30 fps, that adds up to 33 ms of noise.

The third one is nasty: a slow machine made everyone else look like they stuttered.

The fix has two parts.

Get Hemanth R’s stories in your inbox

Join Medium for free to get updates from this writer.

Stamp with the sender’s clock. Each player:move now carries t, the sender’s own sample time. The receiver can’t trust that clock directly, since the two machines aren’t synchronised. But it doesn’t need to. It keeps a running lower envelope of arrival − sent: the least-delayed packets define the offset between the two clocks. No clock-sync protocol required.

pushSent(sentAt: number, arrivedAt: number, x: number, y: number, z: number, rotY: number) {
const raw = arrivedAt - sentAt;
if (Number.isNaN(this.senderOffsetMs)) {
this.senderOffsetMs = raw;
} else {
// allow a slow upward creep, so a lasting rise in latency is followed
const drift = Math.max(0, arrivedAt - this.lastSentArrivalAt) * OFFSET_DRIFT_PER_MS;
this.senderOffsetMs = Math.min(this.senderOffsetMs + drift, raw);
}
this.lastSentArrivalAt = arrivedAt;
const stamp = sentAt + this.senderOffsetMs; // sender time, on my clock
this.trackLateness(arrivedAt - stamp);
this.push(stamp, x, y, z, rotY);
}

Make the delay adaptive. Each interpolator measures the stream’s rhythm and lateness, and draws just far enough behind for the next snapshot to have almost always arrived: one send interval, plus how late packets tend to be, plus 2.5× the jitter of each, clamped between 120 and 300 ms. It grows within about half a second when jitter appears and relaxes over about three seconds when it clears.

In a simulation with 0 to 80 ms of network jitter, the share of frames where a remote player moved more than 30% off their true speed went from about 28% to zero, for about 45 ms of extra delay. With no jitter, the delay doesn’t change at all.

Two smaller details matter just as much. The interpolator is allocation-free after construction (a fixed ring of four preallocated snapshots), because it runs per remote player, per frame, inside the render loop. And a jump larger than a threshold is treated as a teleport: the buffer resets and the player snaps, instead of gliding across the map.

When the server says no

Your own avatar is different. It can’t wait 120 ms for anything, so the client moves it immediately (client-side prediction) and reports the result. The server validates each move: inside the world bounds, in the right medium (you can’t walk on the ocean), and so on.

In the first version, a rejected move was simply dropped. The sender got no feedback, so a client could drift away from the server’s truth and never find out.

The fix is small and boring, which is how you want reconciliation to be:

  1. Each outgoing move carries a monotonic seq.
  2. The server remembers the last accepted position, rotation and seq for each player, seeded with their real spawn point.
  3. When it rejects a move, it sends a player:correction with that baseline to the sender only. Nobody else needs to know.
  4. The client doesn’t snap. It eases toward the correction exponentially (1 − e^(−rate·Δt), the same smoothing the chase camera uses) and blends it into the position after collision has been resolved. So a correction can never push you through a wall.

The correction travels through a plain module-scope variable, not React or Zustand state. The network handler writes it; the player’s frame loop takes it exactly once. That pattern, keeping fast-changing values out of React entirely, turns out to be the single most important idea in Part 3.

In normal play this almost never fires. That’s the point: it exists so that the rare disagreement heals itself instead of persisting forever.

Races in joins, reloads and token refreshes

Some bugs only appear when real people use real browsers. Three of them taught me the most.

The fast refresh that lost your position

Positions are saved to Postgres on a timer or after meaningful movement, and always on disconnect. Never every frame. On a quick browser refresh, though, the new page’s join can reach the server before the old WebSocket’s close does. Colyseus’s own ping-timeout detection can lag behind a fast reload too. So for a moment the server sees two live sessions for the same userId.

The join handler evicts the stale session so only one is ever active. The bug was how: the stale session was discarded without saving where it was. The new session then loaded an older saved position, and players could reappear at an older saved spot instead of where they actually were.

The fix: eviction calls the same persistLatestPosition that disconnect uses, before discarding the old session. In the guest path this wasn’t just a race. The eviction removed the player from room state first, so when Colyseus later fired the old session’s onLeave, the leave handler found no player and skipped its own save entirely.

Unexpected drops (a brief network blip, or a reload that resumes the same session) get a 30-second grace window via Colyseus’s allowReconnection, so the same session can resume without a fresh join.

Two refreshes, one cookie

Refresh tokens rotate: every use issues a new one and revokes the old one. Now picture two near-simultaneous refresh calls presenting the same still-valid token. React StrictMode double-running an effect in development does it. So do two tabs sharing a cookie, or a retried request. The loser sees its token already revoked and treats it as “session replaced on another device”. A perfectly valid user gets logged out on page load.

The server-side fix: when a session rotates, it records its successor (replaced_by_token_hash). A recently rotated token presented within a 10-second grace window resolves forward to that successor instead of failing. Crucially, that link is only ever set by an ordinary rotation, never by a logout, a ban, a password reset or “logged in elsewhere”, so none of those real revocations can be replayed.

The subtle part is what the losing call returns: a fresh access token, and no Set-Cookie at all. Rotating a second time would leave the browser holding whichever response it processed last, possibly a token the other call had just revoked. That only moves the logout to the next page load. Not rotating means exactly one refresh token is ever live.

The client had the same problem in miniature: app-start session restore, the proactive refresh scheduler and the game-join token request could each rotate the cookie on the same page load. All three now share one single-flight promise.

The name that was always taken

The smallest of the three, and the most embarrassing. Guests who left the name box blank were all called “Guest”. The second blank-named guest to arrive was turned away because “Guest” was taken. Generated names like GoldenFox fixed it, with a uniqueness check against everyone currently connected, a few retries, then a numeric suffix. The join is never blocked on a name.

Measure the server before you scale it

Back to those lag reports. Before changing anything, I put the server under synthetic load: 1, 3, 5 and 10 scripted clients, each walking and reporting at 10 Hz exactly like a real one.

  • 1 client: 10 moves/s handled, 0.08 ms per player:move, 0 ms event-loop lag, 1.2 ms ping (p95)
  • 3 clients: 30 moves/s, 0.10 ms per move, 1 ms lag, 1.0 ms ping
  • 10 clients: 100 moves/s, 0.17 ms per move, 1 ms lag, 0.7 ms ping

The per-move cost does grow with the room, because the broadcast loop is linear in the number of other players. But at ten players that’s about 17 ms of CPU per second. A later pass with 30 scripted players still showed event-loop lag at or under 2 ms. The realtime loop was never the bottleneck, and a bigger Render instance would have fixed nothing.

What I did add is the instrumentation, so the question can be answered against production instead of guessed at:

  • Event-loop lag: one unref’d 500 ms timer that measures how late it fires. That lateness is exactly how long an incoming message waits behind synchronous work.
  • Handler cost: two performance.now() calls around the move handler, smoothed, plus an all-time peak and the process’s memory.
  • Delivered on demand: none of it is broadcast. When a client opens the game with ?diag=1, its diagnostic ping gets the server’s numbers back on the pong. Nobody else pays for it.

If the server was fine, why was the game slow? Because every one of those ten updates per second, per player, was being routed through React on the client, on the same thread that draws the camera. That’s where Part 3 begins.

What I’d take to any multiplayer project

  1. Use your framework’s state sync for slow, shared state. Send anything that moves as explicit messages you control.
  2. Count bytes per message type before optimising. Mine was dominated by birds, not players.
  3. Integers, not floats, on the wire. Centimetres and milliradians are precise enough, and msgpack rewards you for them.
  4. Send less to people who can’t see you. A distance check and a counter go a long way.
  5. Timestamp at the source. Interpolating on arrival time imports every machine’s jitter into everyone’s screen.
  6. Give rejected moves a way home. A correction message to the sender is cheap insurance against silent drift.
  7. Assume two of everything: two sessions per user, two refreshes per cookie, two clicks per button. Then make the second one harmless.

Next: Part 3, performance

The world started at roughly 60,000 scene nodes and 5,200 draw calls, all built at startup wherever you stood, and a minimap that re-rendered once per network packet. In Part 3: getting the network out of React, streaming the world by distance, instancing done properly, the name-tag bug that ate 48% of the main thread, and shipping 88% less JavaScript to the landing page.

Try the multiplayer yourself at soloworld.in. Bring a friend; you’ll see each other in seconds.

--

--

Hemanth R
Hemanth R

Consultant Developer at Thoughtworks