# 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`](/docs/cli#settings). 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](/docs/guides/player-data) 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](/docs/http-api#rooms).

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

```ts
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:

```ts
// 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);
}
```

```ts
// 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](/docs/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](/docs/limits#project-settings-with-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](/docs/reference/errors#the-http-helpers).

## 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:

```ts
// 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](/docs/guides/testing) for more.

## Adding a backend later

Accounts come from your own backend, which signs
[player tokens](/docs/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"`.
