Determinism & The Contract
Client prediction only matches the server if both run the same simulation. None of the rules below is hard to follow, but each is easy to break by accident. The resulting bugs look mysterious: rubber-banding, re-firing sounds, hits that don’t land. This page collects the rules and the symptom each break produces. Read it alongside Client Prediction and Server Input.
Available in Colyseus 0.18+.
The contract
These are load-bearing. Break one and prediction drifts or hits stop registering:
-
One
input.send()per fixed step.predict.tick()returns how many fixed steps are due this frame; stage and send exactly that many inputs. Everysend()transmits one input (a body-less frame when nothing changed), so the predicted set equals the server-applied set. Don’t send per render frame, and don’t skip sends while predicting movement. -
Everything the step reads must live on the input schema.
stepreceives the buffered wire input (input.at(seq)), the exact value the server decodes, on the live step and on every replay. State you read from outside the input (a captured variable, a DOM read) won’t replay and diverges silently. -
Send before you read. Within a frame:
predict.tick()→ the send loop → render reads. Apredict.value()read betweentick()and the sends renders one step stale (the SDK warns once). -
One-shot effects never go raw inside
step. A step replays 0..N times during rollback. Usectx.predictfor events (a sound, a celebration): they fire on the live step only. Usectx.memofor values replay can’t re-derive (an RNG roll, a lag-comp’d collision). For fire-and-forget presentation (particles, camera shake), a plainif (!ctx.isReplay)branch is itself the idiom. -
Inputs must be flat primitives: no nested schemas or collections in an input.
-
Input rate = fixed-step rate = server tick. Change one, change all. Lowering
tickRateto save bandwidth therefore also lowers the simulation rate, unless you sub-step: physics can run attickRate × subStepswhile the wire stays attickRate. -
Lag comp rewinds to the acting client’s render time, not the server’s now.
rewind.lastSeenBy(shooterId)gets the direction right for you.
Two more facts ride the same ack channel, but they’re bookkeeping the framework does, not rules you can break. lastProcessed is the server’s consumed count, echoed via the acks: reconcile triggers when it advances. There is no seq field to manage: in "unreliable" mode, the framework’s own sequence plus the redundant input history make drops harmless. And room.clock syncs through those same acks, which is why it requires defineInput(). Without it the clock is a stub: serverNow() falls back to a local monotonic clock, and rtt / lastServerTime are 0.
Determinism
Prediction matches the server only if both run the same simulation:
- One shared
step/applyInputfunction: a single source of truth, imported by both client and server. Don’t maintain two copies. - Identical fixed
dton both sides:setFixedTimestepadvertises it; the clientreconciler/simread it back throughctx.dt/input.stepSeconds. Never hand-write1/60on the client. - Matching engine versions for physics, e.g. the same Rapier build on client and server. Different builds produce different floating-point results and diverge.
Divergence shows up as constant small corrections (rubber-banding) even on a LAN. Jitter shows up as occasional corrections that scale with packet loss: that’s the network, not your simulation.
Diagnosing divergence vs jitter
You don’t have to guess from feel. Every reconciler/sim controller keeps rolling drift telemetry (fed once per reconcile, not per frame) that separates the two:
me.drift.ema: EMA of the correction magnitude: the persistent component. Steadily above ~0 means the two simulations genuinely disagree: divergence.me.drift.peak: a decaying max: recent spikes. A highpeakover a near-zeroemais network jitter (loss / reorder), not a bug; smoothing absorbs it.me.lastCorrectionMag/me.lastCorrection: the latest reconcile’s max |correction| and its per-field breakdown.
Set warnOnDivergence (a tolerance in world units) on the controller to get a console warning when the persistent component crosses it. Telemetry runs while watched (this option set, or the dev panel active):
const me = predict.reconciler(self, { input, step, warnOnDivergence: 0.05 });The dev panel (import "@colyseus/sdk/debug") classifies through the same rule and shows a per-controller verdict: ✓ matched (both ~0), ~ jitter (spikes that decay out), ✗ diverging (persistent ema). The panel and the warning can therefore never disagree about what the numbers mean. me.reset() zeroes the telemetry, and a snap-absorbed correction is excluded from it, so a deliberate pose jump doesn’t read as divergence.
A determinism check
Suspect your step itself? Test it in isolation: run the same input sequence through it twice from the same starting state and assert the outputs are bit-identical:
function isDeterministic(start, inputs, dt) {
const a = clone(start), b = clone(start);
for (const cmd of inputs) applyInput(a, cmd, dt);
for (const cmd of inputs) applyInput(b, cmd, dt);
return JSON.stringify(a) === JSON.stringify(b); // must be true
}If this fails, the culprit is inside step: Math.random(), Date.now(), iteration order over an unordered map, or reading mutable shared state. (When the non-derivable value is intentional, such as an RNG roll or a timestamp, freeze it with ctx.memo so replay reuses it.)
Troubleshooting
| Symptom | Likely cause |
|---|---|
| Local player rubber-bands constantly | Non-determinism: different dt, divergent step, or engine-version mismatch. Confirm with drift telemetry: persistent me.drift.ema ⇒ diverging. |
| Local player renders one step behind, warns once | Render values read before the frame’s send loop. Reorder to tick → send → read. |
| Sounds/FX re-fire on corrections | Raw side effects inside step. Declare them with ctx.predict (events) or ctx.memo (values). |
| Remotes stutter / teleport | Interp delay too small (buffer underruns); raise it past 1–2 patch intervals, or check the patch rate. |
| ”I hit them but no damage” on moving targets | Not rewinding (or rewinding to server-now). Use rewind.lastSeenBy(shooterId). |
| Hits register behind a dead-reckoned target | The target renders forward-reckoned but rewinds on the snapshot timeline (double compensation). Attach it with mode: "reckon". See snapshot vs reckon. |
| Hits land slightly ahead of the crosshair | maxRewindMs too small: it truncates the real rewind (renderDelay + RTT + a tick); raise it. |
rewind.lastSeenBy throws | The room never called defineInput() (the stamps ride the input channel); or use rewind.at(time) with your own time. |
room.clock returns local time | The room never called defineInput() (the clock rides input acks). |
| Bullet overshoots / tunnels past a target | Point hit test on a fast projectile. Use a swept (segment) test. See Recipes. |
Next
→ Recipes: task-oriented patterns built on these APIs.