Build and deploy

Games without a backend

Ship a game with no server of your own, with guest players, rooms clients create, join codes and a list of public rooms.

A game doesn’t need a backend on IRT. Players join as guests, and clients create rooms, share join codes and browse public rooms themselves, through three helpers in @irt/client. Each part is a project setting, on by default:

Setting Default Lets clients
players.guests true Connect, and use the helpers below, with no player token
players.createRooms true Create rooms with createRoom, or by connecting to a room ID that doesn’t exist, and find them with joinByCode
players.listPublic true List active public rooms with listPublic

Listing needs a player token when guests are off. A game whose rooms require a login but whose room directory is public, such as a lobby page, turns on players.listPublicWithoutToken: then listPublic works with no token, while connecting still needs one.

Set them in irt.config.ts. Turn one off and the matching calls fail with 403, or 4001 for a guest’s connection, so a project can move to a backend one part at a time.

A player’s progress, what players make and high score tables need no backend either: player data, collections and leaderboards are kept per project, and a guest is the same player in rooms and in data calls.

Guests

A client that connects without a token is a guest. The router gives it a player ID starting with guest: and a guest token, which the client SDK keeps and sends on every connect:

  • It’s kept in localStorage under irt.guest.<project>, so a guest is the same player across reconnects, reloads, tabs and browser restarts, and keeps its private state, the entities it owns and its voice identity.
  • It lasts 30 days, and each connect renews it with the same player ID, so only a guest away for 30 days comes back as a new one. A token that has expired, or that the router can’t verify, starts a new guest rather than failing.
  • Where localStorage is missing or blocked, as in Node or some private windows, the token is kept in memory: the guest lasts as long as the client object, across its reconnects.

Because every tab of a browser is the same guest, two tabs in one room are one player: the newer connection closes the older with 4012. Test two players with a private window or another browser, and tell players who see 4012 that they’re playing elsewhere.

Guests are anonymous. Anyone can become a new guest at any time by clearing storage, so don’t give a guest anything worth farming, and treat what a guest sends as untrusted, as you would any client’s.

Creating and joining rooms

With players.createRooms on, connecting to a room ID that doesn’t exist creates the room, deployed as under irt dev: a private room with no join code at the project’s rooms.size. The connection waits while the room starts, as for a wake. It counts against the address’s 10 creates a minute, like createRoom; past that the connection closes with 4005 and the client retries after backoff. With the setting off, connecting to a room that was never created closes with 4002, and rooms are created by you with irt rooms create <id> or by a backend through the HTTP API.

Use createRoom for a room with a join code, a public room, meta or maxPlayers:

function createRoom(opts: {
  router: string; project: string; token?: string;
  id?: string;           // default: a random 12-character ID
  type?: string;         // the room type; default the build's type named default
  public?: boolean;      // listed by listPublic while it's active; default false
  joinCode?: boolean;    // give it a 6-character join code; default false
  meta?: unknown;        // JSON up to 1 KB, returned by listPublic
  maxPlayers?: number;   // cap on connections, 1 to the room size's limit; default the limit
}): Promise<{ id: string; joinCode: string | null }>;

function joinByCode(opts: { router: string; project: string; token?: string; code: string }): Promise<string>;
function listPublic(opts: { router: string; project: string; token?: string; type?: string }): Promise<PublicRoom[]>;
// maxPlayers is the room's own limit, else its size's connection limit
interface PublicRoom { id: string; type: string; players: number; maxPlayers: number; meta: unknown }

A room menu with all three:

// rooms-menu.ts
import { connect, createRoom, joinByCode, listPublic, type RoomClient } from "@irt/client";
import { schema } from "./schema.js";

const router = location.hostname.endsWith(".irt-serve.com") ? "wss://eu.irt.io" : location.origin;
const project = "my-game";

/** Creates a private room and returns its join code for the host to share. */
export async function host(): Promise<{ room: RoomClient; code: string }> {
  const { id, joinCode } = await createRoom({ router, project, joinCode: true });
  return { room: await connect({ router, project, room: id, schema }), code: joinCode! };
}

/** Joins the room a friend's code names. */
export async function join(code: string): Promise<RoomClient> {
  return connect({ router, project, room: await joinByCode({ router, project, code }), schema });
}

/** Opens a public room others can find, described by its meta. */
export async function open(mode: string): Promise<RoomClient> {
  const { id } = await createRoom({ router, project, public: true, meta: { mode } });
  return connect({ router, project, room: id, schema });
}

/** The public rooms with space, fullest first. */
export async function browse(max: number) {
  const rooms = await listPublic({ router, project });
  return rooms.filter((r) => r.players < max).sort((a, b) => b.players - a.players);
}
// schema.ts
import { defineSchema, entity } from "@irt/client";

export const schema = defineSchema({ state: entity("scoreboard", { round: "u16" }) });
  • Room IDs. Leave out id and the router picks a random one, which is hard to guess. Anyone who knows a room’s ID can connect to it while guests are allowed, so a private room’s ID should stay private; share its join code instead.
  • Join codes are 6 characters of A-H, J-N, P-Z and 2-9, with no I, O, 0 or 1. joinByCode takes them in any case and with spaces around them. A code lasts as long as its room; after the room is deleted the code gives 404. Room code reads it as room.joinCode, for example to put it in room.state so players who join later, and a host who reloads, can show it in the lobby.
  • Public rooms are listed only while active, with their player count and meta. A hibernated room has no players and isn’t listed, so a public room that empties drops off the list until someone connects to it again. meta is set at creation and doesn’t change; keep what changes, such as a round or a map, in room.state.
  • Who’s in a room is the room’s business: the room decides in onJoin whether to keep a connection, and can room.kick it, which closes it with 4006. Room size caps the connections, and maxPlayers caps them lower, such as 2 for a duel; past either clients get 4003. A player who reconnects takes their own place and doesn’t count twice.

A ready-made room screen

For a room screen without writing one, the UI kit in @irt/client/ui has custom elements that use the helpers above: <irt-room-panel> with create, join by code and quick join buttons, the room’s join code with a share link and QR code, the connection status, the players and a name field; <irt-room-browser> for the public rooms; <irt-invite>, <irt-lobby>, voice controls, a network indicator and a join loader. They show the router’s message when a setting is off.

Rooms that come and go

Client-created rooms pile up unless something deletes them. rooms.retention deletes a room after it has been hibernated that long:

rooms.retention Suits
"none" Rooms that matter only while players are in them, such as a single round; deleted 30 seconds after the last player leaves
"7d" (default), "<n>h", "<n>d" Rooms players come back to, such as a party’s room or a saved game
"forever" Rooms that are places, such as a persistent world

A project can hold at most limits.maxRooms rooms, 1,000 by default, so retention matters most for games where every round creates a room. Over it, createRoom fails with 429 until rooms expire. See Limits.

Errors

Each helper rejects with an IrtError whose code is the HTTP status: 403 when the setting is off, 404 for an unknown join code, 409 for an id that’s taken, 429 when rate limited or at maxRooms. The full list is in Errors and close codes.

Testing

@irt/testing’s host has the same three calls, with the project’s settings applied, so a test can walk through a room menu without a network:

// rooms-menu.test.ts
import { defineRoom, defineSchema, entity } from "@irt/room";
import { createTestHost } from "@irt/testing";
import { expect, test } from "vitest";

const room = defineRoom({ schema: defineSchema({ state: entity("scoreboard", { round: "u16" }) }) });

test("a friend joins by code, and a public room is listed while it's active", async () => {
  const host = createTestHost(room);
  const { id, joinCode } = await host.createRoom({ joinCode: true });
  const alice = await host.room(id).connect({ player: "alice" });
  const bob = await host.room(await host.joinByCode(joinCode!.toLowerCase())).connect({ player: "bob" });
  expect(bob.client.connection).not.toBe(alice.client.connection);

  const open = await host.createRoom({ public: true, meta: { mode: "duel" } });
  expect(await host.listPublic()).toEqual([]);
  await host.room(open.id).connect({ player: "carol" });
  expect(await host.listPublic()).toEqual([{ id: open.id, players: 1, maxPlayers: 64, type: "default", meta: { mode: "duel" } }]);
});

test("clients can't create rooms when the setting is off", async () => {
  const host = createTestHost(room, { settings: { players: { createRooms: false } } });
  await expect(host.createRoom()).rejects.toMatchObject({ code: 403 });
});

See Testing rooms for more.

Adding a backend later

Accounts come from your own backend, which signs player tokens. Pass a token to connect and to the helpers, and that client is a known player rather than a guest. Turning players.guests off then requires one; turning players.createRooms off leaves room creation to the backend, through the HTTP API.

Common mistakes

  • Turning players.createRooms off and connecting to a fixed room ID. Nothing creates the room any more, so connecting gives 4002. Create it with irt rooms create or from your backend.
  • Testing two players in two tabs. They’re the same guest; the newer tab closes the older with 4012. Use a private window.
  • Expecting a public room to stay listed while empty. It hibernates 30 seconds after its last player leaves and drops off the list.
  • Trusting a guest. A guest ID costs nothing to replace. Validate messages in the room and keep anything valuable behind player tokens.
  • Leaving retention at 7 days for throwaway rooms. Every such room counts toward maxRooms until it expires. Use "none".

View this page as Markdown