Replays
A replay is a clip of your room: a few seconds of recorded state that your own game code plays
back. The room keeps the recent past in memory, room.replay.clip(seconds) saves it, and the
client plays it through the same pipeline it uses live, so your game draws it with your own code.
Use it for a killcam, a “clip that” button, a shareable highlight, or a bug report a player can send you.
Record and clip
Two lines. One arms the recording, one takes the clip.
// irtio/room.ts
export default defineRoom(schema, {
tickRate: 20,
replay: {}, // record the last 60 seconds
rpc: {
clipThat(state, _params, ctx) {
// Ask, then carry on: the handler is synchronous and the answer arrives later.
void ctx.room.replay.clip(20).then((clip) => { // the last 20 seconds
state.clips.add(clip.id, { by: ctx.clientId });
});
},
},
tick(state, dt, room) { /* ... */ },
}); clip() resolves with { id }. The id is the clip: anyone holding it can watch, and there is no
other permission. Hand it to the players you meant to and no further.
Nothing is stored until you call clip(). Recording costs memory in the room and no storage at
all.
Play a clip back
The share link is your own game’s page with ?replay=<id> on it. Read the id from the URL and
join with the replay transport instead of a live room.
// main.ts
import { joinRoom, replay, replayIdFromLocation } from '@irtio/client';
import { schema } from './irtio/schema';
const clipId = replayIdFromLocation();
const room = clipId
? await joinRoom(schema, {
transport: replay(clipId, { project: 'your-project-id' }),
room: '', // a replay joins no room
})
: await joinRoom(schema); // the live game
// Draw exactly as you always do: room.state, room.render, onAdd, onRemove. replay() returns the transport and the playback controls together:
| Call | What it does |
|---|---|
seek(seconds) | Jumps to seconds from the start of the clip |
speed(rate) | Playback rate. 0.25 for slow motion, 4 for fast forward |
pause() / resume() | Stops and restarts the feed |
duration | Seconds of footage in the clip |
position | Where playback is, in seconds |
ended | true once the last frame has played |
onEnd(cb) | Fires when the last frame has played |
A replay session writes nothing and sends nothing. Writes, RPCs and messages your game makes during playback go nowhere, so a replay page needs no special case in your input code.
What a clip contains
Everything the recorder can see, and by default the recorder sees everything: every role’s collections, and every instance of a collection restricted by position.
That is the wrong default for a game with hidden information, because a clip of a hidden-hand game would show every hand. Name a role and the clip contains exactly what that role can see:
replay: { seconds: 30, view: 'guesser' }, The recorder has no position of its own, so a collection restricted by position is still recorded whole. If your hidden information is positional, record for a role whose visibility already excludes it, or do not record that room type.
defineRoom refuses a view your schema does not declare as a role.
Options
| Option | Default | Range | What it does |
|---|---|---|---|
replay | absent | {} or the fields below | Absent means no recording at all |
seconds | 60 | 1 to 300 | Seconds of footage held in memory |
view | omitted | a role your schema declares | Records that role’s view instead of everything |
Limits
| Limit | Value | When you reach it |
|---|---|---|
| Recorded footage | seconds, 60 by default, 300 at most | The oldest seconds are dropped |
| Memory per recording room | 8 MB | The oldest seconds are dropped early, so a busy room holds fewer than seconds |
| One recorded moment | must fit in that 8 MB | A room whose whole state is larger than the ring cannot record at all: recording stops, a line says so in the room’s logs, and room.replay.clip() is refused with E_CLIP_DISARMED |
| Seek resolution | 2 seconds | seek() lands on the nearest recorded point at or before your target |
| Clips per room | one per second | A second call inside the second is refused with E_CLIP_COOLDOWN |
| Clip size | 16 MB | The clip is refused with E_CLIP_TOO_LARGE |
| How long a clip is kept | 30 days | The clip is deleted and its link stops working |
Clips count as stored bytes and as bandwidth when they are watched, the same as everything else you store. See limits.
When it goes wrong
The clip refuses to play after a deploy. A clip is pinned to the schema it was recorded on. A deploy that changes your schema changes that pin, and playback refuses rather than decoding old bytes against a new shape:
E_SCHEMA_MISMATCH: E_REPLAY_SCHEMA_MISMATCH: this clip was recorded against schema
a1b2c3d4e5f60718 and this page joined with 0918273645afbeef. There is no migration for a clip. Treat clips as short-lived, which the 30-day window already assumes.
The link opens on someone else’s page. The blob is readable only from an origin your project has registered, plus localhost. Register the origin your game is served from in the dashboard, the same list a browser needs to open a room at all.
clip() rejects with E_CLIP_EMPTY. The room has not changed anything since it started
recording, so there is nothing to clip yet.
Relay rooms cannot record. A relay room has no room file, and replay is a room-file setting.