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.
The clip is stored before the promise resolves, so the id is fetchable the moment you have it.
The wait is one storage write from the room; playback then starts with one fetch from the viewer.
For a killcam, call clip() when the hit resolves and send the id in the same death event; how
fast that feels is those two round trips, so measure it from your own region before you promise
an instant cam.
Nothing is stored until you call clip(). Recording costs memory in the room and no storage at
all.
Record a whole match
record: true keeps the whole match instead of only the last seconds. The room holds the same
seconds in memory as before. As the oldest seconds fall out of memory, the room stores them instead
of dropping them.
// irtio/room.ts
export default defineRoom(schema, {
tickRate: 20,
replay: { record: true }, // keep the whole match, not just the last 60 seconds
rpc: {
shareMatch(state, _params, ctx) {
const id = ctx.room.replay.recording; // undefined until the first minute is stored
if (id) state.links.add(id, { by: ctx.clientId });
},
},
tick(state, dt, room) { /* ... */ },
}); room.replay.recording is the recording’s id, and it is the same kind of link as a clip id. The
id is minted when the first footage is stored, and the first footage is stored when the oldest
seconds fall out of memory. So it reads undefined for the first seconds of a match: a minute by
default, or whatever seconds you set. Handle the undefined case, and expect a share link only
after that first minute.
Play a recording back exactly like a clip: the same ?replay=<id> link, the same replay() transport, the same controls.
Clips keep working while a match is recording. room.replay.clip() reads the same seconds in
memory and saves a separate blob with its own id.
Fetching a recording while the match is still running plays the footage stored so far.
Where a recording ends
A recording ends when the room ends or hibernates. A room that hibernates and wakes starts a second recording with a new id, so a match interrupted by a hibernation is two recordings. Keep the first id if you want the first half.
A recording also ends early in three cases. Each one writes a line on the room’s log saying which:
| Case | What you get |
|---|---|
| It reaches 256 MB | The footage up to that point. |
| The store cannot keep up with the room | The footage up to the last chunk the store took. |
| A single stretch of state is larger than the 8 MB replay ring | The footage up to that stretch. |
The room keeps running in all three, and room.replay.clip() keeps working. You get a short
recording rather than one with a hole in it, because footage with a hole in it plays back as the
wrong game rather than as less of it.
If the room stops without warning, everything already stored still plays. The seconds still in
memory are lost, at most seconds of footage.
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({ project: schema.project });
const room = clipId
? await joinRoom(schema, {
transport: replay(clipId, { project: schema.project }),
room: '', // a replay joins no room
})
: await joinRoom(schema); // the live game
// Draw exactly as you always do: room.state, room.render, onAdd, onRemove. schema.project is the project id irtio init writes into your schema. replayIdFromLocation only accepts a clip ref: a bare clip id, or project/clipId. A ?replay= that holds a URL, or a
ref naming a project other than yours, reads as '', so a link somebody else wrote opens the live
game rather than making your page fetch and draw whatever it points at. replay() also refuses a project/clipId ref whose project differs from the project you pass it.
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
What the recorder can see. A clip is a thing players share, so by default the recorder sees only the public view. It records collections, never parts of one:
| Config | Collections recorded |
|---|---|
replay: {} (default) | Public ones only: no visibility: 'role', 'owner' or 'server' collection |
replay: { view: 'guesser' } | What that role can see, except 'owner' collections |
replay: { omniscient: true } | Every collection except 'server' ones |
A visibility: 'server' collection is never recorded. The recorder owns no records, so the default
and a role view record exactly what a client of that role that owns nothing would receive. Name a
role when that role’s collections belong in the clip:
replay: { seconds: 30, view: 'guesser' }, omniscient: true records every role’s collections and every player’s owned records. Use it only
for a game with no hidden information: in any other game, a clip recorded that way shows one side’s
secrets to whoever the clip is shared with. It cannot be combined with view.
The recorder has no position of its own, so a collection restricted by position is recorded whole
in every mode. If your hidden information is positional, keep it in a role or owner collection
the recorder does not record, or do not record that room type.
defineRoom refuses a view your schema does not declare as a role, a non-boolean omniscient,
and omniscient together with view.
room.replay.excerpt() reads back what the recorder recorded, so it follows the same table. Naming
a collection the recorder does not record in collections is refused with E_EXCERPT_BAD_ARGS rather than returning it empty.
How long a clip is kept
A clip is kept for 30 days. Set ttl in the room config to keep it for a different length of
time, from one minute to ten years:
// irtio/room.ts
replay: { seconds: 30, ttl: '2h' }, // clips from this room type last two hours ttl takes a duration like '45m', '6h' or '30d', the same form retention takes. defineRoom refuses anything else.
The clip’s lifetime is fixed when the clip is saved. Changing ttl and deploying again does not
re-time clips you already saved. A shorter ttl cannot break links you have already shared. A
longer one does not bring expired ones back.
A recording is kept for ttl from its first stored chunk, so a match that ran for an hour expires
all at once rather than losing its opening while its ending still plays. A ttl shorter than the
match deletes the recording while it is still being made, so set ttl to at least the length of a
match.
When a clip expires, its link answers 404 like a link that never existed.
Ticks where nothing happened
The ring takes a frame only on a tick that changed something. A room that sits still records nothing while it sits still, so a clip’s length in frames is not its length in ticks, and a clip of a room whose state stopped changing ends where the changes stopped. This is the same rule the deltas your clients receive already follow, and it is most of why arming the ring is cheap.
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: public collections only | a role your schema declares | Also records that role’s collections. owner collections are never recorded in a role view |
omniscient | false | true or false | Records every collection except server ones. For games with no hidden information; exclusive with view |
record | false | true or false | Stores the whole match, not just the last seconds |
ttl | '30d' | '1m' to '3650d' | How long a saved clip or recording is kept |
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 | bursts of 8, refilled one per second | A call with no token left is refused with E_CLIP_COOLDOWN. The burst covers every death in a team fight that resolves inside one second; the refill is the sustained rate |
| Clip size | 16 MB | The clip is refused with E_CLIP_TOO_LARGE |
| How long a clip is kept | ttl, 30 days by default | The clip is deleted and its link stops working |
| One recording | 256 MB | Recording stops, a line says so in the room’s logs, and clips keep working |
| Recording chunks waiting on the store | 2 | Recording stops rather than leaving a hole in the footage, a line says so in the room’s logs, and clips keep working |
| Footage lost if the room stops without warning | up to seconds | Everything already stored still plays |
| How long a recording is kept | ttl, 30 days by default | The recording is deleted and its link stops working. A recording is kept from its first stored chunk, so a long match expires all at once. A ttl shorter than the match deletes it while it is still being recorded, and a line says so in the room’s logs |
Clips and recordings count as stored bytes and as bandwidth when they are watched, the same as
everything else you store. A recording grows with the match, so a room type with record: true stores far more than one with clips alone. 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. Clips require the schema they were recorded against. Keep a matching game build available for clips you want players to watch after a schema change.
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.
A recording stops partway through a long match. Four things end a recording early: the 256 MB cap, an object store that cannot keep up with the room, a stretch of state larger than the 8 MB replay ring, and a chunk the store refused. The room logs a line saying which, the footage up to that point plays, and clips keep working. See where a recording ends.
A clip is shorter than the wall clock says it should be. The ring holds frames, not seconds of real time, and it takes a frame only on a tick that changed something. Footage that ends early means the room’s state stopped changing at that tick. If your players were moving at the time, the movement was not reaching the room: check that their writes are arriving, because a predicted game shows a player their own local simulation whether or not the server ever saw the input.
Relay rooms cannot record. A relay room has no room file, and replay is a room-file setting.
That covers record: true as well.
Try it
The crate-ball example wires this end to end. A “Clip last 20s” button calls an RPC that clips and answers the caller with a shareable link, and the same page opens that link straight into playback with a seek bar. Play it at crate-ball.irt-serve.com.