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.
  • undefined comes 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: 12 keeps 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 missworstshots that hit
without rewind, matter2d18.7524.000 of 8
with rewind, matter2d0.000.008 of 8
without rewind, rapier3d18.0018.000 of 8
with rewind, rapier3d0.000.008 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.