Sharing rooms and clips

Two links carry a player from a chat window into your game: a join link, which puts them in a room with the person who sent it, and a replay link, which plays back a clip of something that already happened. Both are links to your own page. The platform supplies the room code, the clip bytes and a landing card, and your page does the rest.

room.link is the share link for the room a client is in. In a browser, joinRoom also writes ?room= into the address bar, so a player who copies the URL out of the bar shares a working link without being told to.

const room = await joinRoom(schema);
await navigator.clipboard.writeText(room.link);

A client that opens a link with ?room= joins that room instead of creating one. Nothing extra is needed for that: it is what joinRoom does when you pass no room option.

A join to a room id nobody has used creates that room, which is what lets a link survive a server restart. It also means a mistyped or guessed code quietly starts an empty room. On a page that only ever joins from a link, pass mustExist: true: a code nobody created is then refused with E_ROOM_NOT_FOUND, which your page can show as “that game has ended”.

Default room codes are four characters so they can be read aloud and typed in, and they are easy to guess. For a private game, create the room with privateCode: true, which mints a 10-character code (about 50 bits) instead:

// Creating: a long code, written to ?room= as usual.
const room = await joinRoom(schema, { privateCode: true });

// Joining from the link: only a room that already exists.
const guest = await joinRoom(schema, { mustExist: true });

For the strongest guarantee, bind the room to a JWT instead. A server older than this release ignores mustExist and creates the room, so treat it as advisory until every server you are served by has been upgraded.

A game embedded on a portal cannot share a link into your own origin, because the player is inside the portal’s page. CrazyGames has its own invite link carrying the same room code, and @irtio/crazygames reads and writes it in place of room.link. Poki needs no link work but does need its external-resources approval before a build can reach irtio at all. A Discord Activity needs no link at all: Discord decides who is playing together, and discordRoomId derives the room code from that.

Chat clients build their preview from the Open Graph tags in the HTML at the link, and they do not run your JavaScript. Put static tags in the <head> of the page you deploy, describing the game rather than the room:

<meta property="og:title" content="Crate Ball" />
<meta property="og:description" content="Two-minute physics football. Join a room and play." />
<meta property="og:image" content="https://mygame.example/preview.png" />
<meta property="og:url" content="https://mygame.example/" />
<meta name="twitter:card" content="summary_large_image" />

The room code cannot appear in those tags. The preview is fetched from your static HTML before any room exists, and ?room= is a query parameter that most crawlers strip before fetching. Write the tags for the game and let the room be a detail the player discovers on arrival. If you serve your page from a framework that renders per request, you can vary the tags by URL, but a room code is short-lived and a cached preview of it will outlive the room.

Clips are saved by the room, in your server code, and the id comes back to the client however you choose to send it. See Replays for the recording side. A typical room saves a clip and sends the id to the players in a message:

// server
const clipId = await ctx.room.replay.clip(10);   // the last 10 seconds
ctx.room.messages.clipSaved.send({ clipId });

On the client, replayShareLink turns that id into the link to hand somebody. It is a link to your own page, because a replay is your game running against recorded bytes, and the only page that can render your game is yours:

// client
import { replayShareLink } from '@irtio/client';

const link = replayShareLink(clipId, schema.project);
// https://mygame.example/?replay=p_ab12%2F<clipId>

Playing one back

playReplayFromLocation reads ?replay= off the page. It returns undefined when the page names no clip, which is the signal to join a room as usual:

import { joinRoom, playReplayFromLocation } from 'irtio';

const clip = await playReplayFromLocation(schema);
const room = clip ? clip.room : await joinRoom(schema);

startRendering(room);

It plays only clip refs for your game’s own project, which is schema.project or the { project } you pass. A ?replay= holding a URL, or another project’s project/clipId ref, returns undefined and the live game opens instead. With no project configured at all, a project/clipId link rejects rather than trusting the project it names.

Under irtio dev, a game with no project is served as project dev, so its share links name dev. Pass playReplayFromLocation(schema, { project: 'dev' }) to play them, or run irtio init so schema.project names your real project.

The replay room supports your render loop, onAdd handlers, and event subscriptions. Writes stay local. The viewer owns no recorded entities and prediction is disabled.

clip.playback is the playback head, for a scrubber or a speed control:

clip.playback.speed(0.5);
clip.playback.seek(4);
clip.playback.pause();

To play a specific clip rather than whatever the page names, use playReplay(schema, ref). A ref is a project/clipId pair, a bare clip id with { project }, or the blob URL.

A clip is pinned to the schema it was recorded against. After a schema change, an older clip refuses to play with E_SCHEMA_MISMATCH rather than rendering a game that looks subtly wrong. Handle it the way you handle a live schema skew.

The clip landing card

https://irt.io/clip/<project>/<clipId> is a small landing page for a clip, so a link posted in a chat that renders Open Graph tags shows something rather than a bare URL. The card does not load clip data and has the same appearance whether the clip exists or not.

Set your project’s public URL to give the card somewhere to send people:

curl -X PATCH https://irt.io/v1/projects/$PROJECT_ID 
  -H "authorization: Bearer $IRT_TOKEN" 
  -H 'content-type: application/json' 
  -d '{"name":"My game","siteUrl":"https://mygame.example"}'

name is required by the route, so send the project’s current name alongside the URL. Send "siteUrl": null to clear it.

With a site URL set, the card offers a “Watch in game” link to https://mygame.example/?replay=<project>/<clipId>, which is the link replayShareLink builds. Without one, the card says only that the clip opens in the game that recorded it. The URL must be https, and it is published to anyone holding a clip link, which is the point of setting it.

Sharing your own game’s link directly is still the better share. The landing card is for the case where a clip id travels on its own.

The badge

@irtio/badge is a small “made with irt.io” corner badge. It is opt-in: importing the package registers nothing, joinRoom has no badge option, and mountBadge() is the only thing that puts one on a page.

import { mountBadge } from '@irtio/badge';

mountBadge();
mountBadge({ corner: 'bottom-left' });

The badge links to irt.io and carries a close button. Closing it hides the badge for the rest of the browser session, so a player who dismisses it does not meet it again on the next room. Style it with the --irt-badge-* custom properties:

irt-badge {
  --irt-badge-bg: rgba(0, 0, 0, 0.5);
  --irt-badge-fg: #fff;
  --irt-badge-gap: 20px;
}

mountBadge returns a handle with destroy(), and returns undefined where there is no document to mount into, so it is safe to call during server rendering.

Related: Replays