Debugging corrections
Inspect corrections when movement snaps or rubber-bands. A correction identifies the fields where the server overruled the client; the symptoms below help locate the cause.
Read the corrections before you guess
// main.ts
room.on('correct', (c) => {
console.log(c.collection, c.id, c.fields, c.previous, c.patch, c.snapped, c.replayed);
}); fields names what the server overrode, previous is what this client had, patch is what the
server said, replayed counts the local writes re-applied on top, and snapped means nothing was
replayed at all.
Not every correction is a disagreement. Correction carries two flags that say which kind you have.
| Kind | Flags | What it means |
|---|---|---|
| Simulation | simulation: true | A body field this client does not predict. Server-authoritative body state arriving once per tick, by design |
| Confirmation | suppressed: true | Predicted, and every value matched within epsilon. Authority still applies |
| Misprediction | both false | A real disagreement. This is the one to debug |
Filter on the third kind. A healthy physics room produces a great many of the first two.
The player’s own thing rubber-bands
Their character, cursor or card jumps back after they move it. Likely causes, most common first.
1. validate is refusing legal play. Check: run the honest bots and count what they draw.
npx irtio dev
npx irtio simulate --bots 5 --seconds 20 Honest bots should draw zero corrections. Any at all means the rule is refusing values a real player
produces. The correction-storm line names the peak rate, and the frame trace names the fields.
2. The rule is a speed limit on something that legitimately jumps. A cursor teleports when the
pointer moves fast or leaves the window and comes back. Check: previous and patch differ by the
size of one real pointer move, and the corrections cluster on fast input. Clamp instead of limiting
the step. See anti-cheat.
3. The room writes the same field the client writes. A server write to a client-owned instance
wins, and the owner receives a correction. Check: does tick, an alarm or an RPC handler touch that
collection? If the server is deciding the value, make the collection serverOwned and take the
client’s write out entirely.
4. The field never converges because the type is too narrow. A str(24) over 24 UTF-8 bytes, or
a number outside a u8, is refused with E_WRITE_REJECTED rather than truncated. Check: the
rejections land as errors, not silent corrections.
5. In a physics room, the lead is guessing further ahead than the link needs. room.prediction.stats.stampGap should sit at 0 or 1. Larger means every release of a key shows up
as a yank.
Everyone else jitters, teleports or moves in bursts
1. You are drawing from room.state. room.state is the authoritative head and updates when a
frame arrives, so a draw loop reading it moves at the mercy of delivery timing. room.render has the
same shapes and draws non-owned entities at now - interpDelayMs, so motion comes out at the frame
rate. One substitution in the draw loop is the whole fix:
// main.ts
for (const [id, player] of room.render.players) draw(id, player); 2. interpDelayMs is too low for the connection. It defaults to twice the room’s tick interval,
floored at 50 ms. Past the newest delta the value holds where it is; there is no extrapolation, so a
buffer shorter than the gaps between arrivals shows as a stall then a jump. Raise it in joinRoom.
3. A spatial collection is churning. With visibility: 'spatial-grid' there is no hysteresis, so
a record sitting on a cell boundary while the viewer jitters enters and leaves on every crossing, and
each entry costs a full record. Check: run irtio dev --profile and read the churn row. Lower radius first, then raise cell. See visibility.
A predicted body walks through things
Check room.prediction.stats.absent: bodies beyond maxProxyBodies, or in a collection with proxy: false, have no local collider. Raise the proxy budget or reduce decorative bodies.
Bodies beyond maxPredictedBodies still get kinematic proxies. They support collisions, but a local push moves them only after the server updates their position. Mark pushable collections predicted: true and budget enough predicted bodies for them.
Objects snap instead of easing
A snap is a correction the client could not replay. It has two causes and they are separate numbers.
The correction outran the resimulation window. For ordinary writes that window is the last 20
flushed writes; for a predicted body it is a lead of 20 ticks, about 1.3 seconds of round trip at
30 Hz. Past it, everything the correction names snaps to the server’s values and nothing is replayed.
Check snapped on the correction, room.prediction.stats.snaps, and room.rtt. Zero snaps is the
number you want at playable latency.
The offset was larger than smoothingSnapUnits. A rebase normally moves the jump into a per-body
offset that decays with a 70 ms half life, so the body arrives at the truth a moment later instead of
stepping. Past smoothingSnapUnits, four world units by default, the offset is dropped and the body
appears where it is. That is deliberate for a respawn or an area change. If your world units are
large, raise it. room.prediction.stats.smoothing should rise on a correction and fall back; one that
stays up means corrections are arriving faster than they can be absorbed.
Do not add your own rate limiter to the drawn position. It cannot tell a body that ran from a body that was corrected, so it lags real movement too.
Everything resyncs at once
A --strategy migrate deploy landed. Running rooms move onto the new version now. A code-only or
additive change gives connected clients a short gap and a resync on the same socket, and the resync
discards pending predicted writes because they were made against state the room no longer holds. A
breaking change disconnects with E_SCHEMA_MISMATCH. See deploying a room.
The client was dropped as a slow consumer. E_SLOW_CONSUMER means the client was not draining
its stream and the server dropped it rather than growing an unbounded queue. It reconnects and gets a
fresh snapshot. If it recurs, the room produces more per tick than the connection carries. See room capacity.
Reproduce it without a player
Inject the connection you cannot reproduce by hand:
npx irtio simulate --bots 4 --conditions '{"rttMs":200,"jitterMs":20,"loss":0.02}' The same seed injects the same delays, so a run repeats exactly. The summary line carries corrections,
misprediction mean and max in world units, snaps, and convergence. Turn any of them into a gate with --misprediction-max, --snaps-max or --corrections-max.
For a tighter loop, the in-process harness runs the same clients on a fake clock:
// irtio/room.test.ts
const t = await testRoom(room, { latency: { rttMs: 80, jitterMs: 20 } });
const [a] = await t.join(2, { role: 'player' });
await t.run(2000);
expect(t).toStayWithinPrediction({ maxMagnitude: 5, maxSnaps: 0 }); t.predictionStats() gives per-client corrections, misprediction magnitude, snaps and replayed
writes. See simulated players.
Every run also writes a frame trace. When a number goes red, find the offending frame in the report and read the frames around its timestamp. The interesting part is almost never the frame itself. It is what the client sent in the fifty milliseconds before it.
Related
- Client prediction for the rebase loop and every
statsfield. - Server authority and validation for what sends a correction.