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.
Quickstart
- Sign in 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:
<canvas id="c" width="640" height="400"></canvas>
<script src="https://gamerelay.io/sdk/gamerelay.js"></script>
<script type="module">
const c = document.getElementById('c');
const relay = await GameRelay.connect({ publicKey: 'gr_pub_…' });
const room = await relay.quickMatch({ maxPlayers: 4 });
const dots = room.define('dot', { x: 'number', y: 'number' });
const me = dots.spawn({ x: 320, y: 200 }); // yours: just write it
c.onpointermove = (e) => { me.x = e.offsetX; me.y = e.offsetY; };
const ctx = c.getContext('2d');
(function draw() {
ctx.clearRect(0, 0, 640, 400);
for (const d of dots.all()) ctx.fillRect(d.x - 4, d.y - 4, 8, 8); // everyone's, smoothed
requestAnimationFrame(draw);
})();
</script>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 (see Pick your shape). The arena demo (mixed) and Blocky Cup (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, direct connections).
- Room
- Players who play together, joined by quick match, a code 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 |
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 28 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<string> | 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 isplayer_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 momentsroom.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
requestrepeated while the first waits - the server dropping messages over 120/s; the tick loop falling back to
setInterval
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 and stop reconnecting. |
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.name / room.meta | Host controls, always current: no new joins, listed, 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 (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) / claimed(key) | Take something exactly once; the server decides. |
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. 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: gamerelay.io/<game>/<link> once the game has a slug (dashboard → Short links), with a preview in chat apps. |
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 (?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, gamerelay.io/<game>/<link> 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, 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 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; any other call: no answer from the server 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). |
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. |
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.
/v1/auth/anonymouspublic keyMints 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.
/v1/auth/tokensecret keyMints 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.
/v1/rooms/:code/kicksecret keyRemoves 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.
/v1/rooms/:code/closesecret keyCloses 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.
/v1/plansnone The plans and their limits (the table below renders it), and billing: whether paid plans can be bought right now.
/healthznone{ ok, version, ccu, rooms, uptimeS }. See it live.
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=<unix seconds>,v1=<hex>, 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 (lan: false turns it off), 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, lan: { direct: 'party' } })
// Off: everything through the server only.
// GameRelay.connect({ publicKey, lan: false })- 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 and Terms.
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 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