Netcode Recipes
Task-oriented patterns that build on the Client Prediction, Server Input, and Lag Compensation APIs. Each recipe stands alone. Jump to the one that matches what you’re building.
Available in Colyseus 0.18+.
Choosing a remote smoothing mode
Predict.attachAll smooths entities you don’t control. Pick the mode by what the entity is:
lerp: remote players. Faithful (renders exactly where the server put them, a little in the past). The default for anything you also want lag-compensated, because rewind can match the interpolated instant.reckon: AI / NPCs you can forward-simulate from a known step function. Renders at ≈serverNow, hiding latency; attach the server group withmode: "reckon"so hits line up.damped: cosmetic entities where “never jittery” beats “exact” (floating pickups, ambient critters). No fixed delay, so it can’t be exactly lag-compensated.
Full behavior reference: the modes table.
Taps between steps: buffer, then consume
Input is sampled once per fixed step, not per render frame. Frames and steps don’t line up. A 120 Hz display on a 30 Hz tick runs a step every ~4th frame, and a hitchy frame can run several steps at once. Two traps follow. A tap on a 0-step frame must not be lost (it belongs to the next step). A tap spanning a multi-step frame must not fire twice. “Is the key down right now?” sampling gets both wrong.
Buffer the press when it happens; consume it inside the step loop on exactly one step:
let jumpBuffered = false;
onKeyDown("Space", () => jumpBuffered = true); // buffer: the tap happened
function frame(now) {
const n = predict.tick(now);
for (let i = 0; i < n; i++) {
input.data.moveX = readAxis(); // held state: sample live, no buffer
input.data.jump = jumpBuffered;
jumpBuffered = false; // consume: fires on exactly one step
input.send();
}
}Variants of the same shape:
- Held buttons (movement, auto-fire) sample the live state directly: no buffer.
- Buffer windows (a jump buffer with a grace period): buffer a timestamp instead of a flag; the consuming step decides whether it’s still fresh.
- Analog deltas (pointer aim, mouselook): accumulate into a pending total and let each step consume its budget (
pending -= taken). Fast motion carries over to later steps instead of clipping.
Buffer outside the loop, consume inside it: draining an edge anywhere else either drops taps (0-step frames) or double-fires them (multi-step frames).
Unreliable input over WebTransport
On a datagram-capable transport, input frames don’t need reliable delivery. Each packet carries the last few inputs as redundancy, so a dropped datagram costs nothing and nothing head-of-line blocks.
Experimental, and WebTransport only. Unreliable input needs a datagram channel, which only WebTransport provides today (itself experimental). Right now only the JavaScript/TypeScript SDK supports WebTransport, and with it unreliable input.
Other SDKs reject mode: "unreliable" at construction for now: over their WebSocket-only transports the redundancy ring would cost bandwidth for no benefit. Use mode: "reliable" there.
const input = room.input({ type: MoveInput, mode: "unreliable", historySize: 4 });That’s the whole migration. The framework runs its own sequence numbers and acks by seq value, so rollback reconciliation works unchanged. No user-side bookkeeping, no seq field on your schema. historySize is the redundancy depth (how many recent inputs ride along in each datagram; default 3).
Predicted projectiles: predict.spawns
A fired bullet needs zero-latency feedback, but the server assigns its identity ~RTT later. predict.spawns runs the whole lifecycle: render the predicted local instantly, then correlate it to the authoritative entity when it arrives. One stable id persists across the handoff, so the sprite never flickers:
const rockets = predict.spawns("rockets", {
owned: r => r.owner === room.sessionId, // which server entities are mine
spawnTime: r => r.bornMs, // exact per-shot input lead
step: stepRocket, // shared client/server sim
fields: ["x", "z"], // reckon confirmed entities
});
// on the predicted fire (live input step):
rockets.spawn({ x, z, heading });
// render: one path, handoff-invisible, keyed on the stable entry id:
for (const e of rockets.entries()) {
draw(e.id, rockets.value(e, "x"), rockets.value(e, "z"));
}With spawnTime, an owned projectile keeps flying the shooter’s timeline through the handoff. No snap-back, and the rendered trajectory is the one a favor-the-shooter rewind actually judges. Foreign projectiles (other players’) render at server-present, aged forward automatically. An unmatched prediction expires after a TTL (max(2×rtt, 600ms) by default) and onReject fires: the ghost reverts.
For fast projectiles, hit-test the swept segment between last and current position, not a point. Otherwise a frame-step can tunnel through a thin target.
Optimistic hit FX: defineEvent
Impact feedback (hit marker, sound, decal) shouldn’t wait for the server, but a mispredicted hit must clean up after itself. Key the channel by projectile so multiple in-flight shots settle independently:
const impacts = predict.defineEvent<{ projectileId: string; x: number; y: number }>({
uniqueBy: (h) => h.projectileId, // payload carries FX data; identity is the projectile
onPredict: (h) => spawnImpactFx(h.x, h.y),
onReject: (h) => removeImpactFx(h.projectileId),
});
// inside the step, on a predicted impact:
if (hit) ctx.predict(impacts, { projectileId: id, x: hit.x, y: hit.y });
// settlement:
room.onMessage("impact", (m) => impacts.confirm(m.projectileId));A sim-born prediction auto-rejects once the server processes past its birth step without confirming. See Optimistic events.
Optimistic pickups & breakables: confirmOn
Kills, pickups, and breakables share one shape. Predict a flag keyed by entity id, derive the visual from has(id) OR’d with authoritative state, and confirm when the entity’s field flips. You declare the settlement; the channel does the wiring:
const pickups = predict.defineEvent<string>({
confirmOn: { collection: "items", field: "alive", equals: false },
// a remote player's pickup (nothing pending): play the FX we skipped
onUnpredicted: (id) => {
const item = state.items.get(String(id));
if (item) burstAt(item.x, item.y);
},
});
// inside the step, on predicted contact:
if (touching) ctx.predict(pickups, itemId);
// render: hidden the instant WE predict it, or when the server says so:
const hidden = !item.alive || pickups.has(id);A mispredict needs no cleanup code: the entry auto-rejects once the server processes past it silently, has(id) flips back, the derived view recovers. See confirmOn for the add/remove membership variants (predicted drops and consumables).
confirmOn is JavaScript-only. Ports get the same behavior with two lines: ctx.predict(pickups, itemId) in the step, and confirm(itemId) from a schema callback watching alive. The has(id) derive works everywhere except the GDScript binding.
Dead-reckoned AI (server-driven)
Some entities move predictably but nobody controls them directly (patrolling AI, ballistics). For these, pair the client’s reckon display with a reckon rewind timeline (both sides describe the same decision):
predict.attachAll("enemies", { mode: "reckon", fields: ["x", "y"], step: stepEnemy });The server rewinds on the matching timeline (no double compensation):
this.rewind.attachAll(this.state.enemies, { fields: ["x", "y"], mode: "reckon" });For discrete movers (teleporters), keep snaps sharp under rewind with interpolate:
this.rewind.attachAll(this.state.enemies, {
fields: ["x", "y"],
interpolate: (e) => (e.kind === "teleporter" ? "step" : "linear"),
});Teleports & respawns: snap
Smoothing turns a respawn into a glide across the map. The snap option (on any attach or reconciler config) declares a value-space threshold past which a jump is a teleport. Smoothing resets and the entity pops to the new value:
// Remotes: a passive attach pops past the threshold.
predict.attachAll("players", { mode: "lerp", fields: ["x", "y"], snap: 300 });
// Your own entity: a reconcile correction past it pops instead of gliding
// (and doesn't count toward divergence telemetry).
const me = predict.reconciler(self, { input, step, snap: 300 });Size it well above per-patch motion (maxSpeed × patchInterval) and below your smallest legitimate teleport. Respawns that land on the same position induce zero correction and never trip it.
Cooldowns & timers: pick the right clock
Three kinds of “wait”, three homes:
- Self-imposed rate limits (fire rate, ability spam guard the client enforces on itself):
room.clock.now(), local monotonic:now() - lastFired >= COOLDOWN_MS. No clock sync involved, so no jitter. - Server-stamped windows (invulnerable-until, respawn-at, round-end): compare against
room.clock.serverNow(); the server wrote the absolute deadline. - Cooldowns that gate the simulation itself (a dash the step must refuse during cooldown): make the cooldown a reconciled field ticked inside
step(dashCooldown--per tick, infields). It predicts, replays, and self-corrects like any other simulated state; a wall-clock gate insidestepwould break replay.
Physics-engine prediction (predict.sim + Rapier)
When the local simulation is an opaque engine, bridge it into predict.sim’s step: the engine handle is your world. On the server, consume one input per tick with next() and step the world once:
// server
step(ctx) {
for (const [sid, body] of this.bodies) {
const cmd = this.inputs.get(sid).next();
if (cmd) applyInput(body, cmd);
}
this.world.step(ctx.dt);
}// client: same applyInput, same dt
const me = predict.sim({
input,
world: { world, body },
step: (ctx, w, cmd) => { applyInput(w.body, cmd); w.world.step(ctx.dt); },
adopt: (w) => { w.body.setTranslation({ x: self.x, y: self.y }, true);
w.body.setLinvel({ x: self.vx, y: self.vy }, true); },
pose: (w) => { const t = w.body.translation(); return { x: t.x, y: t.y }; },
});The opaque-engine face (adopt + pose) is JavaScript-only. See the sim note. On the other SDKs, keep engine state in bound schema mirrors. See Composite scalars below.
Reseed every scalar the sim depends on in adopt (position and velocity). Engine-internal non-scalar state isn’t rolled back. See the warning on predict.sim.
Composite scalars (predict.sim, no engine)
When an input pushes more than your own entity (a paddle striking a puck), put the decoded schema instances in the world. They auto-bind: no adopt or pose callbacks, and the bound entities render through the ordinary predict.value(instance, field) read. See Composite & engine state.
Hand-rolled simulation (no physics engine)
You don’t need an engine. The simplest way to satisfy the determinism contract is plain math. applyInput integrates velocity and gravity by hand, shared verbatim between client and server (no engine build to keep in sync).
Next
→ Determinism & The Contract: the rules these patterns rely on, and the troubleshooting table.
→ Client Prediction: the full API reference behind every recipe here.