Skip to content
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, the same guide in one plain-text file written for LLMs, or connect the MCP server.
Open llms.txt

Quickstart

  1. Sign in and create an instance for your game.
  2. 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).
  3. 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.

ShapeWhenYou use
Players own avatarsEach player moves their own thing: shooters, racers, co-op, drawingEntities, events
Host simulatesPhysics or rules must be fair and shared: ball gamesInputs, host entities, relay.tick
Mixed (most games)Players move themselves; the host owns pickups, enemies, roundsEntities, 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.

OptionTypeDescription
publicKeystringYour instance public key (gr_pub_…). Required unless getToken is set.
playerNamestringDisplay name. Cut to 24 characters.
playerAvatarstringAnonymous 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.
urlstringServer 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.
debugbooleanDevelopment: 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

simulate: { latency, jitter, loss } tests on a bad network.

Relay

MemberDescription
relay.playerIdYour player id. Stable across reloads of the same tab.
relay.roomThe Room you are in, or null.
relay.partyYour party ({ code, leaderId, members }), or null.
relay.connectedfalse while reconnecting.
relay.featuresWhat 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

MemberDescription
room.id / room.codeStable id (matches webhooks) and the 4–6 character join code.
room.me / room.hostId / room.isHostYour 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.maxPlayersSeats in the room. The host can change it with setAccess.
room.locked / room.isPublic / room.name / room.metaHost 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.renderTimeThe 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.chatHistorySend 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 eventArgumentsWhen
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 eventArgumentsWhen
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:

CodeMeaning
bad_requestInvalid arguments or message.
unauthorizedUnknown or revoked key, an expired token, or a site that isn’t in the game’s allowed origins.
rate_limitedToo many messages, chat lines, new rooms, wrong room codes or token requests.
too_largeMessage, state or chat line over its limit.
room_not_found / room_fullNo room with that code, or no free seat.
too_many_roomsNo 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_roomThe call needs you out of (or in) a room.
not_hostOnly the host can do that (setState, timers, teams, host entities, kick, setAccess, setListing, transferHost).
host_changedroom.request: the host changed before answering; ask again.
timeoutroom.request: no answer from the host in 5 seconds; any other call: no answer from the server in 10.
player_not_foundNo player with that id in the room (sending to one player, kicking one, or handing over the host role).
party_not_found / party_fullNo party with that code, or 8 members already.
bannedYou were kicked from this room.
lockedThe room’s host locked it: nobody new can join.
at_capacityThe game's account has as many players online as its plan allows.
quota_exceededThe game's account used its monthly traffic (Free plan). It resets on the 1st.
unsupportedThe feature is switched off for this game (parties, chat, saves).
rejectedThe host refused a request (the message is its reason), or a leaderboard score broke one of the board's rules.
disconnectedNot connected, or the connection dropped before the reply. getToken players also get this from a site outside the allowed origins.
internalSomething 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.

POST/v1/auth/anonymouspublic 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/tokensecret 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/kicksecret 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/closesecret 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/plansnone

The plans and their limits (the table below renders it), and billing: whether paid plans can be bought right now.

GET/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.

Eventdata
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 room64
Messages per player~120 / second (bursts to 240)
Frame size16 KB (everything sent in one frame)
Room state64 KB, JSON nested at most 16 deep
Entities256 per player, 1,024 per room; 32 fields; text 256 characters, value 4 KB
Claims1,024 per room, keys up to 128 characters
Inputs1 KB each, ~20 / second to the host
Reconnect grace30 seconds per seat
Offline send queue256 reliable messages
Chat120 characters, 5-line burst then 1 / second, 20 lines of history
Saves256 keys per player, keys up to 128 characters
Leaderboards32 boards per game, top() up to 100 entries
Names / tags24 / 1–32 characters
Parties8 members
Player tokens1 hour by default; backend tokens 60 s to 24 h, claims up to 1 KB
Webhooks5 per game, 5 s timeout, retried for ~8½ hours
Allowed origins20 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.

PlanPriceGamesPlayers onlineMonthly 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