NetcodeDeterminism & The Contract

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:

  1. One input.send() per fixed step. predict.tick() returns how many fixed steps are due this frame; stage and send exactly that many inputs. Every send() 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.

  2. Everything the step reads must live on the input schema. step receives 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.

  3. Send before you read. Within a frame: predict.tick() → the send loop → render reads. A predict.value() read between tick() and the sends renders one step stale (the SDK warns once).

  4. One-shot effects never go raw inside step. A step replays 0..N times during rollback. Use ctx.predict for events (a sound, a celebration): they fire on the live step only. Use ctx.memo for values replay can’t re-derive (an RNG roll, a lag-comp’d collision). For fire-and-forget presentation (particles, camera shake), a plain if (!ctx.isReplay) branch is itself the idiom.

  5. Inputs must be flat primitives: no nested schemas or collections in an input.

  6. Input rate = fixed-step rate = server tick. Change one, change all. Lowering tickRate to save bandwidth therefore also lowers the simulation rate, unless you sub-step: physics can run at tickRate × subSteps while the wire stays at tickRate.

  7. 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 / applyInput function: a single source of truth, imported by both client and server. Don’t maintain two copies.
  • Identical fixed dt on both sides: setFixedTimestep advertises it; the client reconciler / sim read it back through ctx.dt / input.stepSeconds. Never hand-write 1/60 on 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 high peak over a near-zero ema is 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

SymptomLikely cause
Local player rubber-bands constantlyNon-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 onceRender values read before the frame’s send loop. Reorder to tick → send → read.
Sounds/FX re-fire on correctionsRaw side effects inside step. Declare them with ctx.predict (events) or ctx.memo (values).
Remotes stutter / teleportInterp 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 targetsNot rewinding (or rewinding to server-now). Use rewind.lastSeenBy(shooterId).
Hits register behind a dead-reckoned targetThe 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 crosshairmaxRewindMs too small: it truncates the real rewind (renderDelay + RTT + a tick); raise it.
rewind.lastSeenBy throwsThe room never called defineInput() (the stamps ride the input channel); or use rewind.at(time) with your own time.
room.clock returns local timeThe room never called defineInput() (the clock rides input acks).
Bullet overshoots / tunnels past a targetPoint hit test on a fast projectile. Use a swept (segment) test. See Recipes.

Next

Recipes: task-oriented patterns built on these APIs.