How many bytes does it take to sync a multiplayer game?
The naive version — full state as JSON, every tick — costs 1,101,710 bytes per second per client at 128 entities and 60 Hz, and 141 Mbit/s of server upload for 16 players. Binary packing, 16-bit quantisation, delta encoding and interest management together brought that to 70 payload bytes per tick,
1,101,710 bytes per second, per client. That is what the obvious version costs: 128 entities, 60 ticks per second, the whole world state serialised to JSON and sent to everybody. For 16 players that is 141 Mbit/s of server upload, which is more than most VPS instances will give you and far more than a phone on 4G will take. The same world, quantised to 16-bit fixed point, delta-encoded against the last acknowledged tick, bit-packed and culled to a 128-unit view radius, came to 70 payload bytes per tick — a 263x reduction — at a worst-case position error of 0.0039 world units.
Hardware: Apple M3, 8 cores, 16 GB, macOS 26.4.1 (build 25E253). Node v23.5.0, built-ins only — no packages, no sockets. Every figure here is a payload byte count. A UDP datagram over IPv4 adds 28 bytes of header per packet regardless, and at 60 Hz that stops being a rounding error and becomes most of your traffic; there is a table for it below.
The short answer
- Naive JSON snapshots cost 1,101,710 bytes per second per client at 128 entities and 60 Hz — 18,362 bytes per tick. At 8 entities it is still 69,475 B/s, and the tick rate multiplies all of it.
- Binary packing is the biggest win and the cheapest to implement. JSON 18,362 B/tick → binary float64 5,638 B → 16-bit quantised 1,670 B: 11x before a single delta or spatial query, and the encoder got 64x faster doing it (69.51 µs → 1.08 µs per tick).
- 16-bit fixed point over a 512-unit world costs a maximum position error of 0.0039 units, mean 0.0019, over 81,920 round trips; rotation in one byte is accurate to 0.706 degrees. Neither error accumulates.
- gzip made the packed packet bigger. A 110-byte quantised packet gzipped to 133 bytes (1.21x), a 422-byte one to 445 (1.05x), breaking even only at 1,670 bytes.
- The ack round trip costs more than packet loss does. Delta against the previous tick was 89 B; against a baseline acknowledged 100 ms ago, 367 B — 4.13x — at zero loss. Going from 0% to 20% loss added a further 3.5%.
What is being measured
A 512x512 unit world of N entities, each with a position (x, y), a velocity (vx, vy) capped at 20 units/second, a rotation, an 8-bit health value and one of eight animation states, seeded from mulberry32(12345). Each tick a random subset integrates by dt = 1/60, reflects off the walls and recomputes rotation from velocity, while 2% of all entities take 1-5 damage and switch animation state — which is what stops health and state from being free in a delta encoder.
The JSON form is {tick, entities: [{id, x, y, vx, vy, rot, hp, st}, ...]} at full float64 precision — what JSON.stringify on your game objects produces — at 143 bytes per entity. The binary forms carry a 6-byte header then 44 bytes of float64 per entity, or 13 quantised, or fewer. Bandwidth only: interpolation, lag compensation and rollback are not measured here.
What does the naive version cost?
Full state, JSON, every tick, averaged over 120 ticks:
| Entities | Tick rate | Bytes/tick | Bytes/s per client | Mbit/s, 16 clients |
|---|---|---|---|---|
| 8 | 10 Hz | 1,158 | 11,579 | 1.48 |
| 8 | 30 Hz | 1,158 | 34,737 | 4.45 |
| 8 | 60 Hz | 1,158 | 69,475 | 8.89 |
| 32 | 10 Hz | 4,591 | 45,905 | 5.88 |
| 32 | 30 Hz | 4,591 | 137,716 | 17.63 |
| 32 | 60 Hz | 4,591 | 275,432 | 35.26 |
| 128 | 10 Hz | 18,362 | 183,618 | 23.50 |
| 128 | 30 Hz | 18,362 | 550,855 | 70.51 |
| 128 | 60 Hz | 18,362 | 1,101,710 | 141.02 |
Eight entities at 10 Hz is 11.6 KB/s and nobody would notice, which is exactly why this survives to production: a two-player prototype on localhost has no symptom. Cost is linear in entities and linear in tick rate, so going from a 2-player 8-entity demo to a 16-player 128-entity match multiplies the server's upload by 127.
What is each technique actually worth?
Same world, 128 entities, 60 Hz, everything moving, 240 ticks. Each row applies that technique to the row above, except the compression rows, which apply to the format named.
| Technique | Bytes/tick | Bytes/s per client | Mbit/s, 16 clients |
|---|---|---|---|
| JSON (naive) | 18,359 | 1,101,527 | 141.00 |
| gzip(JSON) | 7,400 | 443,991 | 56.83 |
| Binary, float64 fields | 5,638 | 338,280 | 43.30 |
| gzip(binary float64) | 5,447 | 326,803 | 41.83 |
| + quantised to 13 B/entity | 1,670 | 100,200 | 12.83 |
| gzip(quantised) | 1,656 | 99,385 | 12.72 |
| brotli(quantised) | 1,604 | 96,258 | 12.32 |
| + bit-packed fields | 1,318 | 79,080 | 10.12 |
| + delta vs previous tick | 638 | 38,280 | 4.90 |
Two things stand out. Plain binary packing beat gzipped JSON by 1.3x, and quantised binary beat it by 4.4x — while costing 1.08 µs of CPU per packet against gzip's 25.06. If your reflex on seeing a large JSON packet is to compress it, the packing you skipped was worth more.
The other is that + delta vs previous tick is 638 bytes even though every entity moved: delta encoding is not only about stationary entities. Of the seven fields only x and y change on a normal tick, so the measured cost was 38.5 bits per entity against 82 for a full one — a 7-bit field mask spending 7 bits to skip up to 50.
How much does quantisation really save, and what does it cost?
Position as a uint16 scaled across the world, velocity as an int16 scaled to the speed cap, rotation as one byte across 2π: 44 bytes per entity down to 13, 3.4x, for this error over 4,096 entities across 20 ticks:
| Field | Encoding | Mean error | Max error |
|---|---|---|---|
| Position | 16-bit fixed, 512 u range | 1.947e-3 u | 3.906e-3 u |
| Velocity | 16-bit fixed, ±20 u/s | 1.553e-4 u/s | 3.052e-4 u/s |
| Rotation | 8-bit, 2π range | 6.107e-3 rad | 1.232e-2 rad (0.706°) |
The max errors are exactly half a quantisation step — what round-to-nearest guarantees, and a useful sign the encoder is not wrong. Picking the width is arithmetic, not taste:
| Position bits | Step | Max error | Bytes for x+y |
|---|---|---|---|
| 8 | 2.007843 u | 1.003922 u | 2 |
| 12 | 0.125031 u | 0.062515 u | 3 |
| 16 | 0.007813 u | 0.003906 u | 4 |
| 24 | 0.000031 u | 0.000015 u | 6 |
Sixteen bits is the sweet spot here because 4 mm of error across a 512-metre world is invisible, while 12 bits — 6 cm — starts to show on a slow-moving object. Make it a 10 km open world and 16 bits gives you 15 cm steps and visible stair-stepping. Quantise per zone, not per world.
What quantisation does not do is accumulate: each snapshot is absolute, so the error stays bounded at 0.0039 units forever. That is the opposite of letting clients integrate their own physics — see what a single wrong bit does to a simulation, where a one-ULP difference became a full world unit of divergence in 413 steps.
How much does delta encoding save?
Two formats, both against the previous tick. The byte-aligned one sends id (u16) + field mask (u8) + changed fields; the bit-packed one replaces the ids with an N-bit presence bitmap and packs fields at natural widths.
| Entities moving | Full quantised | Delta, byte-aligned | Delta, bit-packed | Changed entities |
|---|---|---|---|---|
| 10% | 1,670 B | 110 B | 91 B | 15.7 |
| 50% | 1,670 B | 458 B | 334 B | 65.5 |
| 100% | 1,670 B | 896 B | 638 B | 128.0 |
Bit-packing the mask is worth 17% at low change rates and 29% at high ones. It is the least interesting technique here and the most fiddly to debug, so it is the one to reach for last.
Why does the delta get bigger when latency rises?
Because you cannot delta against the previous tick — only against one the client has acknowledged, and the ack is a round trip old. This is the measurement that did not go the way I expected. 10% of entities moving, zero loss, 1,800 ticks:
| Round trip | Ack lag | Bytes/tick | vs RTT 0 |
|---|---|---|---|
| 0 ms | 0 ticks | 89 | 1.00x |
| 17 ms | 1 tick | 148 | 1.67x |
| 50 ms | 3 ticks | 250 | 2.82x |
| 100 ms | 6 ticks | 367 | 4.13x |
| 200 ms | 12 ticks | 515 | 5.79x |
| 300 ms | 18 ticks | 595 | 6.70x |
A 100 ms ping costs 4.13x the bandwidth of a 0 ms ping, before any packet is lost. Six ticks of movement have piled up in the baseline, so six ticks' worth of entities appear in the delta. Every benchmark of delta encoding run on localhost measures the 89-byte row and misleads by a factor of four — and delta encoding and server placement turn out to be the same optimisation.
What does packet loss do to a delta stream?
Much less than the round trip does. Sixty seconds at 60 Hz, 100 ms RTT, snapshot and ack each subject to independent loss:
| Loss | 10% moving | 100% moving | Max unacked ticks | p99 unacked | Ring buffer |
|---|---|---|---|---|---|
| 0% | 363 B | 656 B | 6 | 6 | 0.011 MB |
| 1% | 364 B | 656 B | 7 | 7 | 0.013 MB |
| 5% | 366 B | 656 B | 10 | 8 | 0.017 MB |
| 10% | 370 B | 657 B | 10 | 8 | 0.017 MB |
| 20% | 380 B | 657 B | 15 | 10 | 0.025 MB |
At 20% loss — a broken connection, not a bad one — the delta grew 4.7% and the server held 16 ticks of history: 26 KB for a 128-entity world, 0.4 MB even with a separate ring per client for 16 players. The history cost people cite as the reason to avoid delta encoding is not a real cost at these scales. Watch the tail instead: p99 unacked depth was 10 ticks but the max was 15, so a ring sized for the average drops a client occasionally and forces a full resend.
How much does interest management save?
Only send entities near the player. 128 entities uniformly distributed, player position sampled at random each tick, quantised full state for what is in range:
| View radius | Avg entities visible | Bytes/tick | Bytes/s at 60 Hz | vs full |
|---|---|---|---|---|
| 32 u | 1.5 | 26 | 1,530 | 1.5% |
| 64 u | 5.7 | 81 | 4,833 | 4.8% |
| 128 u | 20.7 | 275 | 16,514 | 16.5% |
| 256 u | 64.1 | 839 | 50,323 | 50.2% |
| whole world | 128.0 | 1,670 | 100,200 | 100% |
The saving goes with radius squared, making this the only technique here with an unbounded win — every other one has a floor. It is also the only one that changes what the player can see, so it costs design work rather than encoder work. In a Snake-style arena where everyone must see the whole board it is worth nothing.
Should you send state or inputs?
Inputs are 24.6x smaller, and there is a catch. A lockstep input frame is 4 bytes (sequence, buttons, aim); the server relays one per player per tick.
| Model | Server → client, per tick | Bytes/s | Wire, +28 B header |
|---|---|---|---|
| Lockstep inputs, 16 players | 68 B | 4,080 | 5,760 B/s |
| Quantised snapshot, 128 entities | 1,670 B | 100,200 | 101,880 B/s |
Client → server is 16 bytes per tick — a 4-byte header and three redundant input frames, so a lost packet is recovered by the next one — 960 B/s, and it does not grow with the world, because the world is simulated on every machine.
Which is the whole problem. Lockstep requires every client's simulation to produce bit-identical results from the same inputs, and floating-point maths does not give you that for free: Math.sin returned different bits on 4.40% of inputs between two runtimes on the same machine, and one wrong bit became a visible desync in seven seconds. Snapshots cost 25x the bandwidth and are self-correcting — a client that gets one thing wrong is right again on the next packet. Choose lockstep for RTS-scale entity counts where snapshots are impossible, and snapshots for everything else.
Does the packing cost more than it saves?
No — JSON is the expensive one. Per tick at 128 entities, median of 9 runs of 200 repetitions:
| Operation | µs per tick | % of a 16.67 ms frame |
|---|---|---|
JSON.stringify snapshot |
69.51 | 0.42% |
JSON.parse snapshot |
66.90 | 0.40% |
| Pack quantised binary | 1.08 | 0.006% |
| Unpack quantised binary | 2.02 | 0.012% |
| Snapshot + bit-packed delta | 2.95 | 0.018% |
| gzip the quantised packet | 25.06 | 0.15% |
A server serialising for 16 clients spends 1.11 ms per tick in JSON.stringify — 6.7% of a 60 Hz frame — against 17 µs to pack binary. On the client, JSON.parse at 66.90 µs is 33x the cost of unpacking a binary buffer, out of a budget that also has to render.
Does gzip help?
| Packet | Raw | gzip | Ratio | brotli | Ratio |
|---|---|---|---|---|---|
| JSON, 8 entities | 1,153 B | 557 B | 0.48x | 498 B | 0.43x |
| JSON, 32 entities | 4,577 B | 1,912 B | 0.42x | 1,662 B | 0.36x |
| JSON, 128 entities | 18,331 B | 7,413 B | 0.40x | 6,207 B | 0.34x |
| Quantised, 8 entities | 110 B | 133 B | 1.21x | 114 B | 1.04x |
| Quantised, 32 entities | 422 B | 445 B | 1.05x | 426 B | 1.01x |
| Quantised, 128 entities | 1,670 B | 1,658 B | 0.99x | 1,610 B | 0.96x |
| Bit-packed delta, 50% moving | 841 B | 864 B | 1.03x | 845 B | 1.00x |
gzip halves JSON and inflates everything else. Its 18-23 bytes of header and trailer explain the loss at 110 bytes; the deeper reason is that a packed packet is close to incompressible, because quantised fields are near uniform over their range. This is the mirror image of the HTTP API case, where gzip and brotli are both clearly worth it — JSON responses are large, textual and repetitive, and packed game packets are none of those.
What does the whole stack cost?
Quantised, delta-encoded against the last acknowledged tick, bit-packed and culled to a 128-unit radius, 50% of entities moving, 600 ticks, player parked at the world centre with 21.6 entities in range on average:
| Payload | Wire (+28 B) | |
|---|---|---|
| Per tick | 70 B | 98 B |
| Per second at 60 Hz | 4,190 B/s | 5,870 B/s |
| 16 clients | 0.54 Mbit/s | 0.75 Mbit/s |
| vs naive JSON | 263x smaller | 188x smaller |
And the reason those two columns differ:
| Payload | Wire | Header share | Wire B/s at 60 Hz |
|---|---|---|---|
| 18,362 B | 18,390 B | 0.2% | 1,103,400 |
| 1,670 B | 1,698 B | 1.6% | 101,880 |
| 220 B | 248 B | 11.3% | 14,880 |
| 70 B | 98 B | 28.6% | 5,880 |
| 20 B | 48 B | 58.3% | 2,880 |
Once your payload is under about 100 bytes the UDP and IP headers are 29% of your traffic, and the next optimisation is not a smaller payload — it is fewer packets. Dropping from 60 Hz to 30 Hz and interpolating halves everything, headers included. Over IPv6 the header is 48 bytes and the crossover arrives sooner; none of these figures include the 38-byte Ethernet frame.
The honest caveat: every number here is a payload byte count from a serialiser, and the wire column is payload plus 28. I did not open a socket, so nothing measures MTU fragmentation, coalescing, or what a NIC does with 60 small packets per second. If you are deciding how to lay out the entity data these packets are built from, the struct-of-arrays question is measured separately.
Check it yourself
Save as sync.js and run node sync.js. No packages, no network, a few seconds.
// sync.js — how many bytes does it take to sync a multiplayer game?
const zlib = require('node:zlib');
const W = 512, MAXV = 20, HZ = 60, N = 128, PLAYERS = 16, HDR = 6;
const mul = a => () => { a |= 0; a = a + 0x6D2B79F5 | 0; let t = Math.imul(a ^ a >>> 15, 1 | a);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; };
function makeWorld(n, seed) {
const r = mul(seed), w = { n, rnd: r, mv: new Uint8Array(n) };
for (const k of ['x', 'y', 'vx', 'vy', 'rot']) w[k] = new Float64Array(n);
w.hp = new Uint8Array(n); w.st = new Uint8Array(n);
for (let i = 0; i < n; i++) { w.x[i] = r() * W; w.y[i] = r() * W;
const sp = 2 + r() * (MAXV - 2), a = r() * Math.PI * 2;
w.vx[i] = Math.cos(a) * sp; w.vy[i] = Math.sin(a) * sp;
w.rot[i] = a - Math.PI; w.hp[i] = 1 + ((r() * 100) | 0); w.st[i] = (r() * 8) | 0; }
return w;
}
function step(w, frac) { // dt = 1/60; a random subset moves
const dt = 1 / 60, r = w.rnd, m = Math.round(w.n * frac);
if (frac >= 1) w.mv.fill(1); else { w.mv.fill(0); let c = 0;
while (c < m) { const i = (r() * w.n) | 0; if (!w.mv[i]) { w.mv[i] = 1; c++; } } }
for (let i = 0; i < w.n; i++) { if (!w.mv[i]) continue;
w.x[i] += w.vx[i] * dt; w.y[i] += w.vy[i] * dt;
if (w.x[i] < 0) { w.x[i] = -w.x[i]; w.vx[i] = -w.vx[i]; }
else if (w.x[i] >= W) { w.x[i] = 2 * W - w.x[i]; w.vx[i] = -w.vx[i]; }
if (w.y[i] < 0) { w.y[i] = -w.y[i]; w.vy[i] = -w.vy[i]; }
else if (w.y[i] >= W) { w.y[i] = 2 * W - w.y[i]; w.vy[i] = -w.vy[i]; }
w.rot[i] = Math.atan2(w.vy[i], w.vx[i]); }
const k = Math.max(1, Math.round(w.n * 0.02)); // 2% take damage / change anim
for (let j = 0; j < k; j++) { const i = (r() * w.n) | 0;
w.hp[i] = Math.max(0, w.hp[i] - (1 + ((r() * 5) | 0))); w.st[i] = (r() * 8) | 0; }
}
const jsonBuf = (w, t) => { const e = []; for (let i = 0; i < w.n; i++) e.push({ id: i, x: w.x[i],
y: w.y[i], vx: w.vx[i], vy: w.vy[i], rot: w.rot[i], hp: w.hp[i], st: w.st[i] });
return Buffer.from(JSON.stringify({ tick: t, entities: e }), 'utf8'); };
const QP = 65535 / W, QV = 32767 / MAXV, QR = 255 / (2 * Math.PI);
const qx = v => Math.round(v * QP), ux = q => q / QP, qv = v => Math.round(v * QV);
const qr = v => Math.round((v + Math.PI) * QR);
function binQ(w, t) { // 6 + 13 bytes per entity
const b = Buffer.allocUnsafe(HDR + w.n * 13), d = new DataView(b.buffer, b.byteOffset);
d.setUint32(0, t, true); d.setUint16(4, w.n, true); let o = HDR;
for (let i = 0; i < w.n; i++, o += 13) { d.setUint16(o, i, true);
d.setUint16(o + 2, qx(w.x[i]), true); d.setUint16(o + 4, qx(w.y[i]), true);
d.setInt16(o + 6, qv(w.vx[i]), true); d.setInt16(o + 8, qv(w.vy[i]), true);
d.setUint8(o + 10, qr(w.rot[i])); d.setUint8(o + 11, w.hp[i]); d.setUint8(o + 12, w.st[i]); }
return b;
}
const snapQ = w => { const n = w.n, s = { x: new Uint16Array(n), y: new Uint16Array(n),
vx: new Int16Array(n), vy: new Int16Array(n), rot: new Uint8Array(n),
hp: new Uint8Array(n), st: new Uint8Array(n) };
for (let i = 0; i < n; i++) { s.x[i] = qx(w.x[i]); s.y[i] = qx(w.y[i]); s.vx[i] = qv(w.vx[i]);
s.vy[i] = qv(w.vy[i]); s.rot[i] = qr(w.rot[i]); s.hp[i] = w.hp[i]; s.st[i] = w.st[i]; }
return s; };
const FB = [16, 16, 16, 16, 8, 7, 3];
const mask = (c, b, i) => (c.x[i] !== b.x[i]) | (c.y[i] !== b.y[i]) << 1 | (c.vx[i] !== b.vx[i]) << 2 |
(c.vy[i] !== b.vy[i]) << 3 | (c.rot[i] !== b.rot[i]) << 4 | (c.hp[i] !== b.hp[i]) << 5 |
(c.st[i] !== b.st[i]) << 6;
function deltaBits(cur, base, n) { // presence bitmap + packed fields
let bits = HDR * 8 + n;
for (let i = 0; i < n; i++) { const m = mask(cur, base, i); if (!m) continue;
bits += 7; for (let f = 0; f < 7; f++) if (m & (1 << f)) bits += FB[f]; }
return (bits + 7) >> 3;
}
const fmt = n => Math.round(n).toLocaleString('en-US');
console.log(process.version, process.arch);
console.log('\n1. naive JSON, full state every tick (payload bytes)');
for (const n of [8, 32, 128]) { const w = makeWorld(n, 12345); let s = 0;
for (let t = 0; t < 120; t++) { step(w, 1); s += jsonBuf(w, t).length; }
const p = s / 120;
console.log(` n=${n}\t${fmt(p)} B/tick\t${fmt(p * 60)} B/s @60Hz\t${(p * 60 * PLAYERS * 8 / 1e6).toFixed(1)} Mbit/s x16`); }
console.log('\n2. one technique at a time, n=128 @60Hz, all moving');
{ const w = makeWorld(N, 12345); let j = 0, q = 0, g = 0, T = 240;
for (let t = 0; t < T; t++) { step(w, 1); const J = jsonBuf(w, t), Q = binQ(w, t);
j += J.length; q += Q.length; g += zlib.gzipSync(J).length; }
for (const [k, v] of [['JSON', j / T], ['gzip(JSON)', g / T], ['binary+quantised', q / T]])
console.log(` ${k.padEnd(18)}${fmt(v)} B/tick\t${fmt(v * 60)} B/s`); }
console.log('\n3. quantisation error, 16-bit position in a 512-unit world');
{ const w = makeWorld(4096, 999); let s = 0, mx = 0;
for (let t = 0; t < 20; t++) { step(w, 1);
for (let i = 0; i < w.n; i++) { const e = Math.abs(ux(qx(w.x[i])) - w.x[i]); s += e; if (e > mx) mx = e; } }
console.log(` mean ${(s / (4096 * 20)).toExponential(3)} u max ${mx.toExponential(3)} u`); }
console.log('\n4. delta vs previous tick, bit-packed');
for (const f of [0.1, 0.5, 1.0]) { const w = makeWorld(N, 12345); let b = snapQ(w), s = 0;
for (let t = 0; t < 240; t++) { step(w, f); const c = snapQ(w); s += deltaBits(c, b, N); b = c; }
console.log(` ${f * 100}% moving\t${fmt(s / 240)} B/tick\t(full quantised is ${HDR + N * 13} B)`); }
console.log('\n5. delta against an ACKED baseline, 10% moving, 0% loss');
for (const RTT of [0, 3, 6, 12]) { const w = makeWorld(N, 12345), h = new Map(); let ack = -1, s = 0;
const pend = []; h.set(-1, snapQ(w));
for (let t = 0; t < 1800; t++) { step(w, 0.1); const c = snapQ(w);
s += deltaBits(c, h.get(ack) || h.get(-1), N); h.set(t, c); pend.push([t + RTT, t]);
while (pend.length && pend[0][0] <= t) { const p = pend.shift(); if (p[1] > ack) ack = p[1]; }
for (const k of h.keys()) if (k < ack && k !== -1) h.delete(k); }
console.log(` RTT ${String(Math.round(RTT * 1000 / 60)).padStart(3)} ms\t${fmt(s / 1800)} B/tick`); }
console.log('\n6. interest management, quantised full state');
{ const w = makeWorld(N, 12345), r = mul(4242);
for (const R of [32, 64, 128, 256, 1024]) { let v = 0, s = 0;
for (let t = 0; t < 200; t++) { step(w, 1); const px = r() * W, py = r() * W; let c = 0;
for (let i = 0; i < N; i++) { const dx = w.x[i] - px, dy = w.y[i] - py;
if (dx * dx + dy * dy <= R * R) c++; }
v += c; s += HDR + c * 13; }
console.log(` r=${String(R === 1024 ? 'all' : R).padStart(4)}\t${(v / 200).toFixed(1)} visible\t${fmt(s / 200)} B/tick`); } }
console.log('\n7. does gzip help a small binary packet?');
for (const n of [8, 32, 128]) { const w = makeWorld(n, 777); for (let t = 0; t < 30; t++) step(w, 1);
const q = binQ(w, 30), g = zlib.gzipSync(q).length;
console.log(` quantised n=${String(n).padStart(3)}\t${q.length} B -> gzip ${g} B\t${(g / q.length).toFixed(2)}x`); }
console.log('\n8. wire cost: every UDP/IPv4 packet carries 28 bytes of header');
for (const p of [18362, 1670, 70, 20])
console.log(` ${String(p).padStart(5)} B payload -> ${p + 28} B wire\t${fmt((p + 28) * HZ)} B/s @60Hz\t${(100 * 28 / (p + 28)).toFixed(1)}% header`);
On the machine above it prints:
v23.5.0 arm64
1. naive JSON, full state every tick (payload bytes)
n=8 1,158 B/tick 69,475 B/s @60Hz 8.9 Mbit/s x16
n=32 4,591 B/tick 275,432 B/s @60Hz 35.3 Mbit/s x16
n=128 18,362 B/tick 1,101,710 B/s @60Hz 141.0 Mbit/s x16
2. one technique at a time, n=128 @60Hz, all moving
JSON 18,359 B/tick 1,101,527 B/s
gzip(JSON) 7,400 B/tick 443,991 B/s
binary+quantised 1,670 B/tick 100,200 B/s
3. quantisation error, 16-bit position in a 512-unit world
mean 1.947e-3 u max 3.906e-3 u
4. delta vs previous tick, bit-packed
10% moving 91 B/tick (full quantised is 1670 B)
50% moving 334 B/tick (full quantised is 1670 B)
100% moving 638 B/tick (full quantised is 1670 B)
5. delta against an ACKED baseline, 10% moving, 0% loss
RTT 0 ms 89 B/tick
RTT 50 ms 250 B/tick
RTT 100 ms 367 B/tick
RTT 200 ms 515 B/tick
6. interest management, quantised full state
r= 32 1.5 visible 26 B/tick
r= 64 5.7 visible 81 B/tick
r= 128 20.7 visible 275 B/tick
r= 256 64.1 visible 839 B/tick
r= all 128.0 visible 1,670 B/tick
7. does gzip help a small binary packet?
quantised n= 8 110 B -> gzip 133 B 1.21x
quantised n= 32 422 B -> gzip 445 B 1.05x
quantised n=128 1670 B -> gzip 1658 B 0.99x
8. wire cost: every UDP/IPv4 packet carries 28 bytes of header
18362 B payload -> 18390 B wire 1,103,400 B/s @60Hz 0.2% header
1670 B payload -> 1698 B wire 101,880 B/s @60Hz 1.6% header
70 B payload -> 98 B wire 5,880 B/s @60Hz 28.6% header
20 B payload -> 48 B wire 2,880 B/s @60Hz 58.3% header
The order to apply these, by return on effort: binary packing (11x, an afternoon), interest management (up to 6x, a design decision), delta encoding (2.6x at full movement, needs an ack protocol), bit-packing (1.2-1.4x, hardest to debug), compression (nothing, or worse). Then stop optimising the payload and cut the tick rate.