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:

CaseWhat you get
It reaches 256 MBThe footage up to that point.
The store cannot keep up with the roomThe footage up to the last chunk the store took.
A single stretch of state is larger than the 8 MB replay ringThe 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:

CallWhat 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
durationSeconds of footage in the clip
positionWhere playback is, in seconds
endedtrue 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:

ConfigCollections 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

OptionDefaultRangeWhat it does
replayabsent{} or the fields belowAbsent means no recording at all
seconds601 to 300Seconds of footage held in memory
viewomitted: public collections onlya role your schema declaresAlso records that role’s collections. owner collections are never recorded in a role view
omniscientfalsetrue or falseRecords every collection except server ones. For games with no hidden information; exclusive with view
recordfalsetrue or falseStores the whole match, not just the last seconds
ttl'30d''1m' to '3650d'How long a saved clip or recording is kept

Limits

LimitValueWhen you reach it
Recorded footageseconds, 60 by default, 300 at mostThe oldest seconds are dropped
Memory per recording room8 MBThe oldest seconds are dropped early, so a busy room holds fewer than seconds
One recorded momentmust fit in that 8 MBA 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 resolution2 secondsseek() lands on the nearest recorded point at or before your target
Clips per roombursts of 8, refilled one per secondA 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 size16 MBThe clip is refused with E_CLIP_TOO_LARGE
How long a clip is keptttl, 30 days by defaultThe clip is deleted and its link stops working
One recording256 MBRecording stops, a line says so in the room’s logs, and clips keep working
Recording chunks waiting on the store2Recording 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 warningup to secondsEverything already stored still plays
How long a recording is keptttl, 30 days by defaultThe 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.