# gamerelay — add multiplayer to a browser game > gamerelay makes a single-file JS/canvas game multiplayer with no server code: rooms, lobbies, > parties, invite links, host migration, chat, leaderboards, saved player data and webhooks. The > SDK owns the netcode: you declare what exists in the game and who owns it, and it sends, > smooths, hands over and cleans up. One player's browser is the **host**; if it leaves, freezes, > or can't keep up (a hidden tab on some browsers), another player takes over without losing anything. Start with **Pick your shape**, then copy the matching **Starter**. Everything below is what the starters use, then the rest of the platform, then the low-level API. Human-readable docs (SDK and REST API reference): https://gamerelay.io/docs This guide plus the docs, guides and examples in one file: https://gamerelay.io/llms-full.txt Guides for Phaser, Three.js, Kaplay and itch.io: https://gamerelay.io/guides Examples from real games, for patterns the starters don't cover: https://gamerelay.io/examples - Turn-based (moves checked by the host): https://gamerelay.io/examples/turn-based-chess - Quick match, codes, draw offers, rematches: https://gamerelay.io/examples/match-and-rematch-chess - A lobby in a room, with a server browser: https://gamerelay.io/examples/lobby-racecar - AI players the host drives: https://gamerelay.io/examples/host-ai-racecar - One clock for every player: https://gamerelay.io/examples/shared-clock-racecar - Hits between players (tell the owner, claim shared things): https://gamerelay.io/examples/collisions-racecar MCP servers (Streamable HTTP): - `https://gamerelay.io/mcp`: docs, no account (`get_integration_guide`, `get_starter_game` with `shape: 'avatars' | 'host' | 'mixed'`, `check_public_key`). `claude mcp add --transport http gamerelay https://gamerelay.io/mcp` - `https://gamerelay.io/mcp/admin`: everything in the dashboard, as you. The first call opens your browser to sign in and approve the agent (OAuth). Tools: the three docs tools above, `whoami`, `list_instances`, `get_instance`, `create_instance`, `update_instance` (chat, parties, saves, logs, room size, allowed origins, short links: slug, play URL, cover image), `delete_instance`, `rotate_key`, `list_rooms`, `get_room`, `kick_player`, `close_room`, `search_logs`, `get_usage`, `list_leaderboards`, `get_leaderboard`, `set_leaderboard_rules`, `list_webhooks`, `create_webhook`, `update_webhook`, `delete_webhook`, `rotate_webhook_secret`, `test_webhook`, `list_webhook_deliveries`. Destructive ones require `confirm: true`. Disconnect agents in the dashboard. `claude mcp add --transport http gamerelay-account https://gamerelay.io/mcp/admin` Clients that support MCP Events (ChatGPT) can also subscribe to `leaderboard.top_score`, `room.created`, `room.closed` and `webhook.failed` on an instance (`events/list`), so an automation runs when, say, someone takes first place. Both servers speak MCP 2024-11-05 through 2026-07-28. In ChatGPT, add either URL as a connector. ## Install Script tag (exposes `window.GameRelay`): ```html ``` Module build, for `import` without a bundler: ```js import { GameRelay } from 'https://gamerelay.io/sdk/v0/gamerelay.mjs'; ``` Or npm: `npm i @gamerelay/sdk`, then `import { GameRelay } from '@gamerelay/sdk'`. You need a public key (`gr_pub_…`): sign in at https://gamerelay.io/app and create an instance for your game (or ask an agent with the account MCP to `create_instance`). It is safe to ship in the page. Until it's filled in, `connect()` rejects with `unauthorized`. ## Versions `https://gamerelay.io/sdk/v0/gamerelay.js` (and `/sdk/v0/gamerelay.mjs`) is the `v0` line: it updates in place, and within `v0` nothing is removed or renamed without a deprecation first (the old name keeps working and warns in the console for at least 30 days). `/sdk/gamerelay.js` is the same `v0` line, forever. To pin one exact version instead, load it from npm through jsDelivr: `https://cdn.jsdelivr.net/npm/@gamerelay/sdk@0.1.0-alpha.6/gamerelay.js` (any published version: `npm view @gamerelay/sdk versions`). `GameRelay.version` is the SDK's version. The SDK tells the server which version it is, and the server may answer with a notice, shown once in the console as `[gamerelay] …`: usually "this SDK version is old, load the URL above or update @gamerelay/sdk". If a version is too old to be accepted, `connect()` rejects with `upgrade_required` and the SDK stops retrying: load the `/sdk/v0/` URL (or `npm i @gamerelay/sdk@latest`) to fix it. Names marked **experimental** may still change within `v0`: the player-to-player route (on by default; see the docs page), `relay.joinOrCreate`, `relay.features`, `relay.serverInfo`, and a field's `smooth` option. Everything else in this guide is the `v0` promise. ## Connect and get into a room ```js const relay = await GameRelay.connect({ publicKey: 'gr_pub_…', playerName: 'ada', playerAvatar: 'fox' }); const room = await relay.quickMatch({ maxPlayers: 2 }); // join any open public room, or make one // or: const room = await relay.createRoom({ maxPlayers: 4 }); share room.code with friends // or: const room = await relay.joinRoom('K7QM'); // or: const room = await relay.joinInvite(); the room in this page's ?join= or ?room=CODE link, or null (see Invite links) // or: const room = await relay.joinOrCreate({ maxPlayers: 4, updateUrl: true }); experimental: the link's room, else quick match ``` A player is in at most one room at a time. `relay.playerId` stays the same across reloads of the same tab, so a refresh resumes your seat. Anonymous players are per tab, which makes testing with two tabs easy. Signed-in players (see below) are one player across tabs: a second connection takes over and the first one gets `relay.on('replaced')` and stops. Add `debug: true` while developing (see Debugging). ```js relay.playerId // your id relay.room // the Room you're in, or null relay.party // your party, or null relay.connected // false while reconnecting relay.features // what this game's plan includes: { voice, unreliable }. Reserved flags for plan features; nothing uses them yet. relay.close() // disconnect for good: your room gets 'closed' ('left'), waiting calls reject 'disconnected' ``` Every call that fails rejects (or throws) a `GameRelayError`: check `err.code` (the type `GameRelayErrorCode` lists them). Offline, `connect()` rejects with `disconnected`; no answer in 15 s, `timeout`; a key the server refuses, `unauthorized`. The relay and room fields above are read-only: change the room through its methods (`room.setState`, `room.setAccess`, …). ## Pick your shape Every multiplayer game is some mix of three things: what each player owns, what the host runs, and moments everyone should see. Pick the row closest to your game and start from its starter. | Shape | When | You use | Starter | | --- | --- | --- | --- | | **Players own avatars** | Each player moves their own thing and trusts their own hits: shooters, racers, co-op, drawing | entities, events | Starter: players own avatars | | **Host simulates** | Physics or rules must be fair and shared: ball games, one world everyone pushes on | inputs, host entities, `relay.tick` | Starter: host simulates | | **Mixed** (most games) | Players move themselves; the host owns the world: pickups, enemies, rounds | entities, host entities, claims, timers | Starter: mixed | - **Entities** are things that move or persist (ships, balls, coins). Each has one owner, who writes it; everyone else sees it smoothed. Positions always go in entities, never in messages. - **Events** are moments (a shot, a chat emote, a hit). **Requests** ask the host to decide something and wait for the answer. **Claims** let exactly one player take something. - **State** holds facts (score, phase, timers): one small shared document, written by the host. - Turn-based games: use requests (a move) plus state (the board) plus timers (turn clocks). - A full game built this way: racecar (https://github.com/gamerelay/racecar), an 8-player browser racer on the public SDK only (mixed shape: players own their cars, the host drives the AIs; lobbies as rooms, parties, claims). Its docs/ONLINE.md maps each online file to its job. https://gamerelay.io/examples walks through its patterns and a turn-based chess game's, with code. ## Starter: players own avatars Everyone moves their own square; space sends a wave everyone sees. Open it in two tabs. ```html Squares

Connecting…

``` ## Starter: host simulates Everyone sends inputs; the host moves every block and the ball, in two teams, and keeps the score. If the host leaves, another player carries on with the same ball and score. ```html Ball

Connecting…

``` ## Starter: mixed Everyone moves their own ship; the host drops coins on a timer; touching a coin claims it, so only one player scores it; 60 s rounds. ```html Coins

Connecting…

``` ## Entities Anything that moves or lasts (a ship, a ball, a coin) is an entity. Declare each kind once, in every player's game, before using it. `room.define` returns the kind's handle: ```js const ships = room.define('ship', { x: 'number', y: 'number', h: 'angle', alive: 'flag', name: 'text' }); const me = ships.spawn({ x: 100, y: 100, h: 0, alive: true, name: 'ada' }); // you own it me.x += 5; // just write fields, every frame if you like; the SDK sends changes (~20/s) for (const s of ships.mine()) s.x += speed * dt; // update: only the ones you write for (const s of ships.all()) drawShip(s.x, s.y, s.h, s.mine); // draw: every one (yours live, others smoothed) ships.get(id); // one, or undefined ships.on('spawn', (s) => addEffects(s)); // appears (not replayed for ones already here) ships.on('remove', (s, reason) => explode(s.x, s.y)); // 'removed' | 'left' | 'expired' me.remove(); ``` - **Field types**: `'number'` and `'angle'` (radians) are smoothed between updates; `'flag'` (boolean), `'text'` (up to 256 characters) and `'value'` (any JSON, up to 4 KB) change at their moment. Long form: `x: { type: 'number', precision: 1 }` (default precision 0.01). Up to 32 fields. - `room.define(kind, fields, { rate: 30 })` sends that kind 1–60 times a second (default 20). - Every entity has `id`, `kind`, `owner` (a player: `{ id, name, slot, … }`), `mine` (you write it). Key your own game data by `e.id`, or by `e.owner.id` for "that player's ship". Ids are made by the SDK; never make your own. - **Only write what you own.** Only the owner changes an entity. Writing someone else's does nothing: the write is skipped and the console warns once. So update in a loop over `kind.mine()` (yours, plus host entities while you're the host), and keep `kind.all()` for drawing and reading. To change someone else's, send the owner an event (or the host a request). - **Declared fields only.** Writing a field you didn't declare, the wrong type, or an entity you already removed is skipped with a warning too (never thrown, so one mistake can't stop your game loop; in TypeScript an undeclared field doesn't compile). Keep local-only values (cooldowns, input, animation timers) in your own variables or a `Map` keyed by `e.id`, not on the entity: only what others need to see goes in `room.define`. - **Others are drawn about 100 ms in the past** (`room.renderTime`), so motion is smooth on real networks. Don't add your own smoothing. - **Respawns and portals**: set the new position and call `e.teleport()` in the same frame, so everyone snaps instead of sliding across the map. - **Lifetime**: when a player disconnects, their entities freeze where they were until they reconnect (up to 30 s). When they leave for good, their entities go (`'left'`), unless spawned with `{ onLeave: 'host' }`: then the host keeps them (a carried flag stays in the world). - **Host entities**: `ships.spawn(…, { owner: 'host' })`, only on the host. They belong to the host role, so when the host changes the next host writes them, carrying on from where they were. Use them for the world: enemies, balls, pickups. - Entities are live objects (Proxies): copy one with `{ ...e }` (not `structuredClone`). - Late joiners get every entity automatically. Limits: 256 entities per player, 1,024 per room. - A handle belongs to its room: after `room.leave()` or moving rooms, define again in the new room (old handles throw and say so). ## Events Moments everyone should see: a shot, a hit, an emote. ```js room.emit('fire', { x, y, a }); // to everyone, you included room.emit('hit', { dmg: 10 }, { to: victimId }); // to one player ('host' for the host) room.on('fire', ({ x, y, a }, from, meta) => spawnBullet(from, x, y, a)); // meta.at: server time ``` - **Echo**: an event to everyone runs your own handler right away too (`{ echo: false }` skips it; an event `to` one other player doesn't echo). So handle an action in exactly one place: if the shooter also spawns its bullet before emitting, it gets two. - Events are for moments; facts go in entities or state. Nothing is kept for players who join later, and `room.on` doesn't replay earlier events. - Data is any JSON up to about 15 KB. Built-in names (`player_joined`, `spawn`, `host`, `timer`, `claimed`, `state`, `chat`, …) are reserved. ## Requests to the host Ask the host to decide something and wait for the answer: buy an item, pick a seat, make a move. ```js // On every player (whoever is host answers): room.onRequest('buy', ({ item }, from) => { const gold = room.state.gold?.[from] ?? 0; if (gold < PRICE[item]) throw room.reject('Not enough gold'); room.setState({ gold: { ...room.state.gold, [from]: gold - PRICE[item] } }); // in state: a new host has it return { item, gold: gold - PRICE[item] }; }); try { const { gold } = await room.request('buy', { item: 'sword' }); } catch (err) { // err.code: 'rejected' (err.message is the reason), 'host_changed' (ask again), 'timeout' (5 s), // 'disconnected' (you left the room) } ``` - Keep what the host decides in `room.state` (or host entities), not in variables: when the host changes, the next host has the state but not the old host's variables. - The same request (same name and data) while one is waiting returns the same promise. - The host's own requests run its handler directly. ## Claims: take something exactly once For "first one wins": a pickup, a seat, a flag. The server decides. ```js if (await room.claim(coin.id)) score += 1; // true for exactly one player, false for the rest room.on('claimed', (key, playerId) => hide(key)); // everyone, the winner included room.holder(key); // who holds it, or null room.release(key); // the holder (or the host) lets go; everyone gets 'released' ``` - Use one key per thing (an entity id is ideal). A claim is held until released or until its holder leaves. A room holds at most 1,024 claims. - Release a key only once the thing is long gone (seconds later): a claim still on its way could otherwise take it again. ## Inputs (host-simulated games) Players send controls; the host runs the game with them. ```js room.input({ left: keys.a, right: keys.d, jump: keys.w, aim: angle }); // every frame, on every player // on the host, in its game loop: const inp = room.inputs.get(playerId); // that player's latest: { left, right, jump, aim } ``` - The latest input goes to the host about 20 times a second; a tap between two sends still arrives as `true`. - A player who stops sending (left, froze, hid the tab) reads as neutral after 500 ms: booleans false, numbers 0. Players who never sent read as `{}`, so write `inp.aim ?? 0`. - The host's own input is read the same way: `room.inputs.get(room.me)`. - Keep inputs small (under 1 KB): buttons and axes, not positions. ## State and timers ```js room.state // one shared JSON document: score, phase, round room.setState({ score: [1, 0] }) // host only; shallow merge, null deletes a key; saved on the server room.on('state', (state, patch) => {}) room.timer('round', 60_000); // host only: fires once, on whoever is host when it's due room.on('timer', 'round', () => endRound()); // register on every player room.timeLeft('round'); // ms left (0 once due), or null: show countdowns with it room.clearTimer('round'); ``` - State is for facts, not motion (at most about 10 changes a second; 64 KB in all). - A timer fires exactly once even if the host changes: its handler's `setState` changes and the timer's clear go out together. - Keys starting with `$` in state (`$timers`, `$teams`, …) belong to the SDK. ## Slots and teams ```js const p = room.players.find((q) => q.id === room.me); p.slot // 0…maxPlayers − 1, stable for this player, reused after they leave const COLOURS = ['#ff6b1a', '#52aaff', '#6bff8a', '#ffd84a']; const colour = COLOURS[p.slot % COLOURS.length]; if (room.isHost) room.assignTeams(2); // balanced by count; joiners are placed automatically room.teamOf(playerId); // 0 or 1 (undefined before assignTeams) room.assignTeams(2, { rebalance: true }); // between rounds: even the teams out ``` Use `slot`, not the order of `room.players`, for sides, colours and spawn points. ## Host changes `room.isHost` is true on the player who runs the host's work; it can move to another player at any time (the host left or froze, or its hidden tab couldn't keep its game loop going). Host entities, timers and state carry over by themselves. For anything else, `room.on('host', () => …)` runs on the player who just became host (not for the room's first host: check `room.isHost` at start). `room.on('host_changed', (hostId, previousHostId) => …)` runs on everyone. ## relay.tick: the game loop ```js relay.tick(60, (dt, tick) => { if (room.isHost) stepWorld(dt); moveMyShip(dt); }); // dt in seconds, fixed function frame() { draw(); requestAnimationFrame(frame); } // draw separately ``` - Runs at a fixed rate with a constant `dt`, and keeps running in hidden tabs (a hidden host keeps the game going). After a stall it catches up at most 5 steps. Returns a function that stops it. - Draw in `requestAnimationFrame`; don't simulate there. - Simulate at 60 steps a second if you can: others then see perfectly even motion. ## Debugging `GameRelay.connect({ publicKey, debug: true })` shows an overlay (ping, smoothing delay, messages and bytes per second, entities per kind, host). Warnings print to the console either way, once each, and each one says the fix and names the llms.txt section to read. The full list: - `room.on('playerJoined') is a custom event …; the built-in for a player arriving is room.on('player_joined')` (see Room reference) - `room.send() of x/y more than 20×/s: you're hand-writing sync; use entities (room.define(kind, fields), then its .spawn())` - `room.setState() more than 10×/s: state is for facts (score, round, phase); for things that move use entities (room.define(kind, fields), then its .spawn())` - `room.emit('hit') more than 30×/s: events are for moments; for things that change every frame use entities (room.define(kind, fields), then its .spawn())` - `room.all('shp'): no kind 'shp' is defined; did you mean 'ship'?` - `ship jumped across the map without teleport(): call entity.teleport() when respawning` - `ship fields differ between players: are they on different builds?` - `this room holds 1024 entities, the most it can: new ones from other players are ignored` (remove bullets and effects when they're done) - `request('sit') repeated while the first is still waiting: await the first call instead` - `ship:… belongs to ada; only its owner can change it. Update in a loop over .mine() …` and `ship has no field 'vx'; add it to room.define('ship', …)`: that write was skipped - `this room holds 1024 claims; release keys you're done with` - `the server dropped messages: this player sent more than 120 per second` - `tick loop fell back to setInterval (a worker was blocked by the page's security settings)`: hidden tabs tick slowly - `room.state.x = … changes only your copy`: the host changes state with `room.setState` (see State and timers) - `relay.on('player_joined') never fires: …; it's a room event`: room events go on the room - `room.inviteUrl(): this room is link-only and its link hasn't arrived yet`: `await room.shareLink()` first (see Invite links) Test on a bad network before shipping: `connect({ …, simulate: { latency: 150, jitter: 30, loss: 0.05 } })` (development only). ## Invite links The easiest way to play with friends: put the room code in the page URL and send the link. ```js // Join the room in this page's link (?join=… or ?room=CODE), or start one. const room = (await relay.joinInvite().catch(() => null)) ?? (await relay.createRoom({ maxPlayers: 4 })); history.replaceState(null, '', room.inviteUrl()); // the address bar is now the invite link room.inviteUrl() // this page's URL plus ?room=CODE (a link-only room: ?join=); other params and the hash are kept await room.shareInvite() // share sheet on phones, clipboard elsewhere: 'shared' | 'copied' | 'cancelled' ``` - `relay.joinInvite(url?)` resolves `null` when there is no `?join=` or `?room=` in the URL, and rejects like `joinRoom` (`room_not_found`, `room_full`) when the room is gone or full. An old link points at a room that may have closed, so catch and fall back (above). - Call `shareInvite()` from a click or tap: browsers only allow sharing and copying after a user gesture. It rejects if copying isn't allowed (e.g. a page not served over https). - When the player leaves the room, clear the link: `history.replaceState(null, '', location.pathname)`. ### Short links Every room also has a short link, the same for its life: `https://play.gamerelay.io//` once the game's owner sets a slug and a play URL (dashboard → instance → Short links). It previews in Discord, iMessage and Slack with the room's name (`room.setListing`) and the game's cover image, and sends players to the play URL with `?join=`, which `joinInvite()` joins. ```js const link = await room.shareLink() // 'https://play.gamerelay.io/my-game/ZumXpZzDsgo' (no slug or play URL: this page's URL + ?join=) await relay.joinLink('ZumXpZzDsgo') // join by a link's id (joinInvite() reads ?join= for you) await relay.createRoom({ linkOnly: true }) // a room nobody joins by guessing its code: only by its link await room.setAccess({ linkOnly: false }) // host only: open it to its code again ``` - `shareInvite()` shares the short link (an older server's: the `?room=CODE` one). - A link-only room refuses its code with `room_not_found` (what a wrong code gets), except to a player who already has a seat in it, so a reload still gets back in by code. `room.linkOnly` says which it is, and the `access` event says when it changes. A link-only room is never in room lists or quick match. ## Lobby: tags, room lists, parties ```js // Tags are matchmaking pools, e.g. game modes. Quick match only pairs players with the same tag. const room = await relay.quickMatch({ maxPlayers: 4, tag: 'ctf' }); // Browse public rooms (created with { public: true }) that have free seats. const rooms = await relay.listRooms({ tag: 'ctf' }); // [{ code, players, maxPlayers, tag, createdAt, name, meta, locked, hostName }], up to 50 // A server browser: full and locked rooms too (show them greyed out; joining fails with room_full / locked). const all = await relay.listRooms({ tag: 'ctf', includeFull: true }); const { players } = await relay.online(); // players online in this game right now // Parties: friends queue together (up to 8 members). When the leader creates, joins or // quick-matches a room, every connected member is moved in too. const party = await relay.createParty(); // share party.code await relay.joinParty('H4KQZ'); relay.on('party', (party) => renderParty(party)); // { code, leaderId, members } or null relay.on('party_room', (room) => startGame(room)); // you were moved into a room by your leader await relay.leaveParty(); ``` ## Host controls: a lobby the host runs The room's host can run it like a game server: name it for the room list, lock it when the match starts, change its size, kick, and hand over. Each is host only and returns a promise that rejects with `not_host` for anyone else (check `room.isHost`). ```js // Lobby: show up in the server browser with a name and a little JSON. if (room.isHost) await room.setListing({ name: 'Dunes, 3 laps', meta: { map: 'dunes', phase: 'lobby' } }); // Race start: no one new mid-race. Players in the room stay, and a dropped one can rejoin their seat. if (room.isHost) { await room.setAccess({ locked: true }); await room.setListing({ meta: { map: 'dunes', phase: 'racing', lap: 1 } }); // on a lap change, not every frame } // Back to the lobby: open again, maybe bigger. if (room.isHost) await room.setAccess({ locked: false, maxPlayers: 12 }); // Kick (banned from this room until it closes; { ban: false } lets them back), or hand over. if (room.isHost) await room.kick(playerId, { message: 'No ramming' }); if (room.isHost) await room.transferHost(playerId); room.on('access', ({ locked, maxPlayers }) => renderLobby()); // everyone, the host included room.on('listing', ({ name, meta }) => renderLobby()); ``` - `room.locked`, `room.isPublic`, `room.maxPlayers`, `room.name` and `room.meta` are always current. - `setAccess({ locked, public, linkOnly, maxPlayers })`: omitted options stay as they are. Joining a locked room fails with `locked`, and quick match skips it. `public: false` takes it out of room lists. `maxPlayers` is 1–64, never below the players in the room (disconnected seats count until they time out) and above every `slot` in use: lower it past that and it rejects with `bad_request`. - `setListing({ name, meta })`: `name` up to 48 characters (one line), `meta` any JSON up to 512 bytes; `null` clears one. Rooms list them as `name` and `meta`. Both are written by a player (the host), so render them as text (`textContent`, never `innerHTML`) and treat `meta` as untrusted input: check its fields before you use them. - `setAccess` and `setListing` share a budget of 10 changes in a row, then 1 a second, per room; over it they reject with `rate_limited`. Update on a phase or lap change. - `kick` works like the owner's (see Moderation): the player gets `closed('kicked', message)`, everyone else `player_left` with reason `'kicked'`. The host can't kick itself (`room.leave()`). A player kicked while their connection was down hears it when the SDK reconnects. - Bans are per player id. Anonymous players get a new id in a private window or another browser, so a ban only slows a determined player down. To keep strangers out for good, lock the room (`setAccess({ locked: true })`) once your players are in. - `transferHost` needs a connected player; everyone gets `host_changed` and the new host `host`. - The host role moves on its own when the host leaves or drops (see Host changes), so run these from whoever `room.isHost` is now, e.g. in `room.on('host', …)`. ## Room reference ```js room.id // stable room id (matches webhook payloads) room.code // 4-6 char code other players can join with room.me // your player id room.isHost // true if this client is the host now (see Host changes) room.hostId room.maxPlayers // the host can change it (see Host controls) room.locked // no new players can join room.isPublic // in room lists and quick match room.linkOnly // joined by its short link only, not its code (see Short links) room.name // what room lists show, or null (the host sets name and meta) room.meta room.players // [{ id, name, avatar, joinedAt, connected, slot }] in join order room.state // shared JSON facts; only the host writes (see State and timers) room.renderTime // the server-clock moment others' entities are drawn at room.chatHistory // recent chat lines (see Chat) room.seed // random 32-bit number the server picked, the same for everyone (see Time and fairness) await room.leave() // Host only (see Host controls): each rejects with not_host for anyone else. await room.kick(playerId, { ban, message }) // ban defaults to true await room.setAccess({ locked, public, linkOnly, maxPlayers }) // omitted options stay as they are await room.setListing({ name, meta }) // null clears one await room.transferHost(playerId) room.on('player_joined', (player) => {}) room.on('player_left', (playerId, reason) => {}) // 'left' | 'timeout' | 'kicked' room.on('player_disconnected', (playerId) => {}) // seat kept 30s for reconnect room.on('player_reconnected', (playerId) => {}) room.on('host', () => {}) // you just became host room.on('host_changed', (hostId, previousHostId) => {}) room.on('state', (state, patch, fromPlayerId) => {}) room.on('closed', (reason, message) => {}) // 'left' | 'lost' | 'kicked' | 'closed' (see Moderation) room.on('access', ({ locked, public: isPublic, linkOnly, maxPlayers }, fromPlayerId) => {}) room.on('listing', ({ name, meta }, fromPlayerId) => {}) ``` `on()` returns an unsubscribe function; `off(event, handler)` works too (on `relay` and `room`). ## Signed-in players (optional) By default players are anonymous. If your game has its own accounts, mint player tokens on your backend with the instance's **secret key**. Never ship the secret key to the browser. ```js // your server const res = await fetch('https://gamerelay.io/v1/auth/token', { method: 'POST', headers: { authorization: `Bearer ${process.env.GAMERELAY_SECRET_KEY}` }, body: JSON.stringify({ playerId: user.id, // 1–64 chars of letters, digits and _ . : - ; may not start with "p_" playerName: user.name, playerAvatar: user.avatarUrl, // optional, up to 512 chars; may be an image URL ttlSeconds: 3600, // optional, 60 to 86400 (default 1 hour) claims: { role: user.role }, // optional, up to 1 KB of JSON, delivered with this player's webhook events }), }); // a standard HS256 JWT (iss/aud "gamerelay"), when it expires, and where to connect const { token, expiresAt, wsUrl } = await res.json(); // …send that whole object back to your game, e.g. as the JSON of /my/gamerelay-token // your game: getToken is called again whenever the SDK (re)connects, so return a fresh token. // Return the whole response (then the SDK also connects where the server said), or just the token. const relay = await GameRelay.connect({ getToken: () => fetch('/my/gamerelay-token').then((r) => r.json()) }); ``` ## Player data ```js await relay.storage.set('best', { score: 1200 }); // per player, per game, persisted const best = await relay.storage.get('best'); // null if unset ``` Any JSON value, up to 256 keys per player (keys up to 128 characters). Needs the "Persisted data" setting (on by default). ## Chat ```js room.on('chat', ({ from, name, avatar, text, ts }) => addLine(name, avatar, text)); // includes your own lines room.chat('gg 👍'); // everyone in the room, you included for (const m of room.chatHistory) addLine(m.name, m.avatar, m.text); // last 20 lines when you joined ``` - Max 120 characters (an emoji counts as one), single line: newlines and control characters become spaces. `room.chat` throws `too_large` / `bad_request` before sending. - Name and avatar come from the player's token, never from the message, so nobody can chat as someone else. - Avatars: players from your backend get `playerAvatar` from the token (can be an image URL). Anonymous players may pass `playerAvatar` to `connect` as a short id or emoji (≤32 chars, no URLs); map it to your own art. `room.players[i].avatar` has it too. - Up to 5 lines in a burst, then 1 per second per player (`rate_limited` error event). Chat can be switched off per game in the dashboard. History is kept in memory, not saved. - Render `text` as text (e.g. `textContent`, or canvas `fillText`), never as HTML. ## Leaderboards ```js // Keeps each player's best score per board. Returns { best, rank, improved }. const { rank, improved } = await relay.leaderboard.submit('points', 1200); // Top scores plus your own entry, even if you're outside the top: { order, entries, me }. const { entries, me } = await relay.leaderboard.top('points', { limit: 10 }); // default 10, max 100 for (const e of entries) console.log(e.rank, e.name, e.score); // ties share a rank // Lower is better (race times): set the order on the board's first submit. await relay.leaderboard.submit('fastest-lap', 41.73, { order: 'asc' }); ``` - Board names: 1–32 characters of letters, digits and `_ . : -` (e.g. `level-3`, `daily:2026-09-25`). Up to 32 boards per game; each player has one entry per board. - A board's order (`desc` by default) is fixed by its first submit. Submitting with the other order fails with `bad_request`. - Names come from `playerName` (or your token), updated on every submit. - Scores are sent by the browser, so a determined player can fake one. Fine for casual games. Anonymous players are per tab, so use signed-in players if a board should mean one person, one entry. - **Board rules** make faking a score a hassle: the game's owner can set, per board, a min and max score, a max score per minute the player has spent in rooms of the game (higher-is-better boards), a minimum play time, "only while in a room", and a cooldown between submits (dashboard: instance → Leaderboards, or the account MCP's `set_leaderboard_rules`). A score that breaks one fails with the error code `rejected` and no detail; the reason goes to the instance logs. So submit scores while the player is still in the room they earned them in. - Leaderboards use the same "Persisted data" setting as player data: turning it off disables both. ## Moderation You (the game's owner) can kick a player or close a room; players can't, except the room's host, who can kick with `room.kick` (see Host controls). The owner has three ways: the dashboard (instance → Live lobbies → click a room), the account MCP server (`kick_player`, `close_room`), or your backend with the **secret key**: ```js // your server, e.g. from a chat.message webhook const headers = { authorization: `Bearer ${process.env.GAMERELAY_SECRET_KEY}`, 'content-type': 'application/json' }; await fetch(`https://gamerelay.io/v1/rooms/${code}/kick`, { method: 'POST', headers, body: JSON.stringify({ playerId, message: 'Keep it friendly', ban: true }), // ban defaults to true }); await fetch(`https://gamerelay.io/v1/rooms/${code}/close`, { method: 'POST', headers, body: JSON.stringify({ message: 'Round over' }) }); ``` In the game: ```js room.on('closed', (reason, message) => { if (reason === 'kicked') showToast(message ?? 'You were removed from the room'); if (reason === 'closed') showToast(message ?? 'The room was closed'); backToMenu(); // relay.room is null now; the SDK won't try to rejoin }); ``` - A kicked player is banned from that room until it closes (`ban: false` lets them back); joining it again fails with `banned`. They stay connected and can join or create other rooms. The same goes for a kick by the host. A ban is per player id: an anonymous player in a private window is a new player, so lock the room (`room.setAccess({ locked: true })`) to keep people out reliably. - Everyone else sees `player_left` with reason `'kicked'`. Closing sends everyone `closed('closed')` and the room and its state are gone. - Messages are optional, up to 120 characters. Webhooks get `player.left` (reason `kicked`, and `kickedBy`, the host's player id, when the host kicked them) and `room.closed` (reason `closed`). ## Webhooks (your backend) Add a webhook in the dashboard (instance → Webhooks) and gamerelay POSTs JSON to your backend when things happen (up to 5 webhooks per game). Deliveries are retried with backoff for about 8½ hours if your endpoint doesn't answer 2xx within 5 seconds. Production URLs must be `https://` on port 443. Events (subscribe to some or all): | type | data | | --- | --- | | `room.created` | `{ room }` | | `room.closed` | `{ room, reason: 'empty' \| 'closed' }` (`empty` about 2 minutes after the last player leaves; `closed` = by you) | | `player.joined` | `{ room, player, resumed }` | | `player.left` | `{ room, player, reason: 'left' \| 'kicked' \| 'timeout', kickedBy? }` (`kickedBy`: the host, when it kicked them) | | `host.changed` | `{ room, hostId, previousHostId }` | | `score.submitted` | `{ board, order, player, score, best, rank, improved }` | | `chat.message` | `{ room, player, message: { id, text, ts } }` | | `webhook.test` | `{ message }` (the dashboard's "Send test") | `room` is `{ id, code, tag, isPublic, maxPlayers }`; `player` is `{ id, name, claims }` where `claims` are the custom claims from the player's token (or null). Every delivery is an envelope `{ id, type, version: 1, createdAt, instanceId, data }`. Use `id` to ignore duplicates: delivery is at least once, and order isn't guaranteed across retries. Verify every request. The `gamerelay-signature` header is `t=,v1=` where v1 is HMAC-SHA256 of `${t}.${rawBody}` with your webhook's signing secret (`whsec_…`): ```js import { createHmac, timingSafeEqual } from 'node:crypto'; function verifyGamerelay(rawBody, header, secret) { const { t, v1 } = Object.fromEntries(header.split(',').map((kv) => kv.split('='))); if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false; // replay window const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex'); return v1?.length === expected.length && timingSafeEqual(Buffer.from(v1), Buffer.from(expected)); } // e.g. with Hono / Bun: const raw = await req.text(); verify, then JSON.parse(raw) ``` Webhooks are notifications: they can't block or change what happens in the game. ## Connection events The SDK reconnects on its own (exponential backoff) and puts the player back in their room. This also happens after a server restart. Reliable `send`s made while offline are queued (up to 256) and sent on reconnect. Unreliable ones are dropped. ```js relay.on('disconnected', () => showBanner('Reconnecting…')); relay.on('reconnected', () => hideBanner()); relay.on('server_restarting', () => showBanner('Server update, back in a second…')); relay.on('replaced', () => showBanner('Opened in another tab')); // signed-in players only; then the room's 'closed' ('lost') relay.on('error', (err) => console.warn(err.code, err.message)); // e.g. not_host, rate_limited const ms = await relay.ping(); ``` If a reconnect can't get back in, `error` says why, once: `at_capacity` or `quota_exceeded` (it keeps trying), or `unauthorized` (the key was rotated or the game deleted): then it stops, and the room gets `closed` with `'lost'`. Show a message and a way to reload. Calls that wait for a reply (`createRoom`, `storage.get`, `leaderboard.submit`, `ping`, …) reject with a `GameRelayError` whose `code` is `disconnected` if the connection is down or drops first, or `timeout` if no answer comes in 10 s. `createRoom` and `quickMatch` also reject with `too_many_rooms` when a player already has 4 rooms nobody else is in (they close after two idle minutes) or the game is at its plan's room limit: reuse a room for the next round instead of creating one each time. Test on a bad network before shipping. `simulate` is for development only, so remove it for players: ```js const relay = await GameRelay.connect({ publicKey: 'gr_pub_…', simulate: { latency: 150, jitter: 30, loss: 0.05 }, // +150 ms round trip, ±30 ms, 5% of unreliable sends lost }); ``` ## Time and fairness ```js relay.now() // the server's clock (ms), synced from pings when you connect; same timeline for everyone room.on('message', (d, from, meta) => buffer.push({ at: meta.at, d })); // when the server got it // Fair randomness: the server picks room.seed, so the host can't reroll until it wins. const rand = GameRelay.seededRandom(room.seed); // or: import { seededRandom } from '@gamerelay/sdk' const deck = shuffle(cards, rand); // same order for every player room.on('seed', (seed) => startRound(GameRelay.seededRandom(seed))); if (room.isHost) room.reseed(); // next round ``` - `meta.at` and `relay.now()` share the server's clock, so a message's age is `relay.now() - meta.at` regardless of network jitter. Entities already do this for you (`room.renderTime`). - Players own avatars trusts each player with their own position and hits: simple and responsive. When fairness matters, the host simulates from inputs instead (a player can't move faster than the game allows). ## Low level: send and message Everything above is built on a raw relay you can use directly for anything the primitives don't cover (custom protocols, porting an existing game). Don't send positions this way: use entities. ```js room.send(data) // any JSON value to everyone else room.send(data, { to: playerId }) // to one player room.send(data, { reliable: false }) // may be dropped under load room.on('message', (data, fromPlayerId, meta) => {}) // meta.at: server receive time (ms) room.reseed() // host only: new room.seed for a new round ``` ## Rules and limits - Positions and anything that moves go in entities; moments in events; facts in state. - Use `player.slot` for sides, colours and spawn points, not `room.players` order or `isHost` (the host can change mid-game). - Limit: about 120 messages per second per player (the SDK batches its own sends). One event or request is at most about 15 KB, room state 64 KB. Up to 64 players per room, 256 entities per player and 1,024 per room, 1,024 claims per room. - Keep data JSON-only (no Maps, Dates or class instances), nested at most 16 deep. - Player names are cut to 24 characters. Tags are 1–32 characters. Room names (`setListing`) are up to 48 characters and room `meta` up to 512 bytes of JSON. - Parties, chat and persisted data can be turned off per game in the dashboard; their calls then fail with `unsupported`. - Each account has a plan (current limits: `GET https://gamerelay.io/v1/plans`). The free plan is 1 game with up to 16 players online, which suits turn-based games and small multiplayer. `connect()` rejects with `at_capacity` when the account has that many players online, and with `quota_exceeded` when a free account used its monthly traffic. Show a friendly "try again later" instead of retrying in a loop. - **Allowed origins** (dashboard, per game, optional): the sites that may use the public key, e.g. `https://mygame.com`, `https://*.itch.zone` (subdomains), `http://localhost` (any port). Up to 20 entries; set them in the dashboard or with the account MCP's `update_instance`. From any other site, `connect()` rejects: with `unauthorized` and a message that says so for anonymous players, or with `disconnected` for players using `getToken` (the WebSocket is refused). Pages opened from `file://` or in sandboxed iframes send no usable origin and are refused too. When a game won't connect after going live somewhere new, add that site's origin. An empty list allows every site. --- # Docs: JavaScript SDK, REST API and webhooks Source: https://gamerelay.io/docs Documentation # Build multiplayer without a server. GameRelay gives a browser game rooms, lobbies, parties, chat, leaderboards and saves, and an SDK that owns the netcode: you declare what exists and who owns it, and it syncs, smooths and hands over. One player’s browser is the host; if it leaves or hides its tab, another player takes over without losing anything. This page covers the JavaScript SDK and the REST API your backend can call. Using an AI coding agent? Point it at llms.txt (https://gamerelay.io/llms.txt), the same guide in one plain-text file written for LLMs, or connect the MCP server. Open llms.txt (https://gamerelay.io/llms.txt) ## Quickstart - Sign in (https://gamerelay.io/login) and create an instance for your game. - Copy its public key (`gr_pub_…`). It is safe to ship in the game. To stop other sites from reusing it, list your game's sites under Allowed origins on the instance (e.g. `https://mygame.com`, `https://*.itch.zone`; localhost matches any port). - Paste this into your page and open it in two tabs: ``` ``` Each tab is its own player, so two tabs are enough to test. For a whole game, start from one of the three starters in llms.txt (https://gamerelay.io/llms.txt) (see Pick your shape). The arena demo (https://gamerelay.io/demo/) (mixed) and Blocky Cup (https://gamerelay.io/demo/cup.html) (host simulates) are complete games. ## Concepts Instance One per game. It has a public key for the game and a secret key for your backend only, plus settings (chat, parties, saves, room size, allowed origins, short links, direct connections). Room Players who play together, joined by quick match, a code, a short link or a public room list. A player is in one room at a time. Host One player’s browser runs the host’s work: host entities, timers and `room.state`. If it leaves, hides its tab or freezes, GameRelay moves the role to another player, who carries on from there. Entities, events, state Things that move or last (ships, balls, coins) are entities: one owner writes each, everyone else sees it smoothed. Moments (a shot, a hit) are events. Facts (score, round) are state. Use `player.slot` for sides, colours and spawn points, never `isHost`: the host can change mid-game. ## Pick your shape Every multiplayer game mixes the same few pieces. Pick the row closest to yours: llms.txt has a complete starter for each, and the MCP tool `get_starter_game` serves them too. Shape | When | You use Players own avatars | Each player moves their own thing: shooters, racers, co-op, drawing | Entities, events Host simulates | Physics or rules must be fair and shared: ball games | Inputs, host entities, relay.tick Mixed (most games) | Players move themselves; the host owns pickups, enemies, rounds | Entities, host entities, claims, timers ## A real game: racecar racecar (https://github.com/gamerelay/racecar) is our own arcade street racer for the browser, up to 8 players online, built on GameRelay the way any customer would: the public SDK, no private APIs and no game server of its own. Its code is open source (MIT), so it's a good place to see the pieces working together in a real game rather than a starter. - Mixed shape: each player's car is their own entity, and the room's host drives the AI racers as host entities. - Lobbies are rooms: a room list on the title screen, seats and options in room state, a host who runs the lobby. - Parties for player to player: each lobby is also a party, so friends connect directly where their networks allow. - Claims and checks: takedown credit taken exactly once, and everything other players send is checked before it's used. - One clock: traffic, weather and hazards line up on every screen from the server's time. Start with its ONLINE.md (https://github.com/gamerelay/racecar/blob/main/docs/ONLINE.md), which maps every online file to what it does. The examples (https://gamerelay.io/examples) walk through its lobby, AI, shared clock and collisions, and a turn-based chess game, with the code for each. ## JavaScript SDK Load it with a script tag, which exposes `window.GameRelay`, or install it with `npm i @gamerelay/sdk` and `import { GameRelay } from '@gamerelay/sdk'`. It has no dependencies, is fully typed, and is about 32 KB gzipped. ### Connecting `await GameRelay.connect(options)` returns a `Relay`. It reconnects on its own and puts the player back in their room. Option | Type | Description `publicKey` | string | Your instance public key (gr_pub_…). Required unless getToken is set. `playerName` | string | Display name. Cut to 24 characters. `playerAvatar` | string | Anonymous players: a short id or emoji (≤32 chars, no URLs) your game maps to art. `getToken` | () => Promise | Use tokens minted by your backend instead of anonymous ones. Called on every (re)connect. `url` | string | Server origin. Defaults to where the SDK was loaded from, else https://gamerelay.io. `simulate` | { latency, jitter, loss } | Development only: add round-trip ms, ± jitter ms, and drop a share (0–1) of reliable: false sends, to test your netcode on a bad network. `debug` | boolean | Development: show an overlay with ping, smoothing delay, traffic, entities and host. See Debugging. Players are anonymous by default. If your game has accounts, mint tokens on your backend (see Player token): ``` // your server const res = await fetch('https://gamerelay.io/v1/auth/token', { method: 'POST', headers: { authorization: `Bearer ${process.env.GAMERELAY_SECRET_KEY}` }, body: JSON.stringify({ playerId: user.id, playerName: user.name }), }); const { token } = await res.json(); // your game const relay = await GameRelay.connect({ getToken: () => fetch('/my/gamerelay-token').then((r) => r.text()), }); ``` ### Entities ``` const ships = room.define('ship', { x: 'number', y: 'number', h: 'angle', alive: 'flag' }); const me = ships.spawn({ x: 100, y: 100, h: 0, alive: true }); // you own it me.x += 5; // write fields; the SDK sends changes ~20/s for (const s of ships.mine()) s.x += speed * dt; // update only what you own (others' are skipped, with a warning) for (const s of ships.all()) draw(s); // draw every one: yours live, others smoothed ~100 ms behind ships.on('remove', (s, reason) => explode(s)); // 'removed' | 'left' | 'expired' me.teleport(); // after a respawn: everyone snaps, no slide // The host owns the world; the next host carries on from where it was. const coins = room.define('coin', { x: 'number', y: 'number' }); if (room.isHost) coins.spawn({ x, y }, { owner: 'host' }); ``` Field types: `'number'` and `'angle'` are smoothed; `'flag'`, `'text'` and `'value'` (any JSON) change at their moment. Only the owner writes an entity: update over `kind.mine()` and draw over `kind.all()`. Keep local-only values (cooldowns, input) in your own variables, not on the entity. A disconnected player’s entities freeze until they’re back; when they leave, their entities go, unless spawned with `{ onLeave: 'host' }`. In TypeScript the handle is typed from your fields. ### Events, requests & claims ``` room.emit('fire', { x, y, a }); // everyone, you included (echo) room.on('fire', ({ x, y, a }, from) => spawnBullet(from, x, y, a)); room.onRequest('buy', ({ item }, from) => { // on every player: the host answers if (!canAfford(from, item)) throw room.reject('Not enough gold'); return { item }; }); const { item } = await room.request('buy', { item: 'sword' }); if (await room.claim(coin.id)) score++; // exactly one player gets true ``` Events echo to the sender, so handle an action in one place. Requests reject with `rejected`, `host_changed` or `timeout` (5 s). A claim is held until released or its holder leaves; use one key per thing. ### Inputs ``` room.input({ left, right, jump }); // every frame, on every player relay.tick(60, (dt) => { if (!room.isHost) return; for (const p of room.players) move(p.id, room.inputs.get(p.id), dt); // neutral after 500 ms of silence }); ``` `relay.tick(rate, fn)` runs at a fixed step and keeps going in hidden tabs, so a hidden host keeps the game running. Draw in `requestAnimationFrame`. ### State, timers & teams ``` room.setState({ score: [1, 0] }); // host only; everyone gets 'state' room.timer('round', 60_000); // host only: fires once, even across host changes room.on('timer', 'round', endRound); room.timeLeft('round'); // ms left, for countdowns if (room.isHost) room.assignTeams(2); // balanced; joiners placed automatically room.teamOf(playerId); ``` ### Debugging `GameRelay.connect({ …, debug: true })` shows an overlay (ping, smoothing delay, traffic, entities, host). Warnings print either way, once each, with the fix and the `llms.txt` section to read: - `room.on('playerJoined')` and other guessed names: the built-in is `player_joined` - positions sent with `room.send()` more than 20×/s: use entities (`room.define`, then its `.spawn()`) - `room.setState()` more than 10×/s, or one event type more than 30×/s: state and events are for facts and moments - `room.all('shp')` for a kind that was never defined (with the closest name) - a respawn without `teleport()`; fields that differ between players (different builds) - a write to someone else’s entity, an undeclared field or the wrong type: the write is skipped, and your game keeps running - the room at 1,024 entities or 1,024 claims; a `request` repeated while the first waits - the server dropping messages over 120/s; the tick loop falling back to `setInterval` - `room.state.x = …` (only your copy changes: the host uses `room.setState`); a room event on the relay (`relay.on('player_joined')`) `simulate: { latency, jitter, loss }` tests on a bad network. ### Relay Member | Description `relay.playerId` | Your player id. Stable across reloads of the same tab. `relay.room` | The Room you are in, or null. `relay.party` | Your party ({ code, leaderId, members }), or null. `relay.connected` | false while reconnecting. `relay.features` | What this game’s plan includes, sent when you connect: { voice, unreliable }. Reserved flags for plan features; nothing uses them yet. `relay.quickMatch({ maxPlayers?, tag? })` | Join any open public room with the same tag, or create one. Returns a Room. `relay.createRoom({ maxPlayers?, tag?, public?, linkOnly? })` | Create a room (private unless public: true). Share room.code, or with linkOnly: true only its short link gets in. `relay.joinRoom(code)` | Join a room by its code. `relay.joinInvite(url?)` | Join the room in this page’s invite: a short link’s ?join=, or ?room=CODE; null if there is none. See Invite links. `relay.joinLink(link)` | Join the room a short link is for, by its id. The way into a link-only room. `relay.listRooms({ tag?, includeFull? })` | Public rooms with free seats: [{ code, players, maxPlayers, tag, createdAt, name, meta, locked, hostName }], up to 50. includeFull adds full and locked rooms (a server browser). `relay.online()` | Players online in this game right now: { players }. `relay.createParty() / joinParty(code) / leaveParty()` | Parties queue together: when the leader enters a room, members follow. `relay.storage.get(key) / set(key, value)` | Per-player saved data. See Saves & leaderboards. `relay.leaderboard.submit(board, score, opts?) / top(board, opts?)` | High score boards. `relay.ping()` | Round-trip time in milliseconds. `relay.tick(rate, fn)` | The game loop: fn(dt, tick) at a fixed rate with a constant dt (seconds), even in hidden tabs. Returns a stop function. Draw in requestAnimationFrame. `relay.now()` | The server’s clock (ms), synced when you connect. Compare with a message’s meta.at. `GameRelay.seededRandom(seed)` | A seeded random number generator: the same seed gives every player the same numbers. From npm: import { seededRandom }. `relay.on(event, fn) / off(event, fn)` | Subscribe; on() returns an unsubscribe function. `relay.close()` | Disconnect for good: your room gets closed (left), and calls still waiting reject with disconnected. `relay.joinOrCreate({ maxPlayers?, tag?, private?, updateUrl? })` | Experimental. The room in this page’s invite link; if there is none, or it has closed, quick match (private: a new unlisted room). updateUrl writes the invite into the address bar. ### Room Member | Description `room.id / room.code` | Stable id (matches webhooks) and the 4–6 character join code. `room.me / room.hostId / room.isHost` | Your id, the host’s id, and whether you are the host. Re-check on host_changed. `room.players` | [{ id, name, avatar, joinedAt, connected, slot }] in join order. Use slot (stable per player) for sides, colours and spawn points. `room.maxPlayers` | Seats in the room. The host can change it with setAccess. `room.locked / room.isPublic / room.linkOnly / room.name / room.meta` | Host controls, always current: no new joins, listed, joined by its short link only, and what room lists show. `room.kick(playerId, { ban?, message? })` | Host only: remove a player (banned from the room unless ban: false). They get closed('kicked', message). `room.setAccess({ locked?, public?, linkOnly?, maxPlayers? })` | Host only: lock the room to newcomers (joins fail with locked), list or unlist it, make it link-only, resize it (1–64, never below the players in it). `room.setListing({ name?, meta? })` | Host only: a name (48 characters) and JSON (512 bytes) for room lists; null clears one. Up to 10 changes in a row, then 1 a second, with setAccess. A player writes both: render them as text, never HTML. `room.transferHost(playerId)` | Host only: hand the host role to another connected player. `room.define(kind, fields, options?)` | Declare an entity kind; returns its handle (spawn, mine, all, get, on). See Entities. `room.emit(type, data?, { to?, echo? })` | An event to everyone (you included), 'host' or one player. See Events. `room.request(type, data?) / onRequest(type, fn) / reject(reason)` | Ask the host and await its answer. `room.claim(key) / release(key) / holder(key)` | Take something exactly once; the server decides. holder(key) is who has it, or null. `room.input(state) / room.inputs.get(playerId)` | Host-simulated games: controls to the host; the host reads them. `room.state / room.setState(patch)` | Shared JSON facts. Only the host writes (shallow merge, null deletes); it survives restarts. `room.timer(name, ms) / timeLeft(name) / clearTimer(name)` | Host timers that fire exactly once, on whoever is host. `room.assignTeams(n, { rebalance? }) / teamOf(playerId)` | Balanced teams, kept in state; joiners are placed automatically. `room.renderTime` | The server-clock moment others’ entities are drawn at. `room.send(data, { to?, reliable? })` | Low level: any JSON value to everyone else (or one player). Positions go in entities, not here. `room.chat(text) / room.chatHistory` | Send a chat line; the last 20 lines when you joined. `room.seed / room.reseed()` | A random seed the server picked, the same for everyone. The host can start a new round with reseed(). `room.inviteUrl(base?)` | This page’s URL with ?room=CODE (a link-only room: ?join=). Other query params and the hash are kept. `room.shareInvite(text?)` | Share the room’s short link (share sheet on phones, clipboard elsewhere). Call it from a click. Resolves shared, copied or cancelled. `room.shareLink()` | The room’s short link, the same for its life: play.gamerelay.io// once the game has a slug and a play URL (dashboard → Short links), with a preview in chat apps; until then this page’s URL with ?join=. `room.leave()` | Leave the room. ### Invite links Put the room code in the page URL and send the link: friends who open it join your room. An old link can point at a room that has closed, so catch `joinInvite()` and fall back. ``` // Join the room in this page's link (?join=… or ?room=CODE), or start one. const room = (await relay.joinInvite().catch(() => null)) ?? (await relay.createRoom({ maxPlayers: 4 })); history.replaceState(null, '', room.inviteUrl()); // the address bar is now the invite link inviteButton.onclick = () => room.shareInvite(); ``` Every room also has a short link, `play.gamerelay.io//` once you set a slug and a play URL on the instance (Short links): `room.shareLink()`. It previews in chat apps with the room's name and your cover image, and sends players to your play URL with `?join=`, which `joinInvite()` joins. A room made with `linkOnly: true` is joined by its link only, so nobody gets in by guessing its code. ### Events Subscribe with `relay.on(…)` or `room.on(…)`. Your own event names (`room.emit`) use the same `room.on`. Relay event | Arguments | When `disconnected` | () | Connection dropped; reconnecting automatically. `reconnected` | () | Back online, and back in your room. `server_restarting` | () | Server update; you will be reconnected in about a second. `replaced` | () | Signed-in players: the same player connected elsewhere, so this connection stopped. `room` | (room) | Your party leader moved you into a room. `party` | (party | null) | Your party changed. `error` | (err) | Errors not tied to a call, e.g. rate_limited, not_host. Room event | Arguments | When `message` | (data, fromPlayerId, meta) | Data from room.send. meta.at is when the server received it (server clock). `seed` | (seed, fromPlayerId) | The host started a new round: room.seed changed. `state` | (state, patch, fromPlayerId) | The host changed the shared state. `player_joined` | (player) | Someone took a seat. `player_left` | (playerId, 'left' | 'timeout' | 'kicked') | Someone left for good. `player_disconnected` | (playerId) | Connection lost; the seat is held for 30 seconds. `player_reconnected` | (playerId) | They made it back in time. `host_changed` | (hostId, previousHostId) | A new host was elected. You may be it. `host` | () | You just became host (not fired for the room’s first host). `timer` | (name, fn) → fn() | room.on('timer', name, fn): the host's timer fell due (runs on the host). `spawn / remove` | (kind, fn) → fn(entity, reason?) | An entity appeared or went. Prefer the kind handle: ships.on(…). `claimed / released` | (key, playerId) | Someone took, or let go of, a claim. `chat` | ({ id, from, name, avatar, text, ts }) | A chat line, your own included. `closed` | ('left' | 'lost' | 'kicked' | 'closed', message?) | You are no longer in the room. `access` | ({ locked, public, linkOnly, maxPlayers }, fromPlayerId) | The host changed who may join (setAccess). `listing` | ({ name, meta }, fromPlayerId) | The host changed what room lists show (setListing). ### Saves & leaderboards ``` await relay.storage.set('best', { score: 1200 }); const best = await relay.storage.get('best'); // null if unset const { rank, improved } = await relay.leaderboard.submit('points', 1200); const { entries, me } = await relay.leaderboard.top('points', { limit: 10 }); // Lower is better: fix the order on the board's first submit. await relay.leaderboard.submit('fastest-lap', 41.73, { order: 'asc' }); ``` Saves are any JSON value per player, per game. Leaderboards keep each player’s best score; ties share a rank, and `top()` includes your own entry as `me` even outside the top. Both need the “Persisted data” setting (on by default). Scores come from the browser, so they suit casual games. To make faking one a hassle, give a board rules in the dashboard (instance → Leaderboards): a score range, a maximum per minute of play, a minimum play time, “only while in a room” and a cooldown. A score that breaks one fails with `rejected`, and the reason shows in your logs. ### Chat ``` room.on('chat', ({ name, text }) => addLine(name, text)); // render as text, never HTML for (const m of room.chatHistory) addLine(m.name, m.text); room.chat('gg'); ``` Names and avatars come from the player’s token, so nobody can chat as someone else. Lines are single-line, at most 120 characters. ### Errors Calls reject with a `GameRelayError` that has a `code` and a `message`: Code | Meaning `bad_request` | Invalid arguments or message. `unauthorized` | Unknown or revoked key, an expired token, or a site that isn’t in the game’s allowed origins. `rate_limited` | Too many messages, chat lines, new rooms, wrong room codes or token requests. `too_large` | Message, state or chat line over its limit. `room_not_found / room_full` | No room with that code or link (a link-only room refuses its code this way too), or no free seat. `too_many_rooms` | No new room: the game has as many open rooms as its plan allows, or you made 4 that nobody joined (they close after two idle minutes). `already_in_room / not_in_room` | The call needs you out of (or in) a room. `not_host` | Only the host can do that (setState, timers, teams, host entities, kick, setAccess, setListing, transferHost). `host_changed` | room.request: the host changed before answering; ask again. `timeout` | room.request: no answer from the host in 5 seconds; connect(): none from the server in 15; any other call: none in 10. `player_not_found` | No player with that id in the room (sending to one player, kicking one, or handing over the host role). `party_not_found / party_full` | No party with that code, or 8 members already. `banned` | You were kicked from this room. `locked` | The room’s host locked it: nobody new can join. `at_capacity` | The game's account has as many players online as its plan allows. `quota_exceeded` | The game's account used its monthly traffic (Free plan). It resets on the 1st. `unsupported` | The feature is switched off for this game (parties, chat, saves), or the browser can’t do it (shareInvite() without a clipboard). `upgrade_required` | This SDK version is too old for the server: load /sdk/v0/gamerelay.js or update @gamerelay/sdk (see Versions). The SDK stops retrying. `rejected` | The host refused a request (the message is its reason), or a leaderboard score broke one of the board's rules. `disconnected` | Not connected, or the connection dropped before the reply. getToken players also get this from a site outside the allowed origins. `internal` | Something went wrong on the server. Try again; if it keeps happening, contact support. ### Versions `https://gamerelay.io/sdk/v0/gamerelay.js` (and `gamerelay.mjs`) is the `v0` line. It updates in place, and nothing in it is removed or renamed without a deprecation first: the old name keeps working, and warns in the console, for at least 30 days. `/sdk/gamerelay.js` is the same line, forever. To pin one exact version, load it from npm through jsDelivr: `https://cdn.jsdelivr.net/npm/@gamerelay/sdk@/gamerelay.js`. `GameRelay.version` is the running version. When a version gets old, the server says so once in the console (`[gamerelay] …`) and links here. A version it no longer accepts makes `connect()` reject with `upgrade_required`, and the SDK stops retrying: load the `/sdk/v0/` URL above, or `npm i @gamerelay/sdk@latest`. What changed in each release, and what to change in a game, is in the changelog (https://github.com/gamerelay/sdk/blob/main/CHANGELOG.md). ## REST API Base URL `https://gamerelay.io`. Bodies are JSON. Endpoints that act for your game take the instance secret key as `authorization: Bearer gr_sk_…`; call them from your backend only. Errors look like `{ "error": "unauthorized", "message": "Invalid secret key" }` with a matching HTTP status. POST`/v1/auth/anonymous`public key Mints a token for an anonymous player. The SDK calls this for you; you only need it for a custom client. Rate limited per game and IP. `publicKey` required · `playerName` · `playerAvatar` (short id or emoji) · `previousToken` (keeps the same player id) ``` curl -X POST https://gamerelay.io/v1/auth/anonymous \ -H 'content-type: application/json' \ -d '{ "publicKey": "gr_pub_…", "playerName": "ada" }' ``` ``` { "token": "eyJhbGciOiJIUzI1NiIs…", "playerId": "p_x7k2m9q4w1ab", "name": "ada", "avatar": null, "expiresAt": 1790000000000 } ``` Fails with 401 `unauthorized` (unknown key), 403 `origin_not_allowed` (the page’s site isn’t in the game’s allowed origins), or 429 `rate_limited` / `at_capacity` / `quota_exceeded`. POST`/v1/auth/token`secret key Mints a token for one of your own users, returned in the same shape as above. Pass it to the SDK through `getToken`. `playerId` required, 1–64 of `A–Z a–z 0–9 _ . : -`, not starting with `p_` · `playerName` · `playerAvatar` (may be an image URL, ≤512 chars) · `ttlSeconds` 60–86400, default 3600 · `claims` JSON object ≤1 KB, delivered with this player’s webhook events ``` curl -X POST https://gamerelay.io/v1/auth/token \ -H "authorization: Bearer $GAMERELAY_SECRET_KEY" \ -H 'content-type: application/json' \ -d '{ "playerId": "user_42", "playerName": "Ada", "playerAvatar": "https://example.com/ada.png", "ttlSeconds": 3600, "claims": { "role": "admin" } }' ``` Tokens are standard HS256 JWTs (`iss`/`aud` `gamerelay`). A signed-in player is one player across tabs: a second connection takes over and the first gets `replaced`. POST`/v1/rooms/:code/kick`secret key Removes a player from a live room. They get `closed('kicked', message)`; everyone else sees `player_left` with reason `kicked`. `playerId` required · `message` ≤120 chars · `ban` default true (keeps them out of this room until it closes). Bans are per player id, and an anonymous player in a private window gets a new one: to keep strangers out reliably, the host locks the room with `room.setAccess({ locked: true })`. ``` curl -X POST https://gamerelay.io/v1/rooms/K7QM/kick \ -H "authorization: Bearer $GAMERELAY_SECRET_KEY" \ -H 'content-type: application/json' \ -d '{ "playerId": "user_42", "message": "Be nice", "ban": true }' ``` Returns `{ "kicked": "user_42", "banned": true }`, or 404 with `room_not_found` / `player_not_found`. POST`/v1/rooms/:code/close`secret key Closes a room for everyone. Players get `closed('closed', message)` and the room’s state is deleted. `message` ≤120 chars ``` curl -X POST https://gamerelay.io/v1/rooms/K7QM/close \ -H "authorization: Bearer $GAMERELAY_SECRET_KEY" \ -H 'content-type: application/json' \ -d '{ "message": "Server maintenance" }' ``` Returns `{ "closed": "K7QM" }`, or 404 with `room_not_found`. GET`/v1/plans`none The plans and their limits (the table below renders it), and `billing`: whether paid plans can be bought right now. GET`/healthz`none `{ ok, version, ccu, rooms, uptimeS }`. See it live (https://gamerelay.io/healthz). ## Webhooks Add a webhook in the dashboard (instance → Webhooks) and GameRelay POSTs signed JSON to your backend when things happen. Production URLs must be `https://` on port 443. Deliveries that don’t get a 2xx within 5 seconds are retried with backoff for about 8½ hours. Webhooks notify; they can’t block or change the game. Event | data `room.created` | { room } `room.closed` | { room, reason: 'empty' | 'closed' } `player.joined` | { room, player, resumed } `player.left` | { room, player, reason: 'left' | 'kicked' | 'timeout', kickedBy? } (kickedBy: the host's player id, when the host kicked them) `host.changed` | { room, hostId, previousHostId } `score.submitted` | { board, order, player, score, best, rank, improved } `chat.message` | { room, player, message: { id, text, ts } } `webhook.test` | { message } `room` is `{ id, code, tag, isPublic, maxPlayers }`. Every delivery is an envelope: ``` { "id": "evt_…", // use to ignore duplicates (delivery is at least once) "type": "player.joined", "version": 1, "createdAt": 1790000000000, "instanceId": "ins_…", "data": { "room": { … }, "player": { "id": "…", "name": "…", "claims": null }, "resumed": false } } ``` Verify every request. The `gamerelay-signature` header is `t=,v1=`, where `v1` is HMAC-SHA256 of ``${t}.${rawBody}`` with the webhook’s signing secret (`whsec_…`): ``` import { createHmac, timingSafeEqual } from 'node:crypto'; function verifyGamerelay(rawBody, header, secret) { const { t, v1 } = Object.fromEntries(header.split(',').map((kv) => kv.split('='))); if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false; // replay window const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex'); return v1?.length === expected.length && timingSafeEqual(Buffer.from(v1), Buffer.from(expected)); } ``` ## Limits Players per room | 64 Messages per player | ~120 / second (bursts to 240) Frame size | 16 KB (everything sent in one frame) Room state | 64 KB, JSON nested at most 16 deep Entities | 256 per player, 1,024 per room; 32 fields; text 256 characters, value 4 KB Claims | 1,024 per room, keys up to 128 characters Inputs | 1 KB each, ~20 / second to the host Reconnect grace | 30 seconds per seat Offline send queue | 256 reliable messages Chat | 120 characters, 5-line burst then 1 / second, 20 lines of history Saves | 256 keys per player, keys up to 128 characters Leaderboards | 32 boards per game, top() up to 100 entries Names / tags | 24 / 1–32 characters Parties | 8 members Player tokens | 1 hour by default; backend tokens 60 s to 24 h, claims up to 1 KB Webhooks | 5 per game, 5 s timeout, retried for ~8½ hours Allowed origins | 20 per game ## Experimental: player to player Players' browsers can also send broadcasts straight to each other over WebRTC, racing the server's copy, so they arrive sooner. Everything still goes through the server as well, so remote players, metering and state work as before. It's on by default (`p2p: false` turns it off; its old name `lan` still works), it's for rooms of up to 8, and it may change or be removed. It isn't in llms.txt yet. ``` // On by default: each pair takes its best route (direct on one network, else our relay). const relay = await GameRelay.connect({ publicKey }); // Also directly over the internet, between members of your party only: // GameRelay.connect({ publicKey, p2p: { direct: 'party' } }) // Off: everything through the server only. // GameRelay.connect({ publicKey, p2p: false }) room.p2pPeers(); // the players you have a channel with room.p2pRoute(id); // 'direct' | 'relay' | null ``` - Same network: players connect directly, sharing only local addresses (usually hidden names). - Different networks: players connect through our nearest relay, when the server has one. Players don't see each other's IP address. - Party members (`direct: 'party'`): members of your party may also connect directly over the internet, which shows each of them the other's public IP address. Only party members get it, not other players in the room. It also needs the instance's Direct connections setting turned on in the dashboard (off by default). Before using `direct: 'party'`, ask each player in your game, for example "Connect directly with your party? They'll be able to see your IP address.", and only pass it for players who agree. Don't use it in games children under 13 may play. See the Privacy Policy (https://gamerelay.io/privacy#p2p) and Terms (https://gamerelay.io/terms#your-content). ## Plans Limits apply per account, across all of its games. Traffic is what GameRelay sends to your players and resets on the 1st of each month (UTC). On the Free plan, new players are turned away (`quota_exceeded`) once it is used up; players already in a game are never kicked. GameRelay is in alpha, so prices and limits may change. Plan | Price | Games | Players online | Monthly traffic Loading… ## For AI agents llms.txt (https://gamerelay.io/llms.txt) is this guide as one plain-text file, written for LLMs: give it to your agent before it writes GameRelay code. Two MCP servers (Streamable HTTP) cover the rest: - `https://gamerelay.io/mcp`: the guide, a starter game and a key check. No account. - `https://gamerelay.io/mcp/admin`: everything in the dashboard, as you (create instances, change settings, read logs, moderate rooms, manage webhooks). The first call opens your browser to sign in. ``` claude mcp add --transport http gamerelay https://gamerelay.io/mcp claude mcp add --transport http gamerelay-account https://gamerelay.io/mcp/admin ``` In ChatGPT, add either URL as a connector. Clients that support MCP Events, ChatGPT among them, can also watch a game for you and act when something happens: a new #1 on a leaderboard, a room opening or closing, or a webhook that keeps failing. --- # Add multiplayer to a Phaser game Source: https://gamerelay.io/guides/phaser-multiplayer You can make a Phaser game multiplayer without writing or hosting a server. Each player's browser sends its own player, the GameRelay SDK smooths everyone else's, and an invite link gets friends into the same room. It takes about 40 lines. ## How it works Phaser draws and simulates; GameRelay moves the data. You declare the things other players need to see as entities (a player, a ball, a coin). Each entity has one owner. The owner writes it, and everyone else gets it smoothed, about 100 ms behind, so motion looks right on real networks. One player's browser is the host and runs the shared world, if your game has one. If the host leaves, another player takes over. This guide uses Phaser 3. The SDK doesn't depend on Phaser, so the same steps work with any version. ## 1. Install Sign in at gamerelay.io/app (https://gamerelay.io/app), create an instance for your game, and copy its public key (`gr_pub_…`). It's safe to ship in the page. Then install the SDK: ``` npm i phaser @gamerelay/sdk ``` No bundler? Add `` and use `window.GameRelay`. ## 2. Connect and join a room Connect before you start Phaser, so the scene has a room from its first frame: ``` import { GameRelay } from '@gamerelay/sdk'; const relay = await GameRelay.connect({ publicKey: 'gr_pub_…', playerName: 'ada' }); // A friend who opened an invite link joins that room; anyone else is matched with anyone. const room = (await relay.joinInvite().catch(() => null)) ?? (await relay.quickMatch({ maxPlayers: 4 })); history.replaceState(null, '', room.inviteUrl()); // the address bar is now the invite link // Declare what other players need to see. Same call in every player's game. const players = room.define('player', { x: 'number', y: 'number', flip: 'flag' }); ``` ## 3. Sync players in your scene Spawn your own player in `create()`. In `update()`, copy your sprite's position to it, and draw a sprite for every other player: ``` import Phaser from 'phaser'; class Play extends Phaser.Scene { create() { this.cursors = this.input.keyboard.createCursorKeys(); this.hero = this.add.rectangle(100, 300, 24, 24, 0xff6b1a); this.me = players.spawn({ x: this.hero.x, y: this.hero.y, flip: false }); // yours to write this.others = new Map(); // entity id → the sprite that draws it players.on('remove', (p) => { this.others.get(p.id)?.destroy(); this.others.delete(p.id); }); } update(_time, deltaMs) { // Move your own player however your game does it... const dt = deltaMs / 1000, c = this.cursors; const dx = (c.right.isDown ? 1 : 0) - (c.left.isDown ? 1 : 0); const dy = (c.down.isDown ? 1 : 0) - (c.up.isDown ? 1 : 0); this.hero.x += dx * 220 * dt; this.hero.y += dy * 220 * dt; // ...then copy it to your entity. The SDK sends what changed, about 20 times a second. this.me.x = this.hero.x; this.me.y = this.hero.y; if (dx) this.me.flip = dx < 0; // Draw everyone else. Their positions are already smoothed. for (const p of players.all()) { if (p.mine) continue; let sprite = this.others.get(p.id); if (!sprite) { sprite = this.add.rectangle(p.x, p.y, 24, 24, 0x4fc3f7); this.others.set(p.id, sprite); } sprite.setPosition(p.x, p.y); } } } new Phaser.Game({ type: Phaser.AUTO, width: 800, height: 600, backgroundColor: '#111', scene: Play }); ``` Open the page in two browser tabs. Each tab is its own player, so you can test alone. Copy the address bar to a friend and they join your room. ## Arcade Physics and Matter Keep physics on your own player as usual and copy its position after the step. Other players' sprites should have no physics of their own, or physics and the smoothing fight each other and the sprites jitter: ``` // Remote players: drawn where the SDK says, never pushed by physics. sprite = this.physics.add.sprite(p.x, p.y, 'hero'); sprite.body.setAllowGravity(false); sprite.body.moves = false; // setPosition() moves it; the physics step leaves it alone ``` If players must collide with each other fairly (a ball both can push), the host should simulate it. Let the host own that object and have other players send inputs. The SDK guide's "host simulates" starter (https://gamerelay.io/llms.txt) shows the pattern. ## Moments, scores and the rest Things that happen once go in events, not entities: ``` // A moment everyone sees, sender included: a jump, an emote, a shot. room.emit('shoot', { x: this.hero.x, y: this.hero.y, dir: this.me.flip ? -1 : 1 }); room.on('shoot', ({ x, y, dir }, from) => this.spawnBullet(x, y, dir, from)); ``` Leaderboards, saved player data and chat are built in too: ``` const { rank } = await relay.leaderboard.submit('points', score); const { entries } = await relay.leaderboard.top('points', { limit: 10 }); ``` ## Common pitfalls - Don't send positions as messages. Write them to entities. The SDK batches, smooths and limits them for you, and late joiners get them automatically. - Only write what you own. Writes to another player's entity are skipped with a console warning. To affect someone else, send them an event. - Hidden tabs. Browsers pause Phaser's loop in a background tab. That's fine for your own player, but a host simulating the world should use `relay.tick(60, (dt) => …)`, which keeps running. - Respawns. Set the new position and call `this.me.teleport()` in the same frame, so others see a jump, not a slide across the map. - Going live. Add your site under Allowed origins on the instance, so nobody else can use your key. ## Try it GameRelay has a free plan (https://gamerelay.io/#plans), no card needed. Get a public key (https://gamerelay.io/login), then follow the quickstart (https://gamerelay.io/docs#quickstart), or point your AI coding agent at llms.txt (https://gamerelay.io/llms.txt). --- # Add multiplayer to a Three.js game Source: https://gamerelay.io/guides/threejs-multiplayer You can make a Three.js scene multiplayer without writing or hosting a server. Each player writes their own position and heading, the GameRelay SDK sends and smooths them, and your render loop draws everyone. ## How it works Three.js renders; GameRelay syncs. Anything other players need to see is an entity with a few typed fields. The owner writes it, and everyone else reads a smoothed copy, drawn about 100 ms in the past so it moves evenly on real networks. You don't write interpolation, send rates or reconnect logic. ## 1. Install Create an instance at gamerelay.io/app (https://gamerelay.io/app) and copy its public key (`gr_pub_…`). Then: ``` npm i three @gamerelay/sdk ``` ## 2. Connect and declare your entities ``` import * as THREE from 'three'; import { GameRelay } from '@gamerelay/sdk'; const relay = await GameRelay.connect({ publicKey: 'gr_pub_…' }); const room = (await relay.joinInvite().catch(() => null)) ?? (await relay.quickMatch({ maxPlayers: 8 })); history.replaceState(null, '', room.inviteUrl()); // share the address bar to invite friends // x and z on the ground, h the heading. 'angle' fields are smoothed the short way round. const players = room.define('player', { x: 'number', z: 'number', h: 'angle', color: 'number' }); ``` ## 3. Set up the scene and spawn your player ``` const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.1, 200); camera.position.set(0, 12, 14); camera.lookAt(0, 0, 0); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(innerWidth, innerHeight); document.body.append(renderer.domElement); scene.add(new THREE.HemisphereLight(0xffffff, 0x222222, 2)); scene.add(new THREE.GridHelper(40, 40)); // Your player. The slot (0, 1, 2…) is stable for the room, good for colours and spawn points. const slot = room.players.find((p) => p.id === room.me)?.slot ?? 0; const colors = [0xff6b1a, 0x4fc3f7, 0x9ccc65, 0xffd54f, 0xba68c8, 0xf06292]; const me = players.spawn({ x: slot * 2 - 4, z: 0, h: 0, color: colors[slot % colors.length] }); ``` ## 4. Move yours, draw everyone's Simulate in `relay.tick` at a fixed step and render in `setAnimationLoop`. Your own entity updates at once; other players' come in smoothed: ``` const keys = new Set(); addEventListener('keydown', (e) => keys.add(e.key)); addEventListener('keyup', (e) => keys.delete(e.key)); addEventListener('blur', () => keys.clear()); // Simulate at a fixed rate; it keeps running when the tab is hidden. relay.tick(60, (dt) => { const turn = (keys.has('ArrowLeft') ? 1 : 0) - (keys.has('ArrowRight') ? 1 : 0); const go = (keys.has('ArrowUp') ? 1 : 0) - (keys.has('ArrowDown') ? 1 : 0); me.h += turn * 2.5 * dt; me.x -= Math.sin(me.h) * go * 6 * dt; me.z -= Math.cos(me.h) * go * 6 * dt; }); // Draw every player, yours included, in the render loop. const meshes = new Map(); // entity id → mesh players.on('remove', (p) => { const mesh = meshes.get(p.id); if (mesh) scene.remove(mesh); meshes.delete(p.id); }); renderer.setAnimationLoop(() => { for (const p of players.all()) { let mesh = meshes.get(p.id); if (!mesh) { mesh = new THREE.Mesh(new THREE.ConeGeometry(0.5, 1.2, 12), new THREE.MeshStandardMaterial({ color: p.color })); mesh.rotation.order = 'YXZ'; mesh.rotation.x = -Math.PI / 2; // point the cone forward scene.add(mesh); meshes.set(p.id, mesh); } mesh.position.set(p.x, 0.5, p.z); mesh.rotation.y = p.h; } renderer.render(scene, camera); }); ``` Open two tabs to see two players. Send the address to a friend to play together. ## Send rate and precision Entities send what changed about 20 times a second, rounded to 0.01. A racing or action game can ask for more, up to 60 a second: ``` // A fast game: send this kind 30 times a second instead of 20, to 1 cm. const players = room.define('player', { x: { type: 'number', precision: 0.01 }, z: 'number', h: 'angle' }, { rate: 30 }); ``` Rotations: use an `'angle'` field (radians) for a heading, so smoothing turns from 359° to 1° the short way. For full 3D orientation, sync yaw and pitch as two angles; a quaternion isn't smoothed. ## Shared objects and physics A ball, a door or an enemy belongs to the world, not to a player. Spawn it on the host with `{ owner: 'host' }`: the host simulates it, and if the host leaves, the next host carries on from the same state. With a physics engine (cannon-es, Rapier), run the shared bodies on the host only, and give other players' copies kinematic bodies that follow the entity. ## Common pitfalls - Key your meshes by entity id and remove them on `'remove'`, or players who left stay in the scene. - Don't add your own smoothing on top. Other players are already interpolated; lerping again makes them lag and float. - Respawns: set the new position and call `me.teleport()` in the same frame. - Big assets: GameRelay syncs game state, not files. Load models and textures from your own host as usual. ## Try it GameRelay has a free plan (https://gamerelay.io/#plans), no card needed. Get a public key (https://gamerelay.io/login), then follow the quickstart (https://gamerelay.io/docs#quickstart), or point your AI coding agent at llms.txt (https://gamerelay.io/llms.txt). --- # Add multiplayer to a Kaplay game Source: https://gamerelay.io/guides/kaplay-multiplayer You can make a Kaplay game multiplayer without writing or hosting a server. Move your player the Kaplay way, copy its position to a GameRelay entity, and draw everyone else from theirs. The whole game below is about 40 lines. ## How it works Kaplay (formerly Kaboom.js) runs your game; GameRelay keeps players in sync. Anything others need to see is an entity with typed fields. Its owner writes it; everyone else reads a smoothed copy. One player's browser is the host for shared things like coins and enemies, and if it leaves, another player takes over with the same state. ## 1. Install Create an instance at gamerelay.io/app (https://gamerelay.io/app) and copy its public key (`gr_pub_…`). Then: ``` npm i kaplay @gamerelay/sdk ``` Using Kaplay from a script tag instead? Add `` too, and use `GameRelay.connect` from a ` ``` Then follow the guide for your engine (Phaser (https://gamerelay.io/guides/phaser-multiplayer), Three.js (https://gamerelay.io/guides/threejs-multiplayer), Kaplay (https://gamerelay.io/guides/kaplay-multiplayer)) or the quickstart (https://gamerelay.io/docs#quickstart). ## 2. Get players into the same room On your own site, the easiest way to invite friends is a link with the room in it. On itch.io the address bar shows your itch.io page, not the game's frame, so a link with `?room=` doesn't reach the game. Use quick match for strangers and short room codes for friends instead: ```

``` For a server browser, tag rooms by game mode and list them with `relay.listRooms(tag)`; see the docs (https://gamerelay.io/docs#sdk-room). ## 3. Upload - Zip your game with `index.html` at the top level of the zip. - On itch.io, create a new project, set Kind of project to HTML, upload the zip and tick This file will be played in the browser. - Set the embed size to your game's canvas size, and save as a draft while you test. ## 4. Lock your key to itch.io On the instance in the dashboard, add `https://*.itch.zone` under Allowed origins, plus `http://localhost` for testing on your machine. Other sites can then no longer use your key. If the game won't connect after uploading, this list is the first thing to check: the origin is `itch.zone`, not `itch.io`. ## 5. Test it Open your project's page in two browser windows and join the same room. Anonymous players are per tab, so two windows are two players. Then publish. ## Tips - Empty rooms are the real problem for a small game. Show how many players are online with `relay.online()`, offer a single-player mode while people wait, and let quick match fill rooms. - Leaderboards work on itch.io too (`relay.leaderboard.submit`), and give players a reason to come back without anyone else online. - Game jams: multiplayer games stand out in a jam, and a hosted backend means the game still works after the jam ends. ## Try it GameRelay has a free plan (https://gamerelay.io/#plans), no card needed. Get a public key (https://gamerelay.io/login), then follow the quickstart (https://gamerelay.io/docs#quickstart), or point your AI coding agent at llms.txt (https://gamerelay.io/llms.txt). --- # Turn-based multiplayer: chess Source: https://gamerelay.io/examples/turn-based-chess From Chess, a real game built on GameRelay · play it (https://asleepace.com/games/Q54DW2) A turn-based game needs one place where the rules are applied, so two players can't make conflicting moves. With GameRelay that place is the room's host: one player's browser, picked by the SDK. Players ask the host to make a move, the host checks it against the rules, and the result goes into the room's state, which every player draws from. There's no server code. ## Why the host, and why state If each player applied their own moves, a slow message or a modified page could leave two boards that disagree. The host is the referee: it alone writes the game, so there's one truth. The truth lives in `room.state`, not in the host's variables, because the host can change at any time (a closed tab, a lost connection). The next host gets the state automatically and carries on mid-game. ## 1. Keep the whole game in state Everything a player needs to draw the game, and everything a new host needs to continue it: ``` // room.state, written only by the host: { board, // the position (whatever your rules code uses) turn: 'w', // whose move: 'w' or 'b' seats: { w: 'p1', b: 'p2' }, // player ids; null while a seat is empty moves: ['e4', 'e5'], // the move list, for everyone's sidebar last: ['e2', 'e4'], // to highlight the last move status: 'waiting', // 'waiting' | 'playing' | 'over' winner: null, reason: null, // 'w' | 'b' | null, and why ('checkmate', 'resignation', …) } ``` State is a small JSON document (up to 64 KB) for facts, not motion. A chess position and move list fit easily. ## 2. Seat the players on the host The host gives out the two seats as players arrive, and frees a seat when its player leaves. A player who only disconnected keeps their seat: the SDK holds it for 30 seconds while they reconnect. ``` // Host: fill empty seats from the players in the room, and start once both are filled. function seatPlayers() { if (!room.isHost) return; const st = room.state; if (!st.board) { room.setState(newGame({ w: room.me, b: null })); // the first host sits down as white return seatPlayers(); } // A player who left gives up their seat. A disconnected one keeps it while they reconnect. const present = new Set(room.players.map((p) => p.id)); const seats = { ...st.seats }; for (const c of ['w', 'b']) if (seats[c] && !present.has(seats[c])) seats[c] = null; for (const c of ['w', 'b']) { if (seats[c]) continue; const free = room.players.find((p) => p.id !== seats.w && p.id !== seats.b); if (free) seats[c] = free.id; } const patch = {}; if (seats.w !== st.seats.w || seats.b !== st.seats.b) patch.seats = seats; if (st.status === 'waiting' && seats.w && seats.b) patch.status = 'playing'; if (Object.keys(patch).length) room.setState(patch); render(); } room.on('player_joined', seatPlayers); room.on('player_left', seatPlayers); room.on('host_changed', seatPlayers); // a new host checks the seats too seatPlayers(); ``` ## 3. Moves are requests to the host A `request` asks the host and waits for its answer. The host's handler checks whose turn it is and whether the move is legal, then writes the new position. A rejected move comes back to the player as an error with the reason. ``` // Every player registers the handler. Whoever is host when a move arrives runs it. room.onRequest('move', ({ from, to, promo }, playerId) => { const st = room.state; const colour = st.seats.w === playerId ? 'w' : st.seats.b === playerId ? 'b' : null; if (!colour) throw room.reject("You're watching, not playing"); if (st.status !== 'playing' || st.turn !== colour) throw room.reject('Not your turn'); const want = promo ?? 'Q'; // a pawn reaching the last rank becomes a queen unless the player picked const m = legalMoves(st.board).find((x) => x.from === from && x.to === to && (!x.promo || x.promo === want)); if (!m) throw room.reject('Illegal move'); const board = apply(st.board, m); room.setState({ board, turn: board.turn, moves: [...st.moves, san(st.board, m)], last: [from, to], drawOffer: null, ...outcome(board) }); render(); // the host's own setState doesn't fire 'state' on the host }); // A click on a square: ask the host, show its answer. async function play(from, to, promo) { try { await room.request('move', { from, to, promo }); } catch (err) { if (err.code === 'rejected') flash(err.message); // 'Illegal move', 'Not your turn' // 'host_changed': the host left mid-move, so try again; 'timeout' or 'disconnected': show it } } room.on('state', () => render()); // everyone else redraws from the new state ``` The real chess game sends moves as plain messages to the host (`room.send`) and has the host ignore illegal ones. Requests, shown here, do the same and also tell the player why. They also run the host's own moves directly, so the host needs no special case. ## 4. Draw it from state ``` // Each player works out their side from the state, never from join order. const myColour = () => (room.state.seats?.w === room.me ? 'w' : room.state.seats?.b === room.me ? 'b' : null); const flipped = myColour() === 'b'; // black sees the board from its side ``` ## Things to get right - Re-render on the host after writing. `room.setState` fires `state` on everyone else, not on the host that wrote it. - Check the seat, not just the turn. A third player in the room (a spectator) can send requests too. - Seats from state, colours from seats. Never from the order of `room.players`, which changes as people leave and join. - Clocks. For timed games, use `room.timer('turn', ms)` on the host. It fires once, even if the host changes, and `room.timeLeft('turn')` drives everyone's countdown. - The host is trusted. The referee is a player's browser, so it suits games among friends and casual play. Ranked play with something at stake needs a referee you run yourself. Next: getting two players into a game, draw offers and rematches (https://gamerelay.io/examples/match-and-rematch-chess), from the same chess game. ## Try it GameRelay has a free plan (https://gamerelay.io/#plans), no card needed. Get a public key (https://gamerelay.io/login), then follow the quickstart (https://gamerelay.io/docs#quickstart), or point your AI coding agent at llms.txt (https://gamerelay.io/llms.txt). --- # Quick match, room codes, draw offers and rematches Source: https://gamerelay.io/examples/match-and-rematch-chess From Chess, a real game built on GameRelay · play it (https://asleepace.com/games/Q54DW2) The parts of a two-player game around the game itself: getting two people into the same room, and what happens after a move. From a real chess game: quick match with strangers, a room code or link for a friend, draw offers, resignations and rematches that swap colours. ## 1. Connect once Connect when the player first presses a button, and keep the connection for the whole visit. The SDK reconnects by itself after a dropped connection, and tells you so you can show it: ``` const relay = await GameRelay.connect({ publicKey: 'gr_pub_…', playerName, playerAvatar: '♞' }); relay.on('disconnected', () => setBanner('Reconnecting…')); // the SDK reconnects by itself relay.on('reconnected', () => setBanner(null)); relay.on('replaced', () => setBanner('This game is open in another tab.')); // signed-in players only ``` ## 2. Three ways into a game - Quick match puts you with anyone else waiting. The `tag` is the pool: only players with the same tag are matched, so one key can serve several games or modes. - Create a room and share its short code. - An invite link carries the code, so a friend lands in your game with one tap. ``` // Three buttons on the title screen, plus a friend's invite link. async function enter(how) { try { const room = how === 'quick' ? await relay.quickMatch({ maxPlayers: 2, tag: 'chess' }) // anyone else waiting for chess : how === 'create' ? await relay.createRoom({ maxPlayers: 2 }) // share room.code with a friend : await relay.joinRoom(codeInput.value.trim().toUpperCase()); attach(room); } catch (err) { const why = { room_not_found: 'No game with that code.', room_full: 'That game is full.', banned: "You can't join that game." }; setLobbyStatus(why[err.code] ?? 'Could not connect.'); } } function attach(room) { history.replaceState(null, '', room.inviteUrl()); // the address bar is now the invite link for (const m of room.chatHistory) addChat(m); // the last lines of chat, for late joiners room.on('chat', addChat); room.on('closed', (reason, message) => { history.replaceState(null, '', location.pathname); showLobby(message || { kicked: 'You were removed from the game.', closed: 'The game was closed.', lost: 'Lost the connection.' }[reason]); }); // …then the seats and moves from the turn-based example } // Opened from an invite link (?join=… or ?room=…, which inviteUrl() writes)? Go straight in. // joinInvite() resolves null when the page has no link, and rejects if that room is gone or full. relay.joinInvite().then((room) => room && attach(room)).catch(() => setLobbyStatus('That game has ended.')); ``` Give each error its own message. A code typed wrong (`room_not_found`) and a game already full (`room_full`) need different help. ## 3. Draw offers, resignations and rematches These are agreements between two players, so they go through the host like moves do. Each is a request, and the offer itself is a field in state, so both players see it and a new host remembers it: ``` // More fields in room.state: drawOffer ('w' | 'b' | null), rematch (colours who asked), round. // commit(): write state and redraw. The host's own setState doesn't fire 'state' on the host. function commit(patch) { room.setState(patch); render(); } room.onRequest('draw', (_, playerId) => { const st = room.state; const colour = colourOf(st, playerId); if (!colour || st.status !== 'playing') throw room.reject('No game to draw'); // The other side offered already? Then this accepts. Otherwise it's an offer. if (st.drawOffer === other(colour)) commit({ status: 'over', winner: null, reason: 'agreement', drawOffer: null }); else commit({ drawOffer: colour }); }); room.onRequest('resign', (_, playerId) => { const colour = colourOf(room.state, playerId); if (!colour || room.state.status !== 'playing') throw room.reject('No game to resign'); commit({ status: 'over', winner: other(colour), reason: 'resignation', drawOffer: null }); }); room.onRequest('rematch', (_, playerId) => { const st = room.state; const colour = colourOf(st, playerId); if (!colour || st.status !== 'over' || st.rematch.includes(colour)) return; const rematch = [...st.rematch, colour]; if (rematch.length < 2) return commit({ rematch }); // waiting for the other player // Both asked: a new game with the colours swapped. commit({ ...newGame({ w: st.seats.b, b: st.seats.w }), status: 'playing', round: st.round + 1 }); }); // And a move clears a standing offer: add drawOffer: null to the move's state. ``` A rematch needs both players to ask. The first ask is stored in state, and the second starts a new game with colours swapped, in the same room. Nobody has to share a code again. ## Things to get right - Clear the link when the game ends. Otherwise a reload tries to rejoin a room that has closed. - Chat is text. Render chat lines with `textContent`, never as HTML: another player wrote them. - Tabs. An anonymous player is per tab, so two tabs are two players: handy for testing alone. A signed-in player is one player across tabs. A second tab takes over, and the first gets `replaced` and should say so instead of looking frozen. - Two seats, two players. `maxPlayers: 2` keeps a third person out. For spectators, make the room bigger and seat only two (the seat check in the moves handler already handles it). Before this: the moves themselves (https://gamerelay.io/examples/turn-based-chess), checked by the host. ## Try it GameRelay has a free plan (https://gamerelay.io/#plans), no card needed. Get a public key (https://gamerelay.io/login), then follow the quickstart (https://gamerelay.io/docs#quickstart), or point your AI coding agent at llms.txt (https://gamerelay.io/llms.txt). --- # A lobby that lives in a room Source: https://gamerelay.io/examples/lobby-racecar From racecar, a real game built on GameRelay · the source (https://github.com/gamerelay/racecar/blob/main/src/lobby/relay.ts) racecar's online lobby is a GameRelay room. The lobby's seats, options and phase live in the room's state. Every change is a request to the SDK's host, which runs the same pure `apply` function a local lobby uses. The room's listing feeds the server browser on the title screen. There's no lobby server. ## Two hosts, on purpose This is the idea that makes the rest simple. The lobby's host is a player in the game: the one who made the lobby, picks the map and starts the race. The SDK's host is whichever browser holds the room's write role. It moves on its own when a tab reloads or a connection drops. The SDK's host only applies actions. The rules inside `apply` check the lobby's host. So it doesn't matter which browser holds the SDK's role: the player who made the lobby still runs it, and a reload never hands the lobby to someone else. ## 1. The rules as one pure function ``` // lobby.ts: the rules, as one pure function. No SDK in here, so it's easy to test. // Returns the new lobby, or null if the action isn't allowed. export function apply(lobby, actor, action) { const isHost = actor === lobby.host; // the lobby's host: the player who made it switch (action.type) { case 'seat': if (!isHost) return null; /* an AI in a seat, or an open seat (never a player's) */ break; case 'options': if (!isHost) return null; /* map, laps, traffic… */ break; case 'car': /* your own seat's car and paint */ break; case 'ready': /* your own ready flag */ break; case 'kick': if (!isHost) return null; /* free that seat */ break; case 'start': if (!isHost) return null; /* phase: racing, at: the start time */ break; // … } return next; } ``` Keeping the rules free of networking means the same function runs a local lobby (against AIs, offline) and an online one. The tests cover it without a server. ## 2. The lobby in the room ``` // relay.ts: a lobby is a room. The lobby lives in room.state.lobby; only the SDK's host writes it. const room = await relay.createRoom({ maxPlayers: 8, tag: 'racecar', public: true }); commit(createLobby(room.code, me)); // Host: write the lobby, redraw (the host's own setState doesn't fire 'state' on the host), // and keep the server browser up to date. function commit(lobby) { room.setState({ lobby }); drawLobby(lobby); relist(); } // Everyone registers the handler; whoever is the SDK's host when an action arrives applies it. room.onRequest('lobby', (data, from) => { const action = readAction(data, from); // check it first: another player sent it const next = action && apply(room.state.lobby, from, action); if (!next) return null; // refused: the sender's screen stays as it was commit(next); return next; }); // Any player, any action. The host's own requests run its handler directly. async function send(action) { return room.request('lobby', action); } room.on('state', () => drawLobby(room.state.lobby)); ``` `readAction` checks the shape of what arrived before `apply` sees it. An action comes from another player's page, which could be modified, so it's checked like any input from outside. ## 3. A server browser from room listings ``` // What the server browser shows: a summary of the lobby, at most once a second. // (Listings and access share a budget: 10 changes in a row, then one a second.) let listTimer = null; function relist() { if (listTimer) return; // one is already on its way: it sends the latest listTimer = setTimeout(() => { listTimer = null; if (!room.isHost) return; // host only const { name, ...meta } = summarize(room.state.lobby); // map, laps, seats, phase, who can join room.setListing({ name, meta }); }, 1000); } // The title screen's server browser: full and locked rooms too, shown greyed out. const rooms = await relay.listRooms({ tag: 'racecar', includeFull: true }); for (const r of rooms) addRow(r.name ?? 'New lobby', r.meta?.map ?? '?', `${r.players}/8`, r.locked); // meta is null until listed ``` A listing is a room's name and a little JSON, shown by `listRooms` without joining the room. The host keeps it up to date as the lobby changes, at most once a second. ## 4. Leaving, and tidying up ``` // The SDK host's chores: when someone leaves for good, free their seat. When the role moves to // you, check for anyone who left while nobody held it. room.on('player_left', () => room.isHost && tidy()); room.on('host_changed', () => room.isHost && tidy()); // The last player out unlists the room, so quick match and the browser don't send anyone into an empty one. if (room.isHost && room.isPublic && room.players.every((p) => p.id === room.me)) { await room.setAccess({ public: false }); } await room.leave(); ``` ## Also in racecar's lobby - Invite-only lobbies use `createRoom({ linkOnly: true })`: nobody gets in by guessing the code, only by the lobby's link. - Each lobby is also a party, so its players connect to each other directly where their networks allow (`src/lobby/party.ts`). - Pings per player. Each player measures their own ping and tells the room, so the lobby can show everyone's, like a console's party screen (`src/lobby/presence.ts`). - The race keeps your seat. The race page joins the same room again, and a reload resumes the same player, so one room lasts through many races. The whole lobby is in src/lobby (https://github.com/gamerelay/racecar/tree/main/src/lobby), and ONLINE.md (https://github.com/gamerelay/racecar/blob/main/docs/ONLINE.md) maps each file to its job. ## Try it GameRelay has a free plan (https://gamerelay.io/#plans), no card needed. Get a public key (https://gamerelay.io/login), then follow the quickstart (https://gamerelay.io/docs#quickstart), or point your AI coding agent at llms.txt (https://gamerelay.io/llms.txt). --- # AI players the host drives Source: https://gamerelay.io/examples/host-ai-racecar From racecar, a real game built on GameRelay · the source (https://github.com/gamerelay/racecar/blob/main/src/net/rivals.ts) In racecar, up to 7 AI drivers race alongside the players. Every screen must see the same AIs in the same places, so one browser drives them: the room's host. The host sends each AI car as a host entity, and everyone else draws it like another player's car. When the host leaves, the next one carries on driving them from where they are. ## Why the host drives them If every screen ran its own AIs, they'd drift apart within seconds: a tiny timing difference changes every overtake. One driver means one truth. The AI keeps no memory beyond the car's position and speed (and a little more, below), so any player can take over the driving at any moment. ## 1. One entity kind for the AIs ``` // One kind for the AI cars: a car's fields, plus which lobby seat it's in and which race. const rivals = room.define('rival', { x: 'number', z: 'number', h: 'angle', speed: 'number', seat: 'number', race: 'text' }, { rate: 30 }); ``` The `race` field matters because host entities outlive a race: the room stays up between races, and the last race's AIs belong to that race, not this one. ## 2. The host writes them ``` // After each step of the race, on the host: write every AI car as it is now. function afterStep() { if (!room.isHost) return; const mine = rivals.mine(); // host entities count as yours while you're the host for (const seat of aiSeats) { const car = sim.carInSeat(seat); let e = mine.find((r) => r.seat === seat && r.race === raceId); if (!e) e = rivals.spawn({ seat, race: raceId }, { owner: 'host' }); // the first time const jumped = Math.hypot(car.x - e.x, car.z - e.z) > 20; Object.assign(e, { x: car.x, z: car.z, h: car.h, speed: car.speed }); if (jumped) e.teleport(); // a respawn: others see a jump, not a slide across the map } } ``` ## 3. Everyone else follows ``` // Before each step, on everyone else: put each AI car where the host says. function beforeStep() { if (room.isHost) { for (const seat of aiSeats) sim.carInSeat(seat).remote = false; // yours to drive now return; } // Only the host's count: any player could spawn a 'rival', but it isn't theirs to drive. const theirs = rivals.all().filter((e) => e.owner.id === room.hostId && e.race === raceId); for (const seat of aiSeats) { const car = sim.carInSeat(seat); const e = theirs.find((r) => r.seat === seat); if (!e) { car.remote = false; // not sent yet: drive it here, from the same grid, as the host would continue; } car.remote = true; car.setPose(e.x, e.z, e.h, e.speed); } } ``` Until the host's AIs arrive (the race just started, or nobody has connected yet), each screen drives them itself from the same starting grid. Nothing waits on the network. ## 4. When the host changes The SDK moves host entities to the new host by itself. On the next step, `room.isHost` is true on the new host, and its `beforeStep` takes over driving from the positions the old host last sent. racecar also sends what the next driver needs beyond the position: a wreck in progress (how long it has left, and the tumble), the boost meter, and the last safe spot on the road for a respawn. ``` // For a moment, two players can both think they're host and both spawn a seat's AI. // Keep one per seat, and remove the last race's. const keep = new Map(); for (const e of rivals.mine()) { if (e.race !== raceId || keep.has(e.seat)) e.remove(); else keep.set(e.seat, e); } ``` ## Things to get right - Keep the AI's memory in the entity. Anything only in the old host's variables is lost at a handover. If the next host needs it, send it. - Check who owns it. Read the host's entities (`e.owner.id === room.hostId`), not any entity of that kind. - Hidden tabs. A host in a background tab must keep stepping. racecar steps with the SDK's `relay.tick`, which keeps running when the browser pauses animation frames. - Smoothing and prediction. Entities are drawn about 100 ms in the past. racecar turns smoothing off for cars and predicts them forward from `room.renderTime` instead, which suits fast cars. Most games can keep the default smoothing. ## Try it GameRelay has a free plan (https://gamerelay.io/#plans), no card needed. Get a public key (https://gamerelay.io/login), then follow the quickstart (https://gamerelay.io/docs#quickstart), or point your AI coding agent at llms.txt (https://gamerelay.io/llms.txt). --- # One clock for every player Source: https://gamerelay.io/examples/shared-clock-racecar From racecar, a real game built on GameRelay · the source (https://github.com/gamerelay/racecar/blob/main/src/net/clock.ts) Every racer's screen must start the race at the same moment, and see the traffic, rain and hazards in the same places. racecar does it without sending any of them: it puts the race on the server's clock, and makes everything else a function of the race's time. ## Why not each browser's clock `Date.now()` differs between computers by anything from milliseconds to minutes. A race that starts at "now plus 6 seconds" by each player's own clock starts at different times on every screen. `relay.now()` is the server's clock, kept in sync from pings, so it's the same timeline for every player. ## 1. Start at a moment, not on a message ``` // The lobby's host presses Start: green is a few seconds from now, on the server's clock. const START_LEAD_MS = 6000; await send({ type: 'start', at: relay.now() + START_LEAD_MS }); // into the lobby's state, for everyone // Every screen counts down to the same moment. const secondsToGreen = (at) => Math.min(30, Math.max(0, (at - relay.now()) / 1000)); ``` The start time goes into the lobby's state. A player whose copy arrives a little late still goes green at the same moment: they just see a shorter countdown. ## 2. Keep the race time on that clock ``` // Before each step: keep this screen's race time on the server's clock. const SNAP = 0.25; // behind by more than this (s): jump to it const SLEW = 0.05; // otherwise close 5% of the gap each step (about a second to catch up) function syncClock(sim, at, now) { if (sim.phase === 'countdown') { sim.time = sim.goTime - secondsToGreen(at); // nothing moves yet, so set it outright return; } const want = sim.goTime + (now - at) / 1000; const off = want - sim.time; if (off > SNAP) sim.time = want; // a long pause: catch up at once else sim.time += Math.max(-sim.dt / 2, off * SLEW); // small drift: ease it out, never back more than half a step } relay.tick(60, (dt) => { syncClock(sim, lobby.at, relay.now()); sim.step(dt); }); ``` Small differences are eased out over about a second, so nothing visibly jumps. A big gap (a stall of a few frames, a tab that was in the background) is closed at once. ## 3. Make the world a function of time ``` // Traffic, weather and hazards are functions of the race time and the race's seed, // so every screen with the same time has them in the same places, with nothing sent. const truckAt = (t) => pathPosition(truckPath, (t * truckSpeed) % truckPath.length); const raining = (t) => noise(seed, t / 60) > 0.6; ``` This is the part that saves the most work. Things nobody owns (traffic, weather, a falling sign) are worked out from the race time and the race's seed. Two screens at the same time agree on all of it, with no entities and no traffic on the network. ## Things to get right - Players' cars still sync. A clock lines up what's predictable. What players do (steering, crashes) still goes through entities and events. - Seeded randomness only. Anything random in the world must use the race's seed, never `Math.random()`, or screens disagree. - Message ages. Messages carry `meta.at`, the server time they were sent. Their age is `relay.now() - meta.at`, useful for placing an event where it happened. ## Try it GameRelay has a free plan (https://gamerelay.io/#plans), no card needed. Get a public key (https://gamerelay.io/login), then follow the quickstart (https://gamerelay.io/docs#quickstart), or point your AI coding agent at llms.txt (https://gamerelay.io/llms.txt). --- # Hits between players: tell the owner Source: https://gamerelay.io/examples/collisions-racecar From racecar, a real game built on GameRelay · the source (https://github.com/gamerelay/racecar/blob/main/src/net/contact.ts) In a racing or fighting game, players hit each other. But each player's car is an entity that only its owner may move. So when your car hits someone else's, your screen can't push theirs. It tells their owner what happened, and their screen moves it. This is how racecar does bumps, takedowns and traffic hits. ## Why "tell the owner" Each screen runs physics for its own cars only (and the host for the AIs). If your screen also moved other players' cars, two screens would fight over the same car and it would jitter. One owner per car, and messages to that owner, keeps every car smooth and every decision in one place. ## 1. One name for each car ``` // Name every car the same way on every screen, and know who speaks for it. // 'p:' for a player's car, 's:' for an AI (the host drives those). const ownerOf = (name) => (name.startsWith('s:') ? 'host' : name.slice(2)); const speaksFor = (from, name) => (ownerOf(name) === 'host' ? from === room.hostId : from === ownerOf(name)); ``` ## 2. Bumps go to the car's owner ``` // After each step: your car touched another screen's car. Your physics pushed yours; // tell their owner how hard theirs was pushed, and let them move it. const BUMP_EVERY = 0.15; // s, per pair: a contact lasts a few steps for (const hit of contactsThisStep) { if (!mine(hit.a) || mine(hit.b)) continue; // one of yours, one of theirs if (now - (lastSent.get(pair(hit)) ?? -Infinity) < BUMP_EVERY) continue; lastSent.set(pair(hit), now); room.emit('bump', { to: name(hit.b), by: name(hit.a), t: sim.time, dvx: hit.dvx, dvz: hit.dvz }, { to: ownerOf(name(hit.b)), echo: false }); } // On the owner's screen: apply it, unless this screen saw the same contact itself. room.on('bump', (d, from) => { const b = readBump(d); // check the shape and the numbers first if (!b || !speaksFor(from, b.by)) return; // only the other car's owner may say it bumped you if (sawContact(b.to, b.by, b.t, 0.15)) return; // within ±150 ms: already pushed here, once pushCar(b.to, clamp(b.dvx, -30, 30), clamp(b.dvz, -30, 30)); // their word, so capped }); ``` Often both screens see the same contact: both cars touched on both screens. Then each screen pushes its own car and ignores the other's message, so nothing is pushed twice. A contact only one screen saw (the network delay hid it on the other) still reaches the other car through the message. ## 3. Takedowns: the victim decides ``` // The victim's screen decides a wreck (its car, its call), and credits the attacker // by telling the attacker's owner. That screen gives its own car the boost and the points. room.emit('takedown', { victim: name(car), by: name(attacker), t: sim.time }, { to: ownerOf(name(attacker)), echo: false }); room.on('takedown', (d, from) => { const t = readTakedown(d); if (t && speaksFor(from, t.victim)) creditTakedown(t.by); // only the victim's owner can hand out credit }); ``` Whose screen decides a wreck? The victim's: it's their car. The victim's screen then tells the attacker's owner, whose screen gives the credit. Without that message, a takedown would only count on the victim's screen. ## 4. Shared things: claim them Traffic belongs to nobody. When two players hit the same truck at nearly the same time, a claim decides who hit it: the server gives it to exactly one player. ``` // Traffic is the same on every screen (a function of the race time), but a hit happens on // one screen: the one whose car made it. Two players can hit the same truck at once, so claim it. // Your screen has already wrecked it (your car hit it here), so tell everyone else when. if (await room.claim(`traffic:${truck}`)) { room.emit('traffic_hit', { k: truck, t: sim.time }, { echo: false }); } room.on('traffic_hit', ({ k, t }) => wreckTraffic(k, t)); // every other screen wrecks it from then // When the truck is back on the road, let the claim go so it can be hit again. room.release(`traffic:${truck}`); ``` racecar uses the same pattern for breakable things at the roadside. ## Things to get right - Check every message. It came from another player's page. racecar checks the shape and ranges of everything (`src/net/wire.ts`) and caps how much a bump can change your speed. - Check who's speaking. Only the other car's owner may report a bump from it, and only the victim's owner may hand out a takedown. - Send to one player. `emit(…, { to })` sends to that player only (`'host'` for the host). Fewer messages, and nobody else needs them. - The trust model. This is party-grade: a modified page could lie about its own car. That's fine among friends. Competitive games need the host (or a server you run) to referee. ## Try it GameRelay has a free plan (https://gamerelay.io/#plans), no card needed. Get a public key (https://gamerelay.io/login), then follow the quickstart (https://gamerelay.io/docs#quickstart), or point your AI coding agent at llms.txt (https://gamerelay.io/llms.txt). --- # GameRelay vs Playroom Kit Source: https://gamerelay.io/compare/playroom Playroom Kit and GameRelay are the closest of the multiplayer backends for web games: both are hosted, both need no server code, and in both, one player's browser runs the game as host. The differences are in how you sync state, what's built in, and how you pay. ## The short version | GameRelay | Playroom Kit Server code | None | None Model | A host player plus our server, which relays and checks every message | A host player; shared state synced by the service Syncing state | Entities with owners, smoothed for you; events, requests, claims, inputs | Shared and per-player state (`setState`, `myPlayer().setState`), RPCs Engines | Any JavaScript engine (Phaser, Three.js, Kaplay, plain canvas) | JavaScript and React, plus Unity and Godot Built in | Rooms, quick match, room lists, parties, chat, saves, leaderboards, webhooks, host controls | Lobby UI, matchmaking, RPCs, bots, joysticks and gamepads, turn-based mode, stream mode Discord Activities | Not yet | Yes, with dedicated docs For AI agents | llms.txt and two MCP servers (docs, and your account) | llms.txt Pricing | By players online at once and traffic: a free plan (1 game, 16 players online), Basic at $3.99/month | By monthly active users: free for 10 a day, Lite at $10/month with 10,000 MAU Open source | The SDK (MIT) | No ## How you write the game Playroom gives you shared state: you set keys on the room or on a player, and everyone reads them. It's simple and flexible, and you decide how often to write and how to smooth what you read. GameRelay asks you to declare what exists (`room.define('ship', { x: 'number', y: 'number' })`) and who owns each thing. The SDK then decides how often to send, interpolates other players' copies about 100 ms behind, hands the host's objects to a new host when it leaves, and gives late joiners everything. It's more opinionated, and it's built so that code written by an AI model works on a real network on the first try. ## Pricing The models differ. Playroom charges by monthly active users, so a game with many short visits costs more than one with a few regulars. GameRelay charges by players online at the same moment and by traffic, so cost follows how many play together, not how many tried it once. Free tiers: Playroom's covers 10 unique users a day; GameRelay's covers one game with up to 16 players online and 10 GB of traffic a month. See our plans (https://gamerelay.io/#plans) and Playroom's (https://docs.joinplayroom.com/billing). ## Choose Playroom Kit when - You're building a Discord Activity. Playroom supports it today; GameRelay doesn't yet. - You work in Unity or Godot, or want Playroom's ready-made lobby UI, bots or phone-as-controller stream mode. - A small group of regulars plays your game a lot, where per-user pricing comes out cheaper. ## Choose GameRelay when - You want the netcode handled: smoothing, send rates, host handover and late joiners, without tuning. - An AI assistant writes your code. The guide, starters, MCP servers and model-readable warnings are built for it. - You need the platform pieces too: parties, chat, saves, leaderboards with anti-cheat rules, and signed webhooks to your own backend. - Your game gets many short visits, where paying for players online beats paying per user. ## Sources Checked on October 3, 2026 from Playroom's docs (https://docs.joinplayroom.com) and billing page (https://docs.joinplayroom.com/billing). Products change; if something here is out of date, tell us at support@gamerelay.io (mailto:support@gamerelay.io) and we'll fix it. ## Try it GameRelay has a free plan (https://gamerelay.io/#plans), no card needed. Get a public key (https://gamerelay.io/login), then follow the quickstart (https://gamerelay.io/docs#quickstart), or point your AI coding agent at llms.txt (https://gamerelay.io/llms.txt). --- # GameRelay vs Photon for web games Source: https://gamerelay.io/compare/photon Photon is the most widely used multiplayer service for Unity games. GameRelay is built only for browser games written in JavaScript. If your game is a web page rather than a Unity WebGL build, the two feel very different to work with. ## The short version | GameRelay | Photon Made for | Browser games in JavaScript or TypeScript | Unity first; also native SDKs and a JavaScript SDK for Realtime Server code | None | None for Realtime, Fusion and Quantum on Photon Cloud; self-hosted Photon Server is also available Web support | An npm package or a script tag, MIT licensed | A JavaScript SDK for Realtime, Chat and Voice; Fusion and Quantum reach the browser only as Unity WebGL builds Syncing state | Entities with owners, smoothed for you; host handover built in | Realtime: rooms and raw events, you write the sync. Fusion and Quantum: full netcode stacks, in Unity Built in | Rooms, quick match, parties, chat, saves, leaderboards, webhooks, MCP servers | Rooms, lobbies, matchmaking, webhooks; Chat and Voice as separate products Scale | Up to 64 players a room | Very large games, many regions, enterprise plans Pricing | Free plan (1 game, 16 players online), Basic at $3.99/month for 500 players online | Free for development (20 players online); 100 players online for $95 for 12 months; 500 for $95/month ## Photon on the web Photon's strength is Unity. Its high-level netcode, Fusion and Quantum, runs inside Unity, so a browser game gets it only as a Unity WebGL build. For a game written in JavaScript, Photon offers the Realtime SDK: rooms, matchmaking and events. Moving players smoothly, handing over the host and catching up late joiners are left to you. GameRelay starts from the JavaScript side. You declare entities and who owns them, and the SDK does the sending, smoothing, handover and cleanup. It's about 32 KB and has no dependencies. ## Pricing Both price by players online at once. Photon's free tier is for development, and its paid Realtime plans start at 100 players online for $95 a year. GameRelay's free plan can run a small live game, and Basic covers 500 players online for $3.99 a month. See our plans (https://gamerelay.io/#plans) and Photon's (https://www.photonengine.com/pricing). ## Choose Photon when - Your game is made in Unity and the web is one of several platforms. - You need deterministic rollback netcode (Quantum) or a large competitive game at scale. - You need enterprise support, many regions or an SLA. ## Choose GameRelay when - Your game is a web page: Phaser, Three.js, Kaplay, PixiJS or plain canvas. - You want smoothing, host handover and late joiners handled, not just a channel to send events on. - You're a small team or a solo developer and want a free plan that runs a live game, and low prices after it. - An AI assistant writes your code: GameRelay has a guide written for models and MCP servers. ## Sources Checked on October 3, 2026 from Photon's pricing (https://www.photonengine.com/pricing) and SDK (https://www.photonengine.com/sdks) pages. Products change; if something here is out of date, tell us at support@gamerelay.io (mailto:support@gamerelay.io) and we'll fix it. ## Try it GameRelay has a free plan (https://gamerelay.io/#plans), no card needed. Get a public key (https://gamerelay.io/login), then follow the quickstart (https://gamerelay.io/docs#quickstart), or point your AI coding agent at llms.txt (https://gamerelay.io/llms.txt). --- # GameRelay vs Colyseus Source: https://gamerelay.io/compare/colyseus Colyseus is an open-source framework for writing your own authoritative game server in Node.js. GameRelay is a hosted service where you write no server at all. Both are good choices for web games. Which one fits depends on whether you want to write and run that server. ## The short version | GameRelay | Colyseus Server code | None | You write room classes in Node.js (TypeScript) and deploy them Who runs the rules | A host player's browser; our server relays, orders and limits messages | Your server: fully authoritative Syncing state | Entities with owners, smoothed for you; events, requests, claims, inputs | A schema-based state on the server, sent to clients as binary deltas Hosting | Hosted by us | Self-host anywhere, or Colyseus Cloud Clients | JavaScript and TypeScript in the browser | JavaScript, Unity, Godot, GameMaker, Defold, Construct, Haxe Built in | Rooms, quick match, parties, chat, saves, leaderboards, webhooks, MCP servers | Rooms, matchmaking, reconnection, scaling with Redis; the rest you build on your server Pricing | Free plan (1 game, 16 players online), Basic at $3.99/month | Free to self-host (you pay for the servers); Colyseus Cloud from $15/month, with no player limits Open source | The SDK (MIT) | All of it (MIT) ## The real difference: who decides In Colyseus, your server runs the game. Players send inputs, your room code decides what happens, and nobody can change the result from their browser. That's the strongest protection against cheating, and you can put any logic you like on the server. The cost is a backend to write, deploy, monitor and keep up to date. In GameRelay, one player's browser is the host. Each player writes their own avatar, and the host runs the shared world. If the host leaves, another player takes over with the same state. Our server orders and limits every message and stamps it with the sender, so players can't pretend to be each other. It doesn't run your rules, though, so a determined player could still bend their own game. For most casual, co-op and party games that trade is worth it: there is nothing to deploy. ## Choose Colyseus when - Cheating matters: competitive or ranked play, or anything with real-money value. - You want game logic on a server you control, or to self-host with no vendor at all. - Your game ships on Unity, Godot or other engines besides the web. - You're comfortable running a Node.js service, or happy to pay for Colyseus Cloud to run it. ## Choose GameRelay when - You don't want a backend: no server code, no deploys, no scaling. - Your game is casual, co-op or social, where host-run rules are enough. - You want lobbies, parties, chat, saves and leaderboards without building them. - An AI assistant writes your code. A model is far more reliable at a single HTML file than at a client and a server that must agree. ## Sources Checked on October 3, 2026 from colyseus.io/pricing (https://colyseus.io/pricing), the Colyseus repository (https://github.com/colyseus/colyseus) and its documentation. Products change; if something here is out of date, tell us at support@gamerelay.io (mailto:support@gamerelay.io) and we'll fix it. ## Try it GameRelay has a free plan (https://gamerelay.io/#plans), no card needed. Get a public key (https://gamerelay.io/login), then follow the quickstart (https://gamerelay.io/docs#quickstart), or point your AI coding agent at llms.txt (https://gamerelay.io/llms.txt). --- # GameRelay vs PartyKit (Cloudflare) Source: https://gamerelay.io/compare/partykit PartyKit joined Cloudflare in 2024. Today it's PartyServer, a library for writing real-time servers on Cloudflare Durable Objects, and PartySocket, a client. It's a toolkit for building any real-time app. GameRelay is a finished backend for multiplayer games, with nothing to deploy. ## The short version | GameRelay | PartyKit / PartyServer Server code | None | You write a server class per room and deploy it to your Cloudflare account Made for | Multiplayer browser games | Any real-time app: collaboration, chat, games Syncing state | Entities with owners, smoothed for you; host handover built in | Whatever your server sends; Yjs support for shared documents Game features | Rooms, quick match, room lists, parties, chat, saves, leaderboards, webhooks | None built in: you build lobbies, matchmaking and storage with Workers and Durable Objects Hosting | Hosted by us | Your Cloudflare account, on its global network Pricing | Free plan (1 game, 16 players online), Basic at $3.99/month | Cloudflare's usage pricing: a free tier, then Workers Paid from $5/month plus requests and duration Open source | The SDK (MIT) | Yes (ISC) ## A toolkit versus a game backend PartyServer gives you a stateful server for each room, close to your players, with WebSockets and storage. That's a great foundation, and you decide everything above it: the message format, who runs the game, how players are matched, how state reaches late joiners and how movement is smoothed. GameRelay makes those decisions for you, for games. Players get rooms, quick match and parties; the SDK syncs entities, smooths them and hands the host role over when a player leaves; scores, saves and chat are calls on the client. You give up control of the server, and you don't have one to write. ## Pricing On Cloudflare you pay for what your Durable Objects use: requests (incoming WebSocket messages count at 20 to 1) and time, after a free tier, with Workers Paid at $5 a month minimum. That can be very cheap, but it depends on your code. GameRelay's plans are flat: see our plans (https://gamerelay.io/#plans) and Durable Objects pricing (https://developers.cloudflare.com/durable-objects/platform/pricing/). ## Choose PartyKit when - You want server-side logic, written your way, on Cloudflare's edge. - Your app is more than a game: collaborative editing, presence, shared documents. - You already use Cloudflare Workers and want everything in one account. ## Choose GameRelay when - You're making a game and want game features, not building blocks. - You don't want to write, deploy or debug a server. - An AI assistant writes your code: one HTML file with the GameRelay SDK, nothing to deploy. ## Sources Checked on October 3, 2026 from PartyKit's announcement (https://blog.partykit.io/posts/partykit-is-joining-cloudflare/), the cloudflare/partykit repository (https://github.com/cloudflare/partykit) and Cloudflare's pricing (https://developers.cloudflare.com/durable-objects/platform/pricing/). Products change; if something here is out of date, tell us at support@gamerelay.io (mailto:support@gamerelay.io) and we'll fix it. ## Try it GameRelay has a free plan (https://gamerelay.io/#plans), no card needed. Get a public key (https://gamerelay.io/login), then follow the quickstart (https://gamerelay.io/docs#quickstart), or point your AI coding agent at llms.txt (https://gamerelay.io/llms.txt).