Client Prediction & Reconciliation
This page is the client half of the netcode stack. Send input, predict your own entity, reconcile to the server’s authoritative state, and smooth the entities you don’t control. The matching server half lives in Server Input & Timestep.
Available in Colyseus 0.18+ across the official SDKs. Pick your SDK inside each example below. The GameMaker and C ports expose the same surface. Building with React? @colyseus/react wraps these APIs as netcode hooks.
At a glance
import { Predict } from "@colyseus/sdk";
import { applyInput } from "./shared/applyInput"; // SAME function the server runs
const predict = Predict.get(room, { mode: "lerp", delay: 100 });
// Remote players: render 100ms in the past, interpolated between snapshots.
predict.attachAll("players", { mode: "lerp", fields: ["x", "y"] });
// Local player: the input handle is the ONLY thing that stages + sends.
const input = room.input({ type: MoveInput });
const me = predict.reconciler(self, {
input, // every scalar field of `self` mirrors by default
step: (ctx, s, cmd) => applyInput(s, cmd, LEVEL, ctx.dt),
smoothing: 15,
});
function frame(now) { // pass the rAF timestamp through
const n = predict.tick(now); // fixed input steps due this frame
for (let i = 0; i < n; i++) {
input.data.moveX = moveX(); // stage the wire input
input.data.jump = jump();
input.send(); // transmit + buffer, the reconciler observes
}
for (const [, p] of room.state.players) {
draw(predict.value(p, "x"), predict.value(p, "y")); // ONE read idiom, local + remote
}
requestAnimationFrame(frame);
}The reconciler is a pure observer: it watches the handle’s sends and predicts each one. You never feed input to the reconciler directly. The frame driver runs predict.tick(), sends that many inputs through the handle, and everything downstream reacts. For taps and edges that must land on exactly one step, see buffer, then consume.
Send before you read. Within a frame, run the send loop right after predict.tick(), then read render values. A predict.value() read between tick() and the sends is one step stale (the SDK warns once).
room.input(options)
Returns the per-room InputHandle: the single surface that stages and sends input. Created once: later calls return the same handle.
const input = room.input({
type: MoveInput, // input schema constructor (matches the server's defineInput)
});type: the input schema constructor. Optional when the server calleddefineInput(): the schema then arrives via the join handshake and is used automatically. An explicit constructor always wins.mode:"reliable"(default) or"unreliable". Over plain WebSocket, stay reliable. On a datagram-capable transport,{ mode: "unreliable", historySize: 4 }is the recommended rollback setup. Each packet carries the last N inputs as redundancy, so a drop costs nothing and nothing head-of-line blocks. That transport is WebTransport alone: experimental, and supported by the JavaScript/TypeScript SDK only. Other SDKs reject"unreliable"at construction for now.historySize: unreliable-mode redundancy depth (default 3). Ignored in reliable mode.renderDelay: your remote interp buffer in ms, used for the lag-comp stamp. Usually omit it: wiring the handle throughpredict.reconciler/predict.simbinds it to the Predict’s lerpdelayautomatically. The buffer you render at and the server’s rewind instant then come from one number.allowRewind: a predicate selecting which inputs carry the lag-comp timestamp. It’s a bandwidth trim: stamp only the inputs the server actually rewinds for, e.g.(d) => d.fire. Reliable mode only: an unreliable packet stamps its whole redundancy ring or none of it, so the predicate is ignored there. See Lag Compensation.
The GDScript handle accepts no options object: render_delay is a property, and input.set_rewind_field("fire") replaces allowRewind.
Stage fields on input.data, then input.send(), one call per fixed step:
input.data.moveX = 1;
input.data.jump = true;
input.send();Only declared schema fields ride the wire. Writing an undeclared field does nothing, and with the debug panel active the SDK warns you once per key at send().
The handle also surfaces the server-advertised rates from the handshake: input.tickRate, input.stepSeconds, input.stepMs, input.patchRate, input.subSteps. Predict at input.stepSeconds so your replay stays bit-identical with the server.
The server must advertise a fixed rate (via setFixedTimestep) for input.tickRate to be defined. A robust client asserts it:
if (input.tickRate === undefined) throw new Error("server did not advertise a fixed tick rate");The same check ports as input.TickRate == null (C#), input.tick_rate == nil (Lua), and input.tickRate == null (Haxe). The GDScript and Flutter bindings report 0 instead of null when the rate is unadvertised.
Predict.get(room, options)
The entry point for all client-side smoothing and prediction. Create one per room and share the instance. Each Predict.get call constructs a fresh, independent Predict:
const predict = Predict.get(room, { mode: "lerp", delay: 100 });The construction options object is JavaScript-only. Ports take only the room and declare mode/delay per attach. Use Predict.Get(room) in C#, Predict.get(room) in Lua, Haxe and Dart, and Colyseus.Predict.of(room) in GDScript.
Call predict.tick(now) once per render frame, passing the rAF timestamp. It returns the number of fixed input steps due this frame. It also drives everything it owns: reconcile on new acks, correction decay, remote smoothing, event settlement.
now is optional in every SDK. Omit it and the room clock is read for you, which is what the examples above do. That saves picking a platform clock that may not share the SDK’s epoch. Pass it in JavaScript anyway: the rAF timestamp is the exact frame time, on the same axis as the default.
Remote entities
For entities you don’t control, attach smoothing. attachAll covers every entry of a collection; attach a single instance:
predict.attachAll("players", { mode: "lerp", fields: ["x", "y"] });In C#, Lua, and Haxe the collection attaches wholesale. The reconciler’s overlay wins for your own entity. The GDScript and Dart bindings take an except-key to leave it out explicitly.
Read the smoothed value with predict.value(entity, field). Smoothing modes:
| Mode | Behaviour | Use for |
|---|---|---|
lerp | Interpolate snapshots at now − delay: smooth, lagged, faithful | Remote players |
extrapolate | Forecast forward: live, can overshoot | Low-latency-critical movers |
damped | Ease toward the latest value: never exact, never jittery | Cosmetic entities |
reckon | Dead-reckon forward via a step fn | Server-driven AI / ballistics |
raw | Unsmoothed (the live synced value) | Debugging |
Two per-attach options are worth knowing:
snap: a value-space threshold past which a jump is treated as a teleport: smoothing resets and the entity pops instead of gliding. Use it for respawns, blinks, warps.angle: treat the field as radians, interpolating over the shortest arc. Attach angular fields in a separate call from linear ones.
reconciler: your own entity (flat fields)
Active prediction for one entity you control, mirrored off the instance’s scalar fields:
const me = predict.reconciler(self, {
input,
step: (ctx, state, cmd) => applyInput(state, cmd, LEVEL, ctx.dt),
smoothing: 15,
});step(ctx, state, cmd): the deterministic step function, shared with the server.cmdis the buffered wire input recorded atsend()(the exact value the server decodes), so lossy wire fields (a quantized angle) reconcile by construction. Keep everything the step reads on the input schema.fields: optional, and usually best omitted. Every scalar field of the instance’s schema is then mirrored, which is the right default. Pass an explicit subset only to exclude scalars deliberately. With the dev panel active, the SDK warns once if the step touches a field the list excludes. Numeric fields get smooth error correction; non-numeric fields (e.g.grounded) are copied verbatim on reconcile.snap: teleport threshold in world units, the active-controller mirror of the per-attachsnap. A reconcile correction past it pops to the corrected pose instead of gliding across the map, and doesn’t count toward divergence telemetry.warnOnDivergence: drift tolerance in world units. The SDK warns in the console when the persistent reconcile-correction EMA crosses it: a determinism break, not jitter. The telemetry behind it (me.drift.ema/me.drift.peak) runs while watched (this option set, or the dev panel active). See Diagnosing divergence vs jitter. Not exposed in the GDScript and Flutter bindings: read the drift telemetry directly (me.drift_emain GDScript,me.drift.emain Dart).
One-shot effects inside step need care, because a step can replay several times during rollback. Three tools, split by what the one-shot is:
- A value replay can’t re-derive (an RNG roll, a collision outcome) → freeze it with
ctx.memo. - A discrete event that needs settlement (a goal, a kill, a pickup) → declare it with
ctx.predict. - Fire-and-forget presentation (a sound, particles, a timestamp) → a plain
if (!ctx.isReplay) { … }branch. The branch is the idiom; there is deliberately no wrapper API for it.
Lifecycle is automatic: on reconnect the SDK resets the input handle and observing controllers follow (they poll input.epoch), and snap absorbs respawn-sized jumps. Call me.reset() yourself only for discontinuities the library can’t see: a map swap, or in-flight inputs the app knows are void (a death).
Composite & engine state: predict.sim
When your inputs affect more than one flat-field entity (a paddle and the puck it strikes, or an opaque physics engine), use predict.sim. You own a world (any object) and the SDK runs the same predict → adopt-on-ack → replay loop over it.
Composite scalars need no callbacks: put the decoded schema instances themselves in world and they are auto-bound. The SDK replaces them in place with plain scalar mirrors that it seeds, re-adopts from server truth on every ack, and smooths:
const me = predict.sim({
input,
world: {
paddle: player, // decoded schema instance ⇒ auto-bound
puck: room.state.puck, // ⇒ auto-bound (x, y, vx, vy)
},
step: (ctx, w, cmd) => stepWorld(w, cmd, ctx.dt), // the SAME fn the server runs
smoothing: 15,
});
draw(predict.value(player, "x"), predict.value(room.state.puck, "x")); // one read idiom
aim(me.world.paddle); // raw predicted mirror for game logicstep mutates the mirrors, never the decoded tree. Bound entities render through the same predict.value(instance, field) read as remotes. One caveat: bound sources are pinned at construction. If the server replaces an instance (state.puck = new Puck()), recreate the controller.
For an opaque engine (Rapier), the world is the solver + body handle, and you supply the two callbacks auto-binding can’t derive:
const me = predict.sim({
input,
world: { world, body },
step: (ctx, w, cmd) => { applyInput(w.body, cmd); w.world.step(); }, // timestep = ctx.dt
adopt: (w) => { w.body.setTranslation({ x: self.x, y: self.y }, true); },
pose: (w) => { const t = w.body.translation(); return { x: t.x, y: t.y }; },
});The opaque-engine face (adopt + pose) is JavaScript-only. The ports run sim over auto-bound schema mirrors instead. The Flutter binding additionally accepts adopt for opaque extras, with poses still derived from bound parts.
adopt(world): seed the server’s authoritative scalars into the opaque entries on each ack, before replay. Runs after any bound entries’ auto-adopt, so it may derive from just-adopted mirrors.pose(world): read the opaque entries into a flat render pose; smoothing operates on these numbers in addition to the bound parts’ auto-derived"part.field"fields.
adopt reseeds scalars; replay re-derives the rest. Engine-internal non-scalar state (contact caches, sleeping islands, solver accumulators) is not rolled back across reconcile. Reseed every scalar your sim depends on (position and velocity).
Reading values
Three reads, split by purpose:
- Rendering:
predict.value(instance, field), the smoothed render read. One idiom for everything: passively-smoothed remotes, your reconciled entity, sim-bound instances. Falls back to the raw synced value before spawn / after dispose. The batch formpredict.read(instance, fields, out?)(JavaScript-only) reads every listed field into one object, the client mirror of the server’sseen.read. On per-frame paths, pass a reused scratch and hoist thefieldsarray; extra scratch properties are left untouched. - Game logic:
me.state(reconciler) /me.world(sim), the exact predicted state with no smoothing offset. A hit test against the smoothed render value would lag the truth by the correction offset. - A moment in time:
predict.valueAt(instance, field, time), a remote entity reckoned at an arbitrary server-time instant. Sample remotes atctx.reckonTimeinside your step and your client-side hit verdict matches the server’s lag-comp rewind by construction. See Lag Compensation. The batch formpredict.readAt(instance, fields, time, out?)(JavaScript-only) runs one forward reckon integration per entity instead of one per field.
Optimistic events: defineEvent
A predicted timeline produces discrete events (a goal, a kill, a pickup) that deserve instant feedback but need server settlement. A channel owns one event type end-to-end:
const goals = predict.defineEvent<Team>({
onPredict: (team) => { celebrate(team); hidePuck(); }, // fires immediately, ~RTT early
onReject: () => showPuck(), // the prediction was wrong: undo
});
// inside the reconciler step (fires on the LIVE step only, never on rollback replay):
if (crossedGoalLine) ctx.predict(goals, scoredBy);
// settlement (the authoritative broadcast):
room.onMessage("score", () => goals.confirm());Settlement is automatic where it can be. A sim-born prediction auto-rejects once the server has processed graceTicks (default 10) past its birth step without a confirm(). The server ran the very timeline that predicted the event, and stayed silent. UI-born predictions (goals.predict(payload) called outside the sim) can’t count ticks, so they fall back to a wall-clock TTL (ttlMs, default max(2×rtt, 600ms)).
Three more options:
uniqueBy: gives entries identity: dedupe while pending, and address one among several on confirm/reject. Available in C#, Lua, and Haxe; the GDScript and Flutter channels use explicit string keys instead.cooldownMs: rate-limits feedback channel-wide.onConfirm: available but usually empty; the optimistic feedback already played.
Flag-shaped consumers can skip callbacks entirely and derive from goals.has(key) each frame: has is available everywhere except the GDScript binding.
Confirming from state: confirmOn
confirmOn is JavaScript-only. In the other SDKs, wire the settlement manually: listen for the state change with your schema callbacks and call channel.confirm(key). The grace-tick auto-reject behaves identically.
A broadcast is one settlement signal; more often the signal is state itself: a crate’s alive flipping, a banana joining or leaving its collection. Declare that shape and the channel wires the schema listeners for you (and tears them down with the channel):
const breaks = predict.defineEvent<string>({
confirmOn: { collection: "crates", field: "alive", equals: false },
});
// inside the step:
if (smashed) ctx.predict(breaks, crateId);
// render (flag-shaped): derive, no callbacks needed
const broken = !crate.alive || breaks.has(crateId);When a child’s field becomes equals, the entry keyed by that child’s collection key confirms, so key your entries by collection key. Two membership variants complete the set:
confirmOn: { collection: "bananas", event: "remove" } // the removal confirms its key
confirmOn: { collection: "bananas", event: "add", mine: "owner" } // OUR arrival settles the pending dropA predicted spawn can’t know the key the server will assign, so add settles keyless. The channel holds a single anonymous pending entry. The mine property (a field name compared against your sessionId) keeps a remote player’s spawn from confirming yours. All three forms are plain data: no listener plumbing, nothing to unsubscribe. Signals that don’t fit (a broadcast, entry keys that aren’t collection keys) stay one manual confirm() line, as above.
The other player’s event: onUnpredicted
A confirm() for an event nobody predicted settles nothing: that’s a remote actor’s event arriving (their pickup, their goal). onUnpredicted(key) is that branch: play the feedback onPredict skipped.
const boxPickups = predict.defineEvent<string>({
confirmOn: { collection: "itemBoxes", field: "active", equals: false },
onPredict: () => { hideBox(); startRoulette(); }, // ours, instantly
onUnpredicted: (id) => pickupBurstAt(id), // theirs, on arrival
});onUnpredicted exists in the C#, Lua, Haxe, and Flutter ports (not GDScript). The confirmOn wiring shown here is JavaScript-only: ports call confirm() from their own state callbacks.
Branch on the callbacks: onConfirm means we predicted it, onUnpredicted means we didn’t. Never branch on a has() read racing a confirm: the entry is removed before onConfirm fires.
ctx.memo: freeze a value replay can’t re-derive
ctx.predict handles one-shot events; ctx.memo handles one-shot values the sim itself consumes: a lag-comp’d collision outcome, an RNG roll, a server-assigned id. compute runs exactly once, on the live step; every replay of that step gets the frozen value back:
const hit = ctx.memo(() => collide(state, ctx.reckonTime));
if (hit) state.vx = hit.vx; // re-applied identically on every replayCall memo on every step and let compute decide the value: return undefined for “nothing” (stored sparsely). Need more than one memo per step? Disambiguate with a key: ctx.memo("collide", …). The key-less form is one shared slot per step. Two calls landing on that slot (two key-less calls, or a repeated key) silently corrupt replay with one frozen value for both. With the dev panel active, the SDK warns on the collision. Prefer reconciled fields when the value is derivable by re-running the step: that replays and self-corrects for free. The key-less shared slot exists in TypeScript, C#, Lua, and Haxe; the Godot and Flutter bindings always take a key and freeze numbers (memo_vec/memoVec for small tuples).
room.clock
Your window into server time and connection latency: what time it is on the server, and how long your packets take to get there. It’s always safe to call: room.clock is never undefined and no method needs optional chaining or a fallback. Rooms that use input (defineInput()) get a real clock-sync estimator during the join handshake; other rooms keep a stub that reads from your local clock. Three timelines, split by what you compare against:
clock.now(): local monotonic ms. For self-imposed relative gates: a cooldown or fire-rate you started (now() - lastFired >= COOLDOWN_MS). Needs no clock sync, immune to offset jitter.clock.serverNow(): estimated server clock (ms since room start). For server-stamped absolute deadlines: invuln windows, respawn timers, hit stamps (anything the server wrote a timestamp for).clock.renderNow():serverNow()on a slew-limited render timeline (default on for drawing viaPredict). Strips the per-patch offset wobble so dead-reckoned remotes don’t jitter at speed. Never use it for hit stamps.
Latency accessors: clock.rtt(), clock.smoothedRtt() (preferred for forward-prediction), clock.jitter() (connection quality), clock.lastServerTime() (server time of the newest patch: serverNow() − lastServerTime() is the snapshot’s age).
Bringing your own sync algorithm? Swap it in right after joining: room.clock = new MyClock(). Implement renderNow() too: returning serverNow() is all it takes if your clock has no smoothing of its own.
InputHandle reference
Member names follow each SDK’s casing convention: PascalCase in C# (Send(), LastProcessed, TickRate), snake_case in Lua and GDScript (last_processed, tick_rate), camelCase in Haxe and Dart.
| Member | Role |
|---|---|
data | Mutable schema instance: stage fields, then send() |
send() | Encode and transmit one input (body-less frame when unchanged); returns its seq |
onSend(cb) | Synchronous post-send hook: how reconcilers observe the stream; returns an unsubscribe |
mode | The channel: "reliable" or "unreliable" |
reset() | Drop encoder + seq state and bump epoch. The SDK calls it on the reconnect path itself. Call it only for scene transitions the library can’t see |
epoch | Monotonic reset counter: observing reconcilers poll it and self-reset when it moves (compare with !==, never +1) |
at(seq) | The buffered input sent as seq, for replay (reused instance: read synchronously) |
reckonTimeAt(seq) | The raw reckon stamp on that seq (0 when unstamped). ctx.reckonTime is the resolved read |
lastProcessed | Last input seq the server acked processing (the canonical reconcile ack) |
sentCount | Inputs sent, the latest assigned seq |
pendingCount | In-flight (unacked) inputs = sentCount − lastProcessed |
replayBufferSize | Capacity of the at(seq) replay ring |
tickRate / stepSeconds / stepMs | Server-advertised fixed step |
patchRate | Server state-patch interval (ms), the correction cadence |
subSteps / subStepSeconds / subStepMs | Physics sub-steps per input tick |
The Godot and Flutter bindings do not expose at(seq), onSend, reckonTimeAt, mode, or replayBufferSize, because the native core owns the replay ring internally.
Dev tooling: import "@colyseus/sdk/debug" (a side-effect import, JavaScript SDK only) enables the in-page dev panel. The panel shows per-reconciler drift telemetry, profiles, and connection internals. It’s a no-op in production builds that skip the import.
Next
→ Lag Compensation: make the server judge your shots against what you saw.
→ Determinism & The Contract: the rules that keep prediction matching the server.