../

Multiplayer game worlds

Netcode for realtime browser games: the authoritative server loop, the messages it trades, the client tricks that hide latency (prediction, reconciliation, interpolation, lag compensation), and the transports and frameworks that carry it. Rooms, envelopes and trust basics are in Realtime fundamentals, the socket itself in WebSockets. Drawing the world: Three.js; engines: Game dev.

Network models

ModelHow it worksFeelCheatingFits
Authoritative serverclients send inputs; the server simulates and broadcasts stateown moves instant (predicted); others ≈ 100 ms latehard: the server decidesaction, .io and ranked games: the web default
Client-authoritative relayeach client simulates itself and broadcasts its position; the server forwardsinstanttrivial (teleports, speed hacks)co-op toys, prototypes
Deterministic locksteppeers exchange only inputs for tick N; everyone steps once all inputs for N arriveeveryone waits for the slowest peer (input delay)desyncs; every client knows everythingRTS with thousands of units, turn-based
Rollback (GGPO-style)lockstep that doesn't wait: guess missing remote inputs, and on arrival rewind and re-simulatelocal input instant; remote players can popas lockstepfighting games, 1v1 sports, platformers
  • Tick: one fixed simulation step. Authority: the side whose state is the truth. Determinism: same start
    • same inputs → bit-identical result. Lockstep and rollback need it; Math.sin and friends are implementation-approximated in JavaScript, so run one engine version everywhere or use fixed-point math.
  • The rest of this sheet is the authoritative model, which also underlies Colyseus and most .io games. Who may decide what: Realtime fundamentals: Authority & trust.

The authoritative loop

every tick (33.3 ms at 30 Hz)
  1. take inputs     queued per player since the last tick, already validated
  2. simulate        step(state, input) with a FIXED dt, never the frame time
  3. record          positions per tick (lag compensation history)
  4. every Nth tick  send each client a snapshot: what it may see + ack of its last input
RateWhatTypicalNotes
Tick rateserver simulation steps per second20–60 Hz (Source engine: 66)fixed dt; costs CPU per room
Send (snapshot) ratesnapshots per second to each client10–30 Hz (Source default: 20)bandwidth = rate × snapshot bytes × players
Input (command) rateinput messages per second per client= tick rate (Source: 30)one input per tick keeps prediction exact
Render rateclient frames per seconddisplay refresh, 60–120+ Hzindependent: interpolate between states

The step is a pure function, imported by the server and the client (the big win of TypeScript on both sides):

sim.ts
export type Vec = { x: number; y: number };
export type Input = { seq: number; dx: number; dy: number };
 
export const TICK_HZ = 30;
export const DT = 1 / TICK_HZ; // seconds per tick
const SPEED = 6; // units per second
 
// shared by server and client: same input → same result
export function step(p: Vec, i: Input): Vec {
  const len = Math.hypot(i.dx, i.dy) || 1;
  return {
    x: p.x + (i.dx / len) * SPEED * DT,
    y: p.y + (i.dy / len) * SPEED * DT,
  };
}

The world owns a queue per player and consumes at most one input per tick:

world.ts
import { step, type Input, type Vec } from "./sim";
 
type Player = {
  eid: number; // small numeric id for the wire
  pos: Vec;
  queue: Input[]; // received, not yet simulated
  ack: number; // last seq simulated
};
const MAX_QUEUE = 8; // ≈ 250 ms of inputs at 30 Hz
 
export function createWorld() {
  const players = new Map<string, Player>();
  let tick = 0;
  let nextEid = 1;
  return {
    join(id: string): number {
      const eid = nextEid++;
      const pos = { x: 0, y: 0 };
      players.set(id, { eid, pos, queue: [], ack: 0 });
      return eid; // tell the client which entity is theirs
    },
    leave: (id: string) => players.delete(id),
    // from the socket: validated, then queued (not applied)
    input(id: string, i: Input) {
      const p = players.get(id);
      if (!p) return;
      const last = p.queue.at(-1)?.seq ?? p.ack;
      if (i.seq <= last) return; // duplicate or stale
      if (p.queue.length >= MAX_QUEUE) return; // flooding
      players.set(id, { ...p, queue: [...p.queue, i] });
    },
    // one fixed tick: at most one input per player
    update() {
      tick += 1;
      for (const [id, p] of players) {
        const [next, ...rest] = p.queue;
        if (!next) continue; // no input: stand still
        const pos = step(p.pos, next);
        const ack = next.seq;
        players.set(id, { ...p, pos, queue: rest, ack });
      }
    },
    snapshotFor(id: string) {
      const ents = [...players.values()].map((p) => ({
        id: p.eid,
        ...p.pos,
      }));
      return { tick, ack: players.get(id)?.ack ?? 0, ents };
    },
  };
}
  • Timers jitter (a 33 ms setInterval fires late, and GC pauses), so accumulate real time and run whole steps; cap the catch-up so a long stall doesn't cause a "spiral of death" (Fiedler, Fix Your Timestep).
  • One input per tick matches what the client predicted. Draining the whole queue in one tick is simpler but makes a player with a lag spike move in bursts.
  • Measured with Bun: 29 ticks and 14 broadcasts in one second.
loop.ts
import { TICK_HZ } from "./sim";
 
const TICK_MS = 1000 / TICK_HZ;
const SEND_EVERY = 2; // 30 Hz sim, 15 Hz snapshots
 
// fixed-step accumulator: timers jitter, the sim must not
export function runLoop(
  update: () => void,
  broadcast: () => void,
) {
  let last = performance.now();
  let acc = 0;
  let tick = 0;
  const timer = setInterval(() => {
    const now = performance.now();
    acc += Math.min(now - last, 250); // cap catch-up
    last = now;
    while (acc >= TICK_MS) {
      update();
      acc -= TICK_MS;
      tick += 1;
      if (tick % SEND_EVERY === 0) broadcast();
    }
  }, TICK_MS / 2);
  return () => clearInterval(timer);
}

Input messages

protocol.ts
// client → server, one per client tick
export type InputMsg = {
  t: "input";
  seq: number; // +1 every client tick, never reused
  dx: -1 | 0 | 1; // intent, never a position
  dy: -1 | 0 | 1;
  fire: boolean;
};
 
// server → each client, every send tick
export type SnapshotMsg = {
  t: "snap";
  tick: number; // server tick of this state
  ack: number; // last seq applied for this client
  ents: { id: number; x: number; y: number }[];
};
Field or ruleWhy
intent (dx, fire)the server computes results; a client can't claim a position, a hit or a score
seqorders and dedupes inputs; the server echoes the last one it applied as ack
ack in every snapshottells the client which predicted inputs are settled (reconciliation)
render time (optional)when the shooter saw the world, for lag compensation
stale seq droppedseq ≤ last seen is a duplicate or a replay
queue capa client can't bank inputs and burst them (speed hack)
repeated inputsover unreliable transports, send the last 3 inputs per packet (recipe)

Snapshots & encoding

KindContentProsCons
Full snapshotevery entity the client may see, every sendstateless, any loss is healed next timelargest
Delta snapshotchanges since the last snapshot the client ackedsmallper-client baseline on the server; needs acks
Reliable eventsdiscrete facts: "door opened", kills, chatexactneed ordering and retransmits
  • Quantize before sending: position as an int16 of 1/100 units (±327 units), an angle as 1 byte (256 steps ≈ 1.4°), a health bar as uint8. Clients render quantized values, so the server should simulate on the same precision or accept tiny mispredictions.
  • Binary beats JSON by 4–5× here: no key names, no decimal text. Measured for 50 entities:
Encoding50 entitiesAt 20 Hz
JSON, raw floats1,815 B36 KB/s per client
JSON, 2 decimals1,436 B29 KB/s per client
DataView, int16 positions311 B6.2 KB/s per client
  • One entity: 66 B of JSON against 17 B of binary.

  • Every WebSocket message adds 2–14 bytes of framing plus TCP/IP headers; send one snapshot per tick, not one message per entity.

  • DataView is big-endian by default; set ws.binaryType = "arraybuffer" on the client.

  • Middle ground: MessagePack. Colyseus schema does delta + binary for you.

codec.ts
type Ent = { id: number; x: number; y: number };
const SNAP = 1; // message type byte
const SCALE = 100; // 0.01-unit precision, ±327 units
 
// 1 type + 4 tick + 4 ack + 2 count, then 6 per entity
export function encodeSnap(
  tick: number,
  ack: number,
  ents: Ent[],
): ArrayBuffer {
  const buf = new ArrayBuffer(11 + ents.length * 6);
  const v = new DataView(buf);
  v.setUint8(0, SNAP);
  v.setUint32(1, tick);
  v.setUint32(5, ack);
  v.setUint16(9, ents.length);
  ents.forEach((e, n) => {
    const o = 11 + n * 6;
    v.setUint16(o, e.id);
    v.setInt16(o + 2, Math.round(e.x * SCALE));
    v.setInt16(o + 4, Math.round(e.y * SCALE));
  });
  return buf;
}
 
export function decodeSnap(buf: ArrayBuffer) {
  const v = new DataView(buf);
  if (v.getUint8(0) !== SNAP) throw new Error("not a snap");
  const count = v.getUint16(9);
  if (buf.byteLength !== 11 + count * 6) {
    throw new Error("bad length"); // never trust sizes
  }
  const ents = Array.from({ length: count }, (_, n) => {
    const o = 11 + n * 6;
    return {
      id: v.getUint16(o),
      x: v.getInt16(o + 2) / SCALE,
      y: v.getInt16(o + 4) / SCALE,
    };
  });
  return { tick: v.getUint32(1), ack: v.getUint32(5), ents };
}

Client-side prediction & reconciliation

Client-side prediction and server reconciliation predict locally, then send inputs 1 … 6 client server time → 1 2 3 4 5 6 1 2 3 4 5 6 applies 1–3, sends state + ack 3 4–6 still in flight snapshot arrives 1. drop acked inputs 1–3 2. reset to the server state 3. replay 4, 5, 6 on top pending 1 2 3 4 5 6 4 5 6 kept, replayed each snapshot If the server disagrees (a wall, a hit), the replay starts from its answer: the server stays in charge.
Predict every input now; on each snapshot, rewind to the server's state and replay what it hasn't seen yet
predict.ts
import { step, type Input, type Vec } from "./sim";
 
export function createPredictor(start: Vec) {
  let seq = 0;
  let pending: Input[] = []; // sent, not yet acked
  let pos = start;
  return {
    get pos() {
      return pos;
    },
    get pending() {
      return pending.length;
    },
    // every client tick: apply now, then send
    input(dx: number, dy: number): Input {
      const i = { seq: ++seq, dx, dy };
      pending = [...pending, i];
      pos = step(pos, i);
      return i; // → socket
    },
    // every snapshot: rewind to the server, replay the rest
    reconcile(server: Vec, ack: number) {
      pending = pending.filter((i) => i.seq > ack);
      pos = pending.reduce(step, server);
    },
  };
}
  • Predict only your own entity (and maybe your own projectiles); everyone else is interpolated.
  • Replayed inputs = RTT × input rate: 150 ms at 30 Hz is 4–5 steps per snapshot, cheap for simple movement.
  • When the server disagrees (you ran into another player), the replay moves you. Hide small jumps with a decaying offset.
  • Tested: 5 predicted inputs, snapshot acks 3, replay of 4–5 lands on the same position; a server correction to x = 0 at ack 3 leaves exactly 2 steps of movement.
  • Colyseus 0.18 ships this: room.input() + Predict.get(room).reconciler(entity, { input, step }).

Entity interpolation

Entity interpolation: draw remote players in the past snapshots every 50 ms (20 Hz), stamped with server time render time now 0 50 100 150 lost 200 ms delay = 100 ms (2 × send interval) Lerp between the two buffered snapshots around render time: 100 and 200, so one lost packet (150) is bridged. Own player: predicted, not interpolated. Buffer empty (a longer gap): hold the last position or extrapolate briefly.
Remote players are drawn about 100 ms in the past, between two snapshots already received
interp.ts
type Sample = { t: number; x: number; y: number };
const DELAY_MS = 100; // ≥ 2 snapshot intervals at 20 Hz
 
// one buffer per remote entity; t = server time in ms
export function createInterp(max = 30) {
  let buf: Sample[] = [];
  return {
    push(s: Sample) {
      buf = [...buf, s].slice(-max);
    },
    at(serverNow: number): Sample | undefined {
      const t = serverNow - DELAY_MS; // render the past
      const i = buf.findIndex((s) => s.t > t);
      if (i === -1) return buf.at(-1); // starved: hold
      if (i === 0) return buf[0]; // too early
      const a = buf[i - 1]!;
      const b = buf[i]!;
      const k = (t - a.t) / (b.t - a.t);
      return {
        t,
        x: a.x + (b.x - a.x) * k,
        y: a.y + (b.y - a.y) * k,
      };
    },
  };
}
  • Delay ≥ 2 send intervals so a single lost snapshot is bridged. Source's rule: interpolation period = max(cl_interp, cl_interp_ratio / cl_updaterate), which is 100 ms by default.
  • Stamp snapshots with server time (or tick × dt) and read them with the synced clock, not arrival time: arrival jitter would show as stutter.
  • Starved buffer (packet loss burst): hold the last position, or extrapolate along the last velocity for at most ~250 ms (dead reckoning), then freeze.
  • Angles: interpolate along the shortest arc (wrap at ±180°).

Lag compensation

The shooter aimed at where they saw the target, 100+ ms in the past. The server rewinds the other players to that moment, tests the hit, and applies the result now.

command execution time = server time − packet latency − client interpolation delay   (Valve)
history.ts
type Pos = { x: number; y: number };
type Frame = { t: number; pos: Map<number, Pos> };
const MAX_REWIND_MS = 250; // cap: limits "shot behind cover"
 
// server: record every tick, rewind for hit tests
export function createHistory(keepMs = 1000) {
  let frames: Frame[] = [];
  return {
    record(t: number, pos: Map<number, Pos>) {
      frames = [...frames, { t, pos }].filter(
        (f) => f.t >= t - keepMs,
      );
    },
    // world as the shooter saw it at their render time
    seenAt(renderT: number, now: number) {
      const t = Math.max(renderT, now - MAX_REWIND_MS);
      return frames.findLast((f) => f.t <= t)?.pos;
    },
  };
}
  • Record positions every tick; keep ≈ 1 s (Source's sv_maxunlag default is 1 s).
  • Cap the rewind (250 ms above): past the cap, high-ping players must lead their shots. Without a cap, a faked lag spike gives free hits.
  • Cost: the victim is sometimes "shot behind cover". It favors the shooter by design.
  • Colyseus 0.18: this.allowRewindState({ maxRewindMs }), rewind.attachAll(...) and rewind.lastSeenBy(sessionId).

Clock sync

clock.ts
type Sample = { rtt: number; offset: number };
 
// client sends { t: "ping", c: performance.now() }
// server answers { t: "pong", c, s: its own clock }
export function createClock(keep = 8) {
  let samples: Sample[] = [];
  const best = () =>
    samples.reduce<Sample | undefined>(
      (a, b) => (!a || b.rtt < a.rtt ? b : a),
      undefined,
    );
  return {
    pong(c: number, s: number, now = performance.now()) {
      const rtt = now - c;
      const offset = s + rtt / 2 - now; // server - local
      samples = [...samples, { rtt, offset }].slice(-keep);
    },
    // the lowest-RTT sample has the least queuing error
    get offset() {
      return best()?.offset ?? 0;
    },
    get rtt() {
      return best()?.rtt ?? 0;
    },
    serverNow(now = performance.now()) {
      return now + (best()?.offset ?? 0);
    },
  };
}
  • Offset = server time + RTT/2 − local time: assumes symmetric paths, so keep the lowest-RTT sample (least queuing). Ping every 1–2 s; RTT (round-trip time) also feeds the ping display.
  • Use performance.now() (monotonic); Date.now() jumps when the OS adjusts the clock.
  • Tested: a 40 ms sample with offset 1,000 beats a 200 ms sample with a skewed offset.
  • Colyseus: room.ping((ms) => …) measures RTT.

Interest management

Send each client only what it may see: bandwidth grows with players², and anything sent can be read by a cheat.

TechniqueHowGood for
Grid / AOI cellsbucket entities into cells ≈ view radius; send the 3×3 around a playeropen worlds, .io games (AOI: area of interest)
Distance prioritynear or fast entities every snapshot, far ones every Nthlarge counts under a byte budget
Visibilityserver line-of-sight test before sendingshooters (defeats wallhacks)
Rooms / shardssplit the world into separate rooms or instanceslobbies, MMO zones
aoi.ts
type Ent = { id: number; x: number; y: number };
const CELL = 50; // ≈ view radius
 
const cellOf = (x: number, y: number) =>
  `${Math.floor(x / CELL)},${Math.floor(y / CELL)}`;
 
// rebuild once per tick: O(n)
export function buildGrid(ents: Ent[]) {
  const grid = new Map<string, Ent[]>();
  for (const e of ents) {
    const k = cellOf(e.x, e.y);
    grid.set(k, [...(grid.get(k) ?? []), e]);
  }
  return grid;
}
 
// the 3×3 cells around a player: what they may see
export function nearby(
  grid: Map<string, Ent[]>,
  x: number,
  y: number,
): Ent[] {
  const cx = Math.floor(x / CELL);
  const cy = Math.floor(y / CELL);
  return [-1, 0, 1].flatMap((dx) =>
    [-1, 0, 1].flatMap(
      (dy) => grid.get(`${cx + dx},${cy + dy}`) ?? [],
    ),
  );
}
  • Hysteresis: add at radius r, remove at r + margin, or entities flicker at the border.
  • Colyseus: StateView plus .view() fields filter the synced state per client.

Anti-cheat

  • Never trust client state. Positions, hits, scores, inventory and timers live on the server; the client sends intent.
  • Validate every input with a schema, then clamp to physics: speed, fire rate, cooldowns, reach.
  • Rate limit: about one input per tick; drop or kick above that (Colyseus maxMessagesPerSecond). Token bucket: WebSockets: Security.
  • Decide hits on the server with capped rewind.
  • Hide information: interest management; don't send enemy positions behind walls.
  • Obfuscation is not security: the client bundle and the protocol are public.
  • Log and flag statistical outliers (accuracy, reaction time) for review rather than auto-banning.
  • Identity comes from the connection, never a message field: Authority & trust.
validate.ts
import { z } from "zod";
 
const Dir = z.union([
  z.literal(-1),
  z.literal(0),
  z.literal(1),
]);
export const InputMsg = z.object({
  t: z.literal("input"),
  seq: z.number().int().nonnegative(),
  dx: Dir,
  dy: Dir,
  fire: z.boolean(),
});
 
// a hit claim is checked, never believed
export function canHit(
  shooter: { x: number; y: number; lastShot: number },
  target: { x: number; y: number }, // rewound position
  now: number,
): boolean {
  const RANGE = 30;
  const COOLDOWN_MS = 250;
  const d = Math.hypot(
    target.x - shooter.x,
    target.y - shooter.y,
  );
  return d <= RANGE && now - shooter.lastShot >= COOLDOWN_MS;
}

Transports

TransportDeliveryHead-of-line blockingBrowsersServer side
WebSocketreliable, ordered (TCP)yes: one lost packet stalls all later data for ≥ 1 RTTeverywhereanything: Bun.serve, ws, Colyseus
WebRTC data channelordered: false, maxRetransmits: 0 ≈ UDP (SCTP over DTLS)noeverywherea WebRTC peer on the server (geckos.io, node-datachannel) + ICE; or P2P
WebTransport datagramsunreliable, unordered (QUIC); reliable streams toono (streams are independent)Baseline 2026: Chrome 97, Firefox 114, Safari 26.4an HTTP/3 server; Colyseus @colyseus/h3-transport (experimental)
  • Start with WebSocket: it's enough for turn-based, casual and co-op games. Move the per-tick state to an unreliable channel when loss spikes (mobile, Wi-Fi) start to hurt.
  • Head-of-line blocking (HOL): TCP delivers in order, so a lost snapshot delays the newer ones behind it, which are the only ones you want.
  • WebTransport sending differs per browser: datagrams.writable is deprecated and missing in Safari; createWritable() is in Firefox 155+ and Safari 26.4+ but not Chrome. Feature-detect (below).
  • Keep datagrams under maxDatagramSize: QUIC only guarantees 1,200-byte packets, minus headers; larger ones are dropped.
  • ReadableStream async iteration (for await) only arrived in Safari 27: use a reader loop.
  • WebRTC setup (signaling, STUN/TURN): WebRTC.
transports.ts
// WebRTC: unordered, no retransmits ≈ UDP
export function stateChannel(pc: RTCPeerConnection) {
  const ch = pc.createDataChannel("state", {
    ordered: false,
    maxRetransmits: 0,
  });
  ch.binaryType = "arraybuffer";
  return ch;
}
 
// WebTransport: QUIC datagrams (HTTPS, HTTP/3 server)
export async function datagrams(url: string) {
  const wt = new WebTransport(url);
  await wt.ready;
  const dg = wt.datagrams;
  // Firefox/Safari: createWritable(); Chrome: .writable
  type Dg = { createWritable?: () => WritableStream };
  const make = (dg as Dg).createWritable;
  const w = make ? make.call(dg) : dg.writable;
  const out = w.getWriter();
  const reader = dg.readable.getReader();
  return {
    max: dg.maxDatagramSize, // bytes; larger ones drop
    send: (b: Uint8Array) => out.write(b),
    async *receive() {
      for (;;) {
        const { value, done } = await reader.read();
        if (done) return;
        yield value as Uint8Array;
      }
    },
  };
}

Frameworks

OptionVersion (Sept 2026)ModelTransportNotes
Colyseus0.18: server colyseus, client @colyseus/sdkrooms + matchmaking; schema state synced as binary deltasWebSocket (Node, Bun); WebTransport experimental0.18 adds fixed timestep, input buffers, prediction, rewind; colyseus.js is the old client (stops at 0.16)
geckos.io3.1Socket.IO-like events over WebRTC data channelsWebRTC (node-datachannel); UDP ports must be openNode, ESM only; the netcode is yours
Durable ObjectsWorkers runtimeone object per match: single-threaded, a natural authorityWebSocketa setInterval loop blocks hibernation, so the match bills duration; Durable Objects
Godot high-level multiplayer4.7@rpc("any_peer" | "authority", …), set_multiplayer_authority, MultiplayerSpawner / MultiplayerSynchronizerENet (UDP); web exports: WebSocket or WebRTC onlysame ideas (authority, reliable vs unreliable channels) in an engine; Godot

Colyseus room

State is a schema shared by server and client; mutating it on the server is all it takes to sync.

schema.ts
// shared by server and client
import { schema, t } from "@colyseus/schema";
import type { SchemaType } from "@colyseus/schema";
 
export const Player = schema(
  {
    x: t.float32().default(0),
    y: t.float32().default(0),
  },
  "Player",
);
export const State = schema(
  { players: t.map(Player) },
  "State",
);
export type State = SchemaType<typeof State>;
arena.ts
import {
  Room,
  defineRoom,
  defineServer,
  validate,
  type Client,
} from "colyseus";
import { z } from "zod";
import { Player, State } from "./schema";
 
const Move = z.object({
  dx: z.number().min(-1).max(1),
  dy: z.number().min(-1).max(1),
});
type Move = z.infer<typeof Move>;
 
export class Arena extends Room<{ state: State }> {
  override maxClients = 16;
  override maxMessagesPerSecond = 60; // then kicked
  override state = new State();
  moves = new Map<string, Move>();
 
  override messages = {
    // invalid payloads never reach the handler
    move: validate(Move, (client: Client, m: Move) => {
      this.moves.set(client.sessionId, m);
    }),
  };
 
  override onCreate() {
    this.patchRate = 50; // state patches at 20 Hz
    // variable step: ms = measured time since last tick
    this.setTimestep((ms) => this.tick(ms / 1000), 33);
  }
 
  tick(dt: number) {
    for (const [id, m] of this.moves) {
      const p = this.state.players.get(id);
      if (!p) continue;
      p.x += m.dx * 6 * dt; // schema records the delta
      p.y += m.dy * 6 * dt;
    }
  }
 
  override onJoin(client: Client) {
    this.state.players.set(client.sessionId, new Player());
  }
 
  override onLeave(client: Client) {
    this.state.players.delete(client.sessionId);
    this.moves.delete(client.sessionId);
  }
}
 
export const server = defineServer({
  rooms: { arena: defineRoom(Arena) },
});
// await server.listen(2567);
client.ts
import { Client, Callbacks } from "@colyseus/sdk";
import type { State } from "./schema";
 
const client = new Client("http://localhost:2567");
const room = await client.joinOrCreate<State>("arena");
const $ = Callbacks.get(room);
 
$.onAdd("players", (player, sessionId) => {
  // draw a sprite; then follow every patch
  $.onChange(player, () => {
    // interpolate toward player.x / player.y
  });
});
$.onRemove("players", (_player, sessionId) => {
  // remove the sprite
});
 
room.send("move", { dx: 1, dy: 0 });
  • Start it with await server.listen(2567); bun arena.ts works with the default WebSocket transport (tested).
  • A payload that fails validate() gets the sender disconnected (close code 4002), not just ignored.
  • Fields without .default(…) start undefined: p.x += … then gives NaN.
  • Pass the state type to joinOrCreate<State>() or the callbacks are untyped.
  • Test with lag: server.simulateLatency(150) or COLYSEUS_LATENCY=150 (round trip, ms).

Recipes

Bun room server with binary snapshots

When you want the loop, the queue and the codec above without a framework; one process, one room.

import { createWorld } from "./world";
import { runLoop } from "./loop";
import { encodeSnap } from "./codec";
import { InputMsg } from "./validate";
 
type Data = { id: string };
const parse = (s: string): unknown => {
  try {
    return JSON.parse(s);
  } catch {
    return undefined; // fails the schema below
  }
};
const world = createWorld();
const sockets = new Map<string, Bun.ServerWebSocket<Data>>();
 
export const server = Bun.serve({
  port: 3001,
  fetch(req, server) {
    const id = crypto.randomUUID(); // real app: session
    if (server.upgrade(req, { data: { id } })) return;
    return new Response("Upgrade required", { status: 426 });
  },
  websocket: {
    data: {} as Data,
    open(ws) {
      sockets.set(ws.data.id, ws);
      const eid = world.join(ws.data.id);
      ws.send(JSON.stringify({ t: "welcome", eid }));
    },
    message(ws, raw) {
      const msg = InputMsg.safeParse(parse(String(raw)));
      if (!msg.success) return ws.close(1008, "bad input");
      world.input(ws.data.id, msg.data);
    },
    close(ws) {
      sockets.delete(ws.data.id);
      world.leave(ws.data.id);
    },
  },
});
 
// per-client snapshot: each one carries its own ack
export const stop = runLoop(world.update, () => {
  for (const [id, ws] of sockets) {
    const s = world.snapshotFor(id);
    ws.send(encodeSnap(s.tick, s.ack, s.ents));
  }
});

Tested with two sockets: 15 inputs at 30 Hz give ack = 15 and x = 3.0; an out-of-range dx closes with 1008.

Render loop: predicted self, interpolated others

When wiring the client: fixed-rate input, snapshots in, requestAnimationFrame out.

import { createPredictor } from "./predict";
import { createInterp } from "./interp";
import { createClock } from "./clock";
import { decodeSnap } from "./codec";
import { DT } from "./sim";
 
declare const ws: WebSocket; // binaryType = "arraybuffer"
declare const myId: number;
declare function keys(): { dx: number; dy: number };
declare function draw(id: number, x: number, y: number):
  void;
 
const me = createPredictor({ x: 0, y: 0 });
const clock = createClock(); // fed by ping/pong
type Interp = ReturnType<typeof createInterp>;
const others = new Map<number, Interp>();
 
ws.addEventListener("message", (e) => {
  const snap = decodeSnap(e.data as ArrayBuffer);
  const t = snap.tick * DT * 1000; // server ms
  for (const ent of snap.ents) {
    if (ent.id === myId) me.reconcile(ent, snap.ack);
    else {
      const buf = others.get(ent.id) ?? createInterp();
      others.set(ent.id, buf);
      buf.push({ t, x: ent.x, y: ent.y });
    }
  }
});
 
// fixed-rate input, same rate as the server tick
setInterval(() => {
  const { dx, dy } = keys();
  const i = me.input(dx, dy);
  ws.send(JSON.stringify({ t: "input", ...i, fire: false }));
}, DT * 1000);
 
// render at display rate: own = predicted, others = past
function frame() {
  draw(myId, me.pos.x, me.pos.y);
  const now = clock.serverNow();
  for (const [id, buf] of others) {
    const p = buf.at(now);
    if (p) draw(id, p.x, p.y);
  }
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

Hide small corrections

When reconciliation makes your own player twitch: draw at the simulated position plus an offset that decays.

type Vec = { x: number; y: number };
 
// hide small reconciliation snaps: draw at sim + offset,
// and let the offset decay to zero over ~100 ms
export function createSmoother(halfLifeMs = 35) {
  let offset: Vec = { x: 0, y: 0 };
  return {
    // call with positions before and after reconcile()
    corrected(before: Vec, after: Vec) {
      offset = {
        x: offset.x + before.x - after.x,
        y: offset.y + before.y - after.y,
      };
      if (Math.hypot(offset.x, offset.y) > 3) {
        offset = { x: 0, y: 0 }; // too far: just snap
      }
    },
    // every frame: position to draw
    draw(sim: Vec, frameMs: number): Vec {
      const k = Math.pow(0.5, frameMs / halfLifeMs);
      offset = { x: offset.x * k, y: offset.y * k };
      return { x: sim.x + offset.x, y: sim.y + offset.y };
    },
  };
}

Redundant inputs over an unreliable channel

When inputs travel as datagrams: each packet repeats the last few, and the server's seq check drops copies.

import type { Input } from "./sim";
 
// unreliable channel: resend the last 3 inputs in every
// packet so one lost packet loses nothing
export function inputPacker(copies = 3) {
  let recent: Input[] = [];
  return (next: Input): Input[] => {
    recent = [...recent, next].slice(-copies);
    return recent; // → datagram
  };
}
 
// server side: the seq check in world.input() already
// drops the copies it has seen

Tested: with the packet carrying input 3 lost, the next packet's copy still gets ack to 5.

Simulate lag and loss locally

When localhost's 0 ms hides every netcode bug: wrap the send on either side.

// wrap any send() to test with bad networks locally
export function laggy<T>(
  deliver: (msg: T) => void,
  { ms = 100, jitter = 30, loss = 0.02 } = {},
) {
  return (msg: T) => {
    if (Math.random() < loss) return; // dropped
    const delay = ms + (Math.random() * 2 - 1) * jitter;
    setTimeout(() => deliver(msg), Math.max(0, delay));
  };
}
const sendToServer = laggy((m: string) => ws.send(m), {
  ms: 75, // one way; RTT ≈ 150 ms
  jitter: 20,
  loss: 0.05,
});

References