Rooms

Replays and clips

Record a room tick by tick, keep its best moments as clips, and play either back in the browser with the code that renders the live game.

A replay is a recording of a room’s synced state, tick by tick: what a client that sees every entity would have seen. A clip is the last few seconds of a replay, kept after the replay itself expires. You play both with @irt/client/replay, into an entity store with the same events as a live room, so the code that renders your game renders its replays too.

Nothing is recorded unless the room asks. Replays are never public: your backend downloads them with its API key and decides who may watch.

Recording

import { defineRoom } from "@irt/room";
import { schema } from "./schema.js";

export default defineRoom({
  schema,
  onCreate(room) {
    room.data.replay = room.record();
  },
  onWake(room) {
    room.data.replay = room.record();
  },
  onTick(room) {
    // ...
  },
});

room.record() starts recording from the room’s next tick and returns the replay’s ID. Recording stops with room.stopRecording(), when the room hibernates or stops, and when the replay reaches 64 MB or 2 hours. A recording isn’t room state, so a room that always records calls record in onWake as well as onCreate; after a hibernation, or a crash of its room host, it’s a new replay. room.recording is the ID being recorded, or undefined.

Options:

  • id: your own ID, such as a match ID, 1 to 64 of A-Z, a-z, 0-9, _ and -. A room uses an ID once; recording under an ID it has used keeps nothing, and the project’s logs say so. Without one you get a random ID.
  • private: also record each player’s private state. Off by default. Whoever can watch a replay sees everything in it, so turn this on only for replays that only you or the player see, or after a game where hands no longer matter.
  • messages: also record what the room broadcasts to every connection, such as chat or kill feed events. Messages to one connection are never recorded.
room.record({ id: `match-${matchId}`, messages: true });

record throws when the room is already recording, and when it started a recording less than 10 seconds ago.

What a replay holds

The shared state, every entity and presence, every tick that changed any of them, and a full state every 10 seconds so playback can seek. Interest doesn’t apply: a replay sees everything, whoever could see it at the time. Owner writes appear as the changes they made; corrections, inputs and client messages aren’t recorded.

A replay carries the kinds and fields it was recorded with, as room state does, so it plays after you change your schema.

Clips

onMessage(room, connection, message) {
  if (message.type === "ace") room.clip({ seconds: 15, id: `ace-${room.tick}` });
},

room.clip({ seconds }) keeps the last 1 to 60 seconds of the current recording and returns the clip’s ID. A room makes at most one clip every 10 seconds and 20 per replay. A clip is kept until you delete it, after its replay has expired.

To clip from your backend, send the room a message and call clip in onRequest, returning the ID:

onRequest(room, message) {
  if (message.type === "clip") return { clip: room.clip({ seconds: message.seconds }) };
},

Downloading

Your backend lists a room’s replays and clips and downloads their files with the project’s API key:

curl -H "Authorization: Bearer $IRT_API_KEY" \
  https://api.irt.io/v1/projects/my-game/rooms/match-42/replays
curl -H "Authorization: Bearer $IRT_API_KEY" -o match.irtr \
  https://api.irt.io/v1/projects/my-game/rooms/match-42/replays/match-42/file

The listing says when each replay started and ended, its ticks, size and when it expires. A file grows while its replay records. Serve it to the players you choose, from your own backend or storage.

Playing back

import { loadReplay } from "@irt/client/replay";
import { Ball, schema } from "./schema.js";

const bytes = new Uint8Array(await (await fetch("/my-backend/replays/match-42")).arrayBuffer());
const replay = loadReplay(bytes, { schema });

replay.entities.onEnter(Ball, (b) => scene.add(b.id));
replay.entities.onChange(Ball, (b) => scene.move(b.id, b.x, b.y));
replay.entities.onLeave(Ball, (b) => scene.remove(b.id));
replay.on("message", (m) => feed.push(m));

replay.play();         // at the room's tick rate
replay.speed = 2;      // twice as fast
replay.pause();
replay.seek(1200);     // any tick from replay.firstTick to replay.lastTick
replay.step();         // the next tick; false at the end
  • replay.entities is an EntityStore, as room.entities is on a live client, and replay.state and replay.presence work the same way. Entities are read-only.
  • A seek starts from the full state at or before the tick, so it’s fast anywhere in a replay. Entities shown before leave and those at the tick enter, without the changes between. Recorded messages fire only while playing through their tick.
  • With schema, a kind whose fields are as recorded is your kind, so entities.all(Ball) finds its entities. A kind you’ve changed since is read from the replay: replay.kind("ball") returns it.
  • replay.privateOf(player) is a player’s private state, when the replay recorded it.
  • A clip loads the same way and starts at its first second.

@irt/client/replay is a separate entry, so a game that doesn’t play replays doesn’t carry it.

Testing

@irt/testing keeps replays in memory:

const room = testRoom(game);
const alice = await room.connect({ player: "alice" });
await room.host.tick(100);
const replay = room.replay(room.live.recording!);
replay.seek(50);
expect([...replay.entities.all(Ball)]).toHaveLength(3);

room.replays() and room.clips() list IDs, and room.clip(id) loads a clip. irt dev keeps replays on disk next to room state.

Retention and cost

A replay is deleted rooms.replayRetention after it ends: 7 days by default, from 1 hour to 30 days, set in irt.config.ts or the dashboard. A room keeps its newest 20 replays and 100 clips. Deleting a room through the API deletes its replays and clips; a room deleted by its own retention keeps them until they expire or you delete them.

Replays and clips are billed as storage, by what IRT writes, never by anything your room reports. See Limits.

View this page as Markdown