Debugging corrections

A correction is the server overruling a client. It arrives as its own frame, overwrites the fields the server changed, and the client replays any local writes newer than the one the server judged. When a player says the game feels wrong, the correction stream is where you look first.

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.

KindFlagsWhat it means
Simulationsimulation: trueA body field this client does not predict. Server-authoritative body state arriving once per tick, by design
Confirmationsuppressed: truePredicted, and every value matched within epsilon. Authority still applies
Mispredictionboth falseA 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

The body is on screen. It just does not collide.

1. The instance is over the prediction cap. A client simulates at most 64 non-owned predicted bodies. Over-cap instances are absent from the local world rather than approximated in it. They still render from interpolated authoritative state, so nothing looks missing, but a predicted body has nothing to collide with and passes straight through until the next correction. Check room.prediction.stats.overCap and the console warning the client prints each time the count reaches a new high.

Raise maxPredictedBodies, or order your collections so the cutoff lands on decoration. Which instances fall over the edge is deterministic (collection order, then insertion order) and is not chosen by relevance, so put players and terrain first.

2. Only one of the two collections is predicted. The local world holds predicted bodies and static geometry, and nothing else. If a player shoves a crate, both have to be predicted or both have to interpolate. Set predicted: true on the crate collection, with a cap above their combined count.

3. It is another player’s body. Contact with a body whose input you cannot know mispredicts either way. The correction is bounded and smoothed rather than a teleport, but no setting removes it.

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.

What does not exist

  • There is no lag compensation and no server-side rewind. A write is judged at the tick it arrives, and no setting changes that.
  • Prediction is not rollback netcode. The client re-steps predicted bodies. It does not roll the room back and replay it.

Next steps