Lag compensation
Your player fires at a target sprinting across the screen. The shot leaves their machine, spends half a round trip in the air, and lands on a server whose world has moved on: at 200 ms and a target crossing six units a tick, the server is looking at a target eighteen units from where the shooter aimed. The shot is a clean miss, and the player is right to be annoyed, because on their screen it was a hit.
Lag compensation is the server agreeing to answer for the past. irtio gives you two pieces: a
bounded history of where every body was, tick by tick, and room.rewind(tick, fn), which runs your
own query against a world posed from one of those ticks.
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 is what your room did
before this page existed.
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) {
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, 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.
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.
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.
- 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:
const gap = ctx.tick - (ctx.clientTick ?? ctx.tick);
if (gap > 12 || gap < 0) return { hit: false }; // not a round trip this room believes in A competitive game wants that check and a co-operative one probably does not. irtio does not make it for you, because how much of the past a game is willing to answer for is a design decision, not a platform one.
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.
What this is not
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 all three engines 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.