Lag compensation
Lag compensation checks a shot against recorded body poses. Enable pose history in your physics configuration, then call room.rewind(tick, fn) to run the hit query at a past tick.
Turning it on
It is off until you ask, because it costs heap on every tick. Declare a depth, in ticks, on the physics config:
physics: {
engine: 'matter2d',
gravity: { x: 0, y: 1 },
history: 12,
bodies: { … },
} How deep: the round trip you want to cover, in ticks, plus a tick of slack. A 20 Hz room covering
300 ms wants history: 8; the 12 above covers 600 ms. The ceiling is 240 ticks and defineRoom refuses more by name and by number.
What it costs is arithmetic you can do before you declare it: depth times bodies times about 120 bytes, which is thirteen doubles of pose and velocity plus two string references. A room with a hundred bodies at depth 30 holds about 350 KB. Capturing one tick of a hundred bodies measured 0.015 ms on matter2d and 0.057 ms on rapier3d. A room that declares nothing builds no buffer and never touches its bodies for poses, so the whole feature costs it nothing.
Which tick the shooter was looking at
Every CALL a client sends carries the newest authoritative tick that client had applied when it
sent the call, and the room reads it as ctx.clientTick:
rpc: {
fire(state, { x, y }, ctx) {
const at = ctx.clientTick ?? ctx.tick;
…
},
} ctx.clientTick is undefined for a join, for a write, and for a client that sends no stamp, so ?? ctx.tick is the fallback to write: it means “judge against now”, which uses the current tick.
There is a residual of at most one tick in that number, and it is worth knowing about rather than being surprised by. A target that is not predicted locally is drawn through the interpolation buffer, a fraction of a tick behind the newest state the client holds, so what was on the screen sits between the stamped tick and the one before it. In practice this is smaller than a hit box; if it matters to your game, a target moving faster than its own radius in one tick is the case to measure.
room.rewind(tick, fn)
rpc: {
fire(state, { x, y }, ctx) {
const M = ctx.room.physics2d.matter;
return ctx.room.rewind(ctx.clientTick ?? ctx.tick, (past) => {
const hit = M.Query.point([...(past.matter?.bodies ?? [])], { x, y })
.map((body) => past.matter?.who(body))
.find((who) => who !== undefined);
if (hit) state.players.get(hit.id).hp -= 10;
return { hit: hit !== undefined };
});
},
} and the same shot on rapier3d:
rpc: {
fire(state, { dx, dy, dz }, ctx) {
// The RAPIER namespace (Ray, shapes, filters) lives on the live room.physics,
// not on `past` — `past.rapier.world` is only the rewound world to query.
const { rapier } = ctx.room.physics;
const from = ctx.room.physics.body('players', ctx.clientId)?.translation();
if (!from) return { hit: false };
return ctx.room.rewind(ctx.clientTick ?? ctx.tick, (past) => {
const ray = new rapier.Ray(from, { x: dx, y: dy, z: dz });
const found = past.rapier?.world.castRay(ray, 100, true);
const who = found ? past.rapier?.who(found.collider) : undefined;
return { hit: who !== undefined };
});
},
} fn runs synchronously and whatever it returns is returned. Inside it you get the engine’s own
query surface: a World to cast rays and shapes against on rapier3d and rapier2d, an array of
bodies for Matter.Query.ray, point, region and collides on matter2d. irtio wraps no query
and invents no hit type; the engine you chose is the engine you shoot with.
A room running your own stepper gets past.custom instead: pose(collection, id) and each(fn) over the stored poses, with no scratch world, because your static world is already
in hand inside fn and irtio does not know what one of your queries looks like.
past.who() maps whatever the query hit back to the collection and id your game cares about. It
answers undefined for the world’s static geometry, which is how you tell a wall from a player.
Channels: pose data beyond the body
A pose is a position and a rotation. A hitbox that changes shape with an animation needs more than that: whether the player was crouching on that tick, where in a run cycle they were, a per-bone parameter. Declare those as channels and they are captured on the same ring, at the same tick, and read back the same way.
physics: {
engine: 'matter2d',
gravity: { x: 0, y: 1 },
history: {
depth: 8,
channels: {
players: {
count: 2,
read(player: { crouch: number; runPhase: number }, out) {
out[0] = player.crouch;
out[1] = player.runPhase;
},
},
},
},
bodies, // the same body factories the room already declares
} Type read’s first parameter yourself, as above; it arrives as a plain record of the collection’s
fields. read runs once per tracked body per tick, with that instance and a pre-zeroed array of count floats. A third argument carries the instance’s id: a hook that derives channels from
state kept outside the schema (an animation state machine, say) keys that state by the id. Fill the slots you declared. Inside rewind, past.channels hands them back:
ctx.room.rewind(ctx.clientTick ?? ctx.tick, (past) => {
const then = past.channels('players', targetId);
const crouched = then !== undefined && then[0] > 0.5;
// pick the capsule for the crouched or standing shape, then cast as usual
}); The limits:
- Eight channels per collection at most. A game that wants a whole skeleton per tick should keep its own ring rather than push this one.
- Values are f32, so they carry about seven significant digits. Animation parameters do not need more; positions you want exactly are already in the pose.
- The array is a view into the ring. Read it inside
fn. Do not hold it past the call and do not write to it. undefinedcomes back when the collection declared no channels, or when that instance was not present at the answered tick.- The extra cost is channels times depth times bodies times 4 bytes. Four channels at depth 30
over 100 bodies is 48 KB, on top of the pose cost. A room that declares no channels pays none of
it, and
history: 12keeps its exact old meaning.
What a rewound query can and cannot see
Can see
- Every body the room tracks, at the pose and velocity it had on that tick.
- The world’s static geometry, as it stands now.
Cannot see
- Colliders as they were then. Shapes are not historied, only poses, so a body whose collider was
swapped is queried with its current shape at its old position. The current shape is live: moving,
resizing, swapping or removing a collider rebuilds the rewound body’s copy on the next
rewind. For hitboxes that must match the rewound tick, record the stance in channels and pick the shape in your query. - Joints, constraints and contacts. There is no solver here; nothing is re-simulated.
- Bodies created after that tick. They were not on the shooter’s screen, so they cannot be hit, which is the behaviour you want, and it is enforced rather than left to your query.
- Bodies removed before the room’s very first
rewind. The scratch world is built the first time you call it, and a body that was already gone by then has no double to stand in for it. A room that rewinds on every shot never meets this; a room that rewinds once, an hour in, might. - Anything that happened after the tick you asked for, and anything the caller had not been told about yet.
The live world is never touched. Nothing is stepped that a player can see, no body moves, and a room that rewinds every tick simulates identically to one that never does.
Clamping is not an error
The buffer is a few hundred milliseconds deep, and a stamp can fall outside it: a client that was
disconnected, a room that just woke, a client that is simply wrong. rewind answers from the
nearest tick it does hold, and says so:
ctx.room.rewind(ctx.clientTick ?? ctx.tick, (past) => {
if (past.clamped) {
ctx.room.log(`shot stamped ${past.requested}, answered from ${past.tick}`);
}
…
}); past.tick is the tick actually answered from, past.requested is what was asked for. The oldest
pose the room still holds is the honest answer to “further back than I remember”, so a clamped
rewind is a fallback rather than a failure. A room that would rather refuse one can read clamped and do so.
rewind throws in three cases, all of them programming errors rather than runtime conditions: the
room declares no physics.history, the room has not ticked yet since it woke, and a rewind called from inside another rewind.
The trust boundary
The stamp is a number the client chose. A client that lies about it is choosing which past the server answers from, and that is worth being deliberate about.
What bounds the damage without you doing anything: the buffer is only as deep as you declared, so
a stamp further back than that clamps to the oldest tick you kept. The buffer only ever answers for
a tick it actually recorded. A stamp ahead of the server’s own tick clamps to now, which is the
un-compensated answer. So the worst a lie can buy is “judge my shot against a world up to history ticks old”, and you chose that number.
What is yours to decide: whether a gap you did not expect is acceptable. ctx.tick is the server’s
own tick and ctx.clientTick is what the client claimed, so the gap is right there. Prefer letting rewind clamp and reading past.clamped over refusing on the gap yourself:
return ctx.room.rewind(ctx.clientTick ?? ctx.tick, (past) => {
// past.clamped means the stamp fell outside the buffer and the oldest kept tick answered.
// Judge the shot anyway, or log past.requested when clamping is worth watching.
// The ray cast goes here, unchanged from the examples above.
return { hit: false, clamped: past.clamped };
}); A large gap is usually honest. The stamp advances when the client applies a delta, and a quiet
room sends none, so between actions it is kept fresh only by the connection’s ping, every 2
seconds. The first shot after a lull can honestly claim a stamp a full ping interval old, which
at 60 ticks a second is a gap of 120. A guard like if (gap > history) return { hit: false } refuses exactly that shot, silently. If you refuse at all, refuse only gap < 0 (a stamp from
the future is always a lie); leave the rest to the clamp, which already bounds a lie’s value at
“a world history ticks old”, and you chose that number.
Measuring it
irtio simulate’s hit-registration report is the measurement, and it works on your own room. Every
row names the tick the shot was judged at and how far the aim was from where authority had the
target at that tick. A room that reports its own judging tick gets exact rows; a room that does not
gets estimated ones, marked. See invariants and load runs.
The platform’s own numbers, from a target moving six units a tick in a 10 Hz room with a bot at 200 ms of round trip, eight shots each way:
| mean miss | worst | shots that hit | |
|---|---|---|---|
without rewind, matter2d | 18.75 | 24.00 | 0 of 8 |
with rewind, matter2d | 0.00 | 0.00 | 8 of 8 |
without rewind, rapier3d | 18.00 | 18.00 | 0 of 8 |
with rewind, rapier3d | 0.00 | 0.00 | 8 of 8 |
One rewind with a query, over a hundred bodies, measured 0.074 ms on matter2d and 0.049 ms on
rapier3d.
Usage notes
It is not a physics rewind. Nothing is re-simulated and no past step is replayed; it is stored poses and your own queries against them. That is why it works on every engine, your own included, and why it costs what it costs.
It is also not a way to undo the past. A rewound query tells you what would have been hit; what the room then does about it happens now, on the live world, and every other player sees it in the same tick they see everything else.