Devlog: How We Cut Multiplayer Bandwidth by Rewriting the Wire Protocol

Published July 19, 2026

CosmicDrift.io runs on a Colyseus WebSocket server deployed on Railway, and Railway bills for egress — every byte the server sends back out to every connected client, every tick. That's not a hypothetical line item. A prior investigation into one of our early bills found that $33.47 of a $33.94 total was pure egress. The compute itself was almost free; moving the state of the game to everyone watching it was the actual cost. Once we saw that breakdown, bandwidth stopped being a "nice to optimize eventually" item and became an ongoing, unbounded target: every byte counts as money, optimize everything no matter how small, and don't assume a small saving isn't worth the effort. This post walks through what that actually looked like across several rounds of work — the cheap wins, the genuinely risky ones we did anyway, a production incident we caused and had to fix live, and a bandwidth number we got wrong in public and had to correct.

Round 1: the wins that cost nothing to be sure about

The single biggest win came first and wasn't clever at all: per-client view filtering. The arena is 7,000×7,000 units, and no single player's camera can see more than a small fraction of that. Before filtering, every broadcast included every bot, every player, and every orb in the world, regardless of whether a given client's screen could possibly render it. We now compute each client's real view distance from their reported screen size and only send entities inside it. Collision stays fully server-authoritative on unfiltered, real positions the whole time — this is purely about what gets serialized onto the wire, not what the server tracks internally.

Everything else in the first round was in the same spirit: changes we could reason about with total confidence, because they don't touch timing or ordering, only shape. Every wire object switched from full field names to one- or two-letter keys (x/y/a/l/g/f...). Four boolean flags — boosting, alive, hatching, boss — got packed into a single integer bitmask instead of four separate fields. Orb type became an index into a fixed, append-only array instead of a string. Bot and orb IDs became bare integers instead of strings like "bot_14", decoded back to strings once at the boundary so none of the existing render code needed to change. Players got a short, room-scoped numeric ID for wire use only — the real Colyseus session ID stayed the source of truth everywhere else. Static fields like name and skin ID got sent once per client, cached, and never repeated. And the minimap/leaderboard summary broadcast dropped from the render loop's 15Hz down to 5Hz, since neither needs per-tick precision. None of this required us to trust anything about network timing — it's just a more efficient way of saying the exact same thing.

Round 2: the same afternoon, five more

We asked ourselves directly, the same day, whether there was anything else — and there was, all of it reusing infrastructure the first round had already built rather than inventing anything new. Direction (angle) got the same delta-encoding treatment we'll describe below for position. Two numeric fields — spawn immunity and kill count — got omitted from the wire entirely whenever they were zero, which covers most of a player's actual lifetime. Stage number got sent only when it changed from what a specific client last saw. Zone names got dropped from the periodic resend, since every client already receives them once on join. And moved orbs got delta-encoded against their last-sent position, the one case in this round that needed genuinely new bookkeeping, since orbs churn constantly as they're eaten and respawned and have no natural per-tick cache the way players and bots do.

Getting riskier: delta-encoding position

Up to this point, everything shipped was safe by construction — worst case, we sent slightly more bytes than optimal, never wrong ones. Position delta-encoding was the first change where that stopped being true. Instead of sending absolute x/y every tick, the server sends a small delta from the last position it actually sent to that specific client, falling back to an absolute value on first sight or on a periodic ~1-second keyframe. The server only advances its per-client baseline when it actually sends a message, so a skipped or backpressured tick can never desync the two sides on its own — the keyframe on top of that is deliberate defense-in-depth against a bug in that tracking, not the primary safety mechanism. It's worth saying plainly: this class of change did later cause a real incident, which we cover below. It just wasn't the first one.

The rewrite: leaving msgpack behind

Colyseus's default state sync uses msgpack under the hood. At the key-shortening stage, that still costs something per field, every entity, every broadcast: a fixmap header plus a length-prefixed key string, even for a one-character key like x. We measured this directly with the real msgpackr encoder rather than assuming it from documentation, and it turned out Colyseus's own framing on top of that was cheap — about 2 bytes per message. The real cost was the general-purpose, self-describing nature of msgpack itself.

So for the two highest-frequency message types — the ones carrying nearby entities and full state — we wrote a hand-packed, fixed-layout binary format with no keys and no per-field type tags at all: fixed-width i8/i16/u8/u16/u32 fields in a known order, with a presence bitmask per entity for the fields that are sometimes omitted. That gets sent through Colyseus's native client.sendBytes() path, which we confirmed by reading the actual colyseus.js decode source bypasses msgpack entirely on both ends — not something we assumed from docs. Lower-frequency messages — join/init, kill and death events, boss events — stayed on msgpack deliberately. They're small and infrequent, and rewriting them wasn't worth the added risk for what they'd save.

"The real cost was never Colyseus's framing — it was the price of a self-describing format re-describing itself on every single field, every tick, forever."

The number we got wrong

Our first pass at measuring the rewrite's savings compared msgpack against the new binary format using synthetic test data with unrounded floating-point coordinates, like x: -1234.56. That produced a 4.7x reduction, and we said so. It was wrong — not because the binary format didn't help, but because the benchmark didn't match reality. The actual pre-existing wire code already rounded coordinates to integers before sending them, a change from an earlier round of this same optimization work, and msgpack already encodes small integers far more cheaply than floats. We were benchmarking against a strawman.

Redone honestly against the real, already-integer shape, the number came out to roughly 1.7x — a real, meaningful reduction, just nowhere near what we'd first claimed. For a realistic tick with 50 bots and 2 players, nearby-entity payloads went from about 1,462 bytes to 840, and state summaries from about 1,209 to 723. Interestingly, the number the user actually observed in real play — a roughly 2.5x drop in session egress, from about 2MB down to 800KB — came in higher than our corrected raw-byte model predicted. The likely reason is that WebSocket compression (perMessageDeflate, already enabled on every message in both directions since before this rewrite) was doing real work compressing away the old format's repeated key names — meaning some of the apparent old-vs-new gap was really "how much was compression already covering for the old format's redundancy," a real factor we hadn't isolated, not noise. One practical consequence worth stating plainly: compression was already on everywhere, so there was no additional compression layer left to stack on top of the binary rewrite — compressing already-compressed bytes doesn't help.

We're including this section not because the correction was dramatic, but because the instinct on the first miss was to want to defend the original number. The actual right move — redo the measurement honestly against the real shape of the data rather than defend the flattering one — is the standard we're holding ourselves to going forward, and it's worth saying that out loud rather than quietly editing an old claim.

When it broke in production

The binary rewrite shipped the same night, and it broke something real a few hours later. The symptom reports were specific: about half a second of lag, orbs moving toward the player and then disappearing before reaching them, orbs that wouldn't get eaten, and — worse — orbs disappearing more than they should have. Nothing in that description obviously points at a wire-format bug on its own, but chasing it down to a root cause (not guessing from the symptom) found one fast: orb and bot IDs had been encoded on the wire as u16, a 16-bit unsigned integer capped at 65,535. Both IDs come from plain incrementing counters that never reset or recycle. By the time this was caught, live orb IDs were already past 97,000 — 92% of every currently-tracked orb was already above the u16 maximum.

A u16 field silently wraps on overflow; there's no error, no exception, just the wrong number. That meant unrelated orbs on opposite sides of the map could end up encoded to the identical wrapped ID, so a removal message meant for one orb could silently miss it, or wrongly remove a completely different one. We confirmed the resulting leak directly: the client's orb tracking map, which should hold roughly 3,200 real orbs at steady state, had grown to over 10,400 entries and was still climbing.

The fix that shipped that same night was to widen the ID field to u32in both the server encoder and the client decoder — effectively unbounded for any realistic uptime. After the fix, the same kind of session's orb-tracking count stabilized at roughly 4,500–4,700 entries, fluctuating normally with player movement instead of climbing without bound. We also added regression tests that assert exact round-trip correctness up to the u32 maximum, plus a test proving that two specific IDs which collided under the old u16 field — 65,537 and 131,073, both of which wrap to 1 — now decode to two distinct values. We were explicit at the time that widening the field was the safe fix to ship that night, not the real long-term one: the honest fix is bounded, recycled IDs, the same pattern our short session IDs already used, and that recycling work was deliberately left as an open follow-up rather than rushed in alongside an emergency patch.

Finishing the job

The same production incident also forced an emergency decision to hardcode position delta-encoding off entirely, since an earlier client/server position-cache divergence that same night couldn't be root-caused fast enough to trust it live. A later session tracked that root cause down properly: the client had been independently re-deriving whether a given position update was "absolute" from its own local copy of the keyframe-period constant, instead of trusting the presence bit the server had already written onto the wire to describe exactly that. Two independently-computed answers to the same question can disagree the moment one side's cache loses an entry the other side still has — which is exactly the kind of thing that can happen if a render layer gets torn down and rebuilt without a matching server-side reset. The fix was to make the client read the wire's own answer instead of computing a second, possibly-different one, and delta-encoding went back to genuinely active rather than emergency-disabled.

That same pass finally implemented the real fix to the ID overflow bug: bounded free-lists for bot and orb IDs, recycling an ID only once its owner is permanently gone, mirroring the pattern already used for session IDs. With IDs now actually bounded, bot IDs could be safely narrowed back down from u32 to u16 — real bot counts top out around 150, comfortably inside a 65,535 ceiling. Orb IDs deliberately stayed at u32; orb population is far less tightly bounded than bot population, and this exact silent-wraparound failure had already cost real production time once, so the extra headroom stayed intentional rather than getting trimmed back for symmetry.

The real numbers

Pulling together what was actually measured, not projected, across this whole effort:

MetricBeforeAfter
Nearby-entity payload (50 bots + 2 players)~1,462 bytes~840 bytes
State summary payload (same tick)~1,209 bytes~723 bytes
Live state_payload_size at 50 bots~2,986–4,050 bytes~785–1,811 bytes
Observed real-session egress spike~2MB~800KB
Client-side orb tracking map (u16 bug, mid-incident)~3,200 expected10,423 and climbing
Client-side orb tracking map (post-u32-fix)—~4,500–4,700, stable

The honest raw-byte model says roughly 1.7x, the real observed session egress says closer to 2.5x, and the gap between those two numbers is itself a real finding — compression was already quietly doing more work on the old format than we'd given it credit for. Every change described here shipped behind full server test suites, live multi-tab sessions, and — for the riskiest ones — direct production log monitoring for anomaly counts, not just a type-check. That includes the one we got wrong at first: the fix for a bad number is measuring it again correctly, not explaining why the first number still sort of applies.