Rooms
Lag compensation
Check a shot against where the target was on the shooter's screen, by rewinding recent entity positions on the room.
A player aims at what their screen shows, and that is the past: other players are drawn with interpolation, a little behind the room’s clock, and the shot takes half a round trip to reach the room. By the time the room sees the shot, the target has moved on, so a check against where the target is now misses a shot that was on target.
Lag compensation fixes this on the room. Each input carries the tick of
the state the player was looking at, connection.viewTick. The room
records where the entities you choose were at each recent tick, and
room.rewind hands back those positions at the view tick to check the
shot against.
Recording a history
defineHistory(kind, fields) declares which fields of a kind the room
records, and the room’s history lists the declarations:
export const poses = defineHistory(Player, ["x", "y"]);
export default defineRoom({ schema, stepper, history: [poses] /* ... */ });
After each tick’s state update, the room copies those fields of every
entity of the kind into a ring of recent ticks. Only bool and number
fields can be recorded. Record what a hit check reads, usually the
position and maybe a rotation, and not the rest: each field costs memory
for every entity of the kind.
room.rewind(history, tick) returns the entities as that tick’s state
update sent them, as a ReadonlyMap from entity ID to a frozen object
holding id and the recorded fields:
- An entity created after
tick, or deleted at or before it, is absent. An ID that was deleted and created again gives whichever entity had it attick. - A tick older than the oldest the room kept is clamped to the oldest.
- A tick the room hasn’t recorded yet gives the entities as they are
now. In
onTickthat includesroom.tickitself, since the tick is recorded once its state update goes out. - A history the room’s
historydoesn’t list throws.
Checking a shot
The shot travels in the player’s input, so the room knows which tick the
player was looking at when they fired. The stepper moves players, and
onTick, which runs right after it, checks shots: connection.input is
the input applied this tick, after parseInput, and connection.viewTick
is the tick the player saw when sending it.
A stepper can’t rewind, and shouldn’t decide hits. It also runs on the
client to predict the local player, and a client keeps no history of the
room; a hit is the room’s call. Keep the shot in the input, ignore it in
the stepper, and handle it in onTick.
A top-down arena, where a shot is a ray from the shooter that hits the first other player it passes within half a unit of:
// shared.ts
import { defineHistory, defineSchema, defineStepper, entity } from "@irt/room";
export const Player = entity("player", { owner: "string", x: "f32", y: "f32", hp: "u8" });
export const schema = defineSchema({ entities: [Player] });
export const poses = defineHistory(Player, ["x", "y"]);
export interface Input {
dx: number;
dy: number;
/** The direction of a shot fired this tick. */
fire?: { x: number; y: number };
}
const SPEED = 8;
const finite = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
export const stepper = defineStepper<Input>({
parseInput(raw) {
const { dx, dy, fire } = raw as { dx?: unknown; dy?: unknown; fire?: { x?: unknown; y?: unknown } };
if (!finite(dx) || !finite(dy) || Math.hypot(dx, dy) > 1.0001) throw new TypeError("input is { dx, dy, fire? }");
if (fire === undefined) return { dx, dy };
if (!finite(fire.x) || !finite(fire.y) || (fire.x === 0 && fire.y === 0)) throw new TypeError("fire is a direction { x, y }");
return { dx, dy, fire: { x: fire.x, y: fire.y } };
},
step(world, inputs, dt) {
for (const p of world.all(Player)) {
const input = inputs.get(p.owner);
if (!input) continue;
p.x += input.dx * SPEED * dt;
p.y += input.dy * SPEED * dt;
}
},
});
// room.ts
import { defineRoom, type RoomEntity } from "@irt/room";
import { type Input, Player, poses, schema, stepper } from "./shared.js";
const RADIUS = 0.5;
const RANGE = 40;
const SPAWNS = [
{ x: -8, y: 0 },
{ x: 0, y: 10 },
];
type Point = { x: number; y: number };
/** How far along the shot it passes within RADIUS of `p`, or undefined if it doesn't. */
function along(from: Point, dir: Point, p: Point): number | undefined {
const len = Math.hypot(dir.x, dir.y);
const t = ((p.x - from.x) * dir.x + (p.y - from.y) * dir.y) / len;
if (t < 0 || t > RANGE) return undefined;
const miss = Math.hypot(from.x + (dir.x / len) * t - p.x, from.y + (dir.y / len) * t - p.y);
return miss <= RADIUS ? t : undefined;
}
export default defineRoom({
schema,
stepper,
history: [poses],
onJoin(room, conn) {
const spawn = SPAWNS[(room.connections.size - 1) % SPAWNS.length]!;
conn.data.player = [...room.entities.all(Player)].find((e) => e.owner === conn.player) ?? room.entities.create(Player, { owner: conn.player, ...spawn, hp: 100 });
},
onPlayerLeave(room, player) {
for (const e of room.entities.all(Player)) if (e.owner === player) room.entities.delete(e);
},
onTick(room) {
for (const conn of room.connections.values()) {
const fire = (conn.input as Input | undefined)?.fire;
const shooter: RoomEntity<typeof Player> = conn.data.player;
if (!fire) continue;
// The shot leaves from where the shooter is now, and hits others where the shooter saw them.
let nearest: { id: number; t: number } | undefined;
for (const target of room.rewind(poses, conn.viewTick).values()) {
if (target.id === shooter.id) continue;
const t = along(shooter, fire, target);
if (t !== undefined && (!nearest || t < nearest.t)) nearest = { id: target.id, t };
}
// The target may have left since the tick the shooter saw.
const hit = nearest && (room.entities.get(nearest.id) as RoomEntity<typeof Player> | undefined);
if (!hit) continue;
hit.hp = Math.max(0, hit.hp - 25);
room.broadcast({ type: "hit", shooter: conn.player, target: hit.owner });
}
},
});
The client draws other players with room.interpolate, which is what
the view tick describes, and fires toward a click:
// client.ts
import { connect } from "@irt/client";
import { type Input, Player, schema, stepper } from "./shared.js";
const room = await connect({ router: "wss://eu.irt.io", project: "arena", room: "pit-1", schema });
const UNIT = 32;
const keys = new Set<string>();
addEventListener("keydown", (e) => keys.add(e.key));
addEventListener("keyup", (e) => keys.delete(e.key));
let clicked: { x: number; y: number } | undefined;
addEventListener("pointerdown", (e) => (clicked = { x: e.clientX / UNIT, y: e.clientY / UNIT }));
const me = room.predict(stepper, {
find: (r) => [...r.entities.all(Player)].find((p) => p.owner === r.player),
sample: (prediction): Input => {
const input: Input = {
dx: (keys.has("ArrowRight") ? 1 : 0) - (keys.has("ArrowLeft") ? 1 : 0),
dy: (keys.has("ArrowDown") ? 1 : 0) - (keys.has("ArrowUp") ? 1 : 0),
};
const at = prediction.entity;
if (clicked && at && (clicked.x !== at.x || clicked.y !== at.y)) input.fire = { x: clicked.x - at.x, y: clicked.y - at.y };
clicked = undefined;
return input;
},
});
const others = room.interpolate(Player, ["x", "y"]);
room.on("message", (m) => console.log(m));
function frame() {
const local = me.render(["x", "y"]);
for (const p of room.entities.all(Player)) {
const at = p.owner === room.player ? local : others.get(p);
console.log(p.owner, p.hp, at.x * UNIT, at.y * UNIT);
}
requestAnimationFrame(frame);
}
frame();
The test has the target run past while the shooter fires at it as the shooter’s client draws it. By the time the room checks the shot, the target is more than a unit further on, three times the hit radius:
// arena.test.ts
import type { RoomClient } from "@irt/client";
import { testRoom } from "@irt/testing";
import { expect, test } from "vitest";
import room from "./room.js";
import { type Input, Player, stepper } from "./shared.js";
/** Fires once at the target as the shooter's client draws it, `offset` units along x; returns the target's hp. */
async function arena(offset: number) {
const t = testRoom(room);
const target = await t.connect({ player: "target" });
const shooter = await t.connect({ player: "shooter" });
const drawn = shooter.client.interpolate(Player, ["x", "y"]);
const find = (r: RoomClient) => [...r.entities.all(Player)].find((p) => p.owner === r.player);
target.predict(stepper, { find, sample: () => ({ dx: 1, dy: 0 }) });
let shots = 0;
shooter.predict(stepper, {
find,
sample: (prediction): Input => {
const me = prediction.entity;
const other = [...shooter.client.entities.all(Player)].find((p) => p.owner === "target");
if (t.live.tick < 20 || shots > 0 || !me || !other) return { dx: 0, dy: 0 };
shots++;
const at = drawn.get(other);
return { dx: 0, dy: 0, fire: { x: at.x + offset - me.x, y: at.y - me.y } };
},
});
await t.host.tick(30);
return [...t.live.entities.all(Player)].find((p) => p.owner === "target")!.hp;
}
test("a shot at the target where the shooter saw it hits, though it has moved on", async () => {
expect(await arena(0)).toBe(75);
});
test("a shot that missed on the shooter's screen misses", async () => {
expect(await arena(-1)).toBe(100);
});
The shooter’s own position is the present one: its client predicts it, so the room’s present position is what the shooter saw of itself.
With physics
A Rapier world holds the present, and rebuilding one for a past tick
costs too much for a hit check. @irt/physics instead offers
castRay(ray, maxToi, targets, shape), which casts a ray against
targets placed at their poses with Rapier shapes, with no world. Record
the pose fields in a history, rewind, and cast against the result:
// soldiers.ts
import { defineHistory, defineRoom, defineSchema, entity, type RoomEntity } from "@irt/room";
import { body, castRay, physics, RAPIER } from "@irt/physics/rapier3d";
const Soldier = entity("soldier", { owner: "string", x: "f32", y: "f32", z: "f32", vy: "f32" });
const poses = defineHistory(Soldier, ["x", "y", "z"]);
const capsule = new RAPIER.Capsule(0.5, 0.3);
type Input = { dx: number; dz: number; fire?: { x: number; y: number; z: number } };
const stepper = physics<Input>({
gravity: { x: 0, y: -9.81, z: 0 },
bodies: [
body(Soldier, {
type: "character",
colliders: () => RAPIER.ColliderDesc.capsule(0.5, 0.3),
player: (s) => s.owner,
control({ entity: s, move }, input, dt) {
s.vy -= 9.81 * dt;
if (move((input?.dx ?? 0) * 6 * dt, s.vy * dt, (input?.dz ?? 0) * 6 * dt) && s.vy < 0) s.vy = 0;
},
}),
],
});
export default defineRoom({
schema: defineSchema({ entities: [Soldier] }),
stepper,
history: [poses],
onJoin(room, conn) {
conn.data.soldier = room.entities.create(Soldier, { owner: conn.player, y: 1 });
},
onTick(room) {
for (const conn of room.connections.values()) {
const fire = (conn.input as Input | undefined)?.fire;
const me: RoomEntity<typeof Soldier> = conn.data.soldier;
if (!fire) continue;
const others = [...room.rewind(poses, conn.viewTick).values()].filter((s) => s.id !== me.id);
const ray = new RAPIER.Ray({ x: me.x, y: me.y + 0.6, z: me.z }, fire);
const hit = castRay(ray, 100, others, () => capsule);
if (hit) room.broadcast({ type: "hit", target: hit.target.id, distance: hit.toi });
}
},
});
Walls and other fixed bodies don’t move, so their present is their
past: to stop shots at walls, cast against the room’s own world with
stepper.rapierWorld(room.entities)!.castRay(ray, maxToi, true) and
count a player hit only if it’s nearer than the wall. In 2D, castRay
takes poses of x, y and an optional angle, as the
physics reference shows.
What the room keeps
- How far back. A view tick is never more than
MAX_REWIND_MS(250 ms) behind the room; the room clamps it. A history keeps that many ticks plus two: 7 ticks at 20 ticks a second, 17 at 60, 32 at 120. - Memory. Each recorded entity costs 8 bytes per field per kept
tick, plus about 128 bytes. Three fields at 60 ticks a second is 536
bytes an entity, so 1,000 players take about half a megabyte. A room’s
histories together are capped at 8 MB (
MAX_HISTORY). Over it, the room logs an error once and new entities aren’t recorded, so they’re absent fromroom.rewind, until deleted entities leave the window and free their share. - Not room state. The history is in the room host’s memory only. It
isn’t saved, isn’t checkpointed, and starts empty when the room wakes
or its room host restarts; until the room has run a tick again,
room.rewindgives the present.
Common mistakes
- Checking hits in the stepper. The stepper runs on the client too,
and has no history or view tick there. Check hits in
onTick. - Sending shots as messages. A message carries no view tick, and
connection.viewTickis that of the last input, which may be from another tick. Put the shot in the input. - Rewinding the shooter. Its client predicts it, so it saw itself where the room has it now. Fire from the present position.
- Recording every field. Each field costs 8 bytes per tick for every entity of the kind. Record what the hit check reads.
- Drawing others without interpolation. The view tick is what
room.interpolatedraws. A client that draws the latest state shows entities about two ticks ahead of its view tick, and shots there land behind the target. - Trusting a rewound hit absolutely. Rewinding favours the shooter: a target that just ran behind a wall can still be hit, by up to the shooter’s lag. The 250 ms cap bounds it.