# Multiplayer API — complete reference Base: `https://multiplayerapi.com/v1` Format: JSON. Every response contains `ok: true`, or `ok: false` with an `error` object of the form `{ code, message, details? }`. This document is self-contained: it contains everything you need to integrate the API without consulting any other source. --- ## 1. Mental model The server does not simulate your game. It does three things, and only three: 1. **It connects players**: accounts, matchmaking, WebRTC signaling. 2. **It arbitrates what you ask it to arbitrate**: writes to shared state are validated against rules you declare. 3. **It retains what must survive the match**: progression, items, leaderboards. Everything else — positions, animations, physics — flows directly between players via WebRTC. That is what makes it possible to run a real-time game without a game server: once connections are established, the server only sees one presence call every five seconds per player. Nothing in the API knows about "FPS match" or "hand of cards". There are matches, a JSON state document, counters, leaderboards. What those objects mean is decided by your game. --- ## 2. Create a game Without a UI, in one call. This is the intended path for an agent: ```http POST /v1/games Content-Type: application/json { "name": "My Game", "preset": "realtime_arena", "origins": ["https://mygame.example"] } ``` Response: ```json { "ok": true, "game": { "id": "gm_...", "slug": "my-game", "public_key": "mp_pk_..." }, "secret_key": "mp_sk_...", "claim_code": "..." } ``` - `public_key` goes in the client. It only authorizes player operations. - `secret_key` is shown only once. It stays on your server and authorizes what a player cannot do: grant an item, force a score, ban. - `claim_code` lets you later attach this game to a developer account on the dashboard. Available presets: `realtime_arena`, `turn_based`, `coop`, `strict`. They only prefill rules; everything remains editable afterward. **Origins**: leaving `origins` empty allows all origins, which suits development. In production, list your domains. A Unity or native client does not send an origin and is never blocked by this filter. ### Call authentication | Header | Content | Grants access to | |---|---|---| | `X-Mp-Key: mp_pk_...` | public key | player operations | | `X-Mp-Key: mp_sk_...` | secret key | everything, including privileged writes | | `Authorization: Bearer mp_at_...` | player session token | identifies the player | The key is required on almost all calls; the session token is added whenever a player is involved. --- ## 3. Player accounts Accounts are isolated per game. The same username can exist in two unrelated games with no link between them. ### Guest account — the recommended path ```http POST /v1/auth/guest { "display_name": "Alice" } ``` The player enters the game without a form. They can upgrade their account later without losing anything: ```http POST /v1/auth/upgrade (with the guest account token) { "username": "alice", "password": "strong password" } ``` This is almost always the right choice: requiring registration before the first match loses most players. Two limits to know. Creation is throttled by IP address: thirty accounts in a row, then one every twenty seconds — enough for a classroom behind the same connection, too little for an account farm. And **a guest account with no connection for sixty days is deleted**, along with its inventory and stats; an account the player has given credentials to never is. Offer `upgrade` to a player who gets attached to their progression. ### Other routes | Route | Effect | |---|---| | `POST /v1/auth/register` | `{ username, password, email? }` | | `POST /v1/auth/login` | `{ username, password }` | | `POST /v1/auth/refresh` | `{ refresh_token }` — the SDK handles this automatically | | `POST /v1/auth/logout` | closes the current session | | `GET /v1/auth/check` | validates a token without loading anything | | `POST /v1/auth/recover` | **secret key**: `{ identifier }` → reset token | | `POST /v1/auth/reset` | `{ reset_token, password }` → new password and session | Sessions return `{ access_token, refresh_token, expires }`. The access token lives two hours, the refresh token thirty days. ### Forgotten password The API sends no email: it knows neither your brand nor your sending domain, and a message from elsewhere would end up in spam. It generates the proof; you deliver it. ```bash # 1. your server, with the secret key curl -X POST https://multiplayerapi.com/v1/auth/recover \ -H "X-Mp-Key: mp_sk_..." -H 'Content-Type: application/json' \ -d '{"identifier":"player@example.com"}' # → { "sent": true, "reset_token": "ac_....1785...abcdef", "expires": 1785... } # 2. you send this token to the player (email, notification, support) # 3. the client, with the public key curl -X POST https://multiplayerapi.com/v1/auth/reset \ -H "X-Mp-Key: mp_pk_..." -H 'Content-Type: application/json' \ -d '{"reset_token":"...","password":"new-password"}' ``` The `recover` response is always `{"sent": true}`, whether the account exists or not: your server does not need to know to send an email, and staying silent prevents a leaked secret key from becoming an account directory. The token is never written anywhere and stops working as soon as the password changes — it is therefore single-use. All account sessions are invalidated on reset. On the client side, the SDKs wrap the second step: `mp.resetPassword(token, password)` in JavaScript, `await mp.ResetPassword(token, password)` in Unity. The session comes back open on the account; there is no need to log in again. ### Profile and save data ```http GET /v1/me full profile PATCH /v1/me { display_name?, data? } GET /v1/me/data player free-form save POST /v1/me/data { data, version? } GET /v1/players/ public profile of another player GET /v1/players?ids=a,b,c several at once ``` `data` is a free-form JSON document, versioned: send back the received `version` so the server detects a conflict if the player played from another device. --- ## 4. Matches ### Join in one call ```http POST /v1/rooms/quickmatch { "mode": "duel", "meta": { "map": "desert" }, "max_players": 4 } ``` Joins the best matching room, or creates one. This is the call to prefer: one round trip and the player is in the game. Add `"join_only": true` to fail rather than create. ### Other routes | Route | Effect | |---|---| | `POST /v1/rooms` | creates a room; the caller is the host | | `GET /v1/rooms?mode=duel&meta.map=desert` | search | | `POST /v1/rooms//join` | join by six-character code | | `POST /v1/rooms//join` with `{"role":"spectator"}` | join as spectator | | `POST /v1/rooms//leave` | leave | | `PATCH /v1/rooms//settings` | host: `phase`, `locked`, `max_players`, `meta` | | `POST /v1/rooms//kick` | host: removes a player | | `GET /v1/rooms/mine` | player's active rooms | Metadata is free-form and filterable: `meta.rank=gold`, `meta.map=desert`. The API assigns no meaning to these keys; your game decides. Phases are `lobby`, `playing`, `ended`. Moving to `playing` generally locks the room. **Host**: the first player is host. If they leave, the longest-standing remaining player becomes host automatically — the rule being deterministic, all clients converge on the same host without negotiation. Listen for the host-change event: that is when your game must take back authority. ### Spectators ```js const seat = await mp.watch('ABC123'); // instead of mp.join() seat.isSpectator; // true seat.state.get('board'); // reads everything seat.set('board.0', 'X'); // throws an error, and the server would refuse room.players; // those who play room.spectators; // those who watch ``` A spectator does not count in `player_count`, cannot become host, and the server refuses state writes, events, relayed packets, **and** signaling from them — with no direct channel, they cannot slip anything to players that the server would not see. They receive shared state, and the real-time stream when the match goes through relay; in peer-to-peer, they only see shared state. Each spectator polls the server like a player; the feature is therefore closed by default. Enable it by setting `max_spectators` (1 to 64) in the game configuration, and the server caps their rate to one second. A refused request returns `spectators_disabled` or `spectators_full`. **Room ceiling.** A game can have 2000 live rooms at once. Beyond that, creation returns `quota_rooms` (429); existing rooms remain joinable. You can impose a lower limit with `max_rooms` in the configuration — useful so a loop in your code does not consume your entire quota. --- ## 5. The loop: `POST /v1/rt/sync` This is the sole call in the game loop. One round trip carries everything the client has to say and reports everything it has not yet seen. ```json { "room": "ABC123", "since": 42, "transport": "p2p", "ops": [ { "op": "set", "path": "players.$me.hp", "value": 80 } ], "events": [ { "topic": "chat", "data": { "text": "gg" } } ], "signals": [ { "to": "ac_x", "kind": "offer", "data": { } } ], "relay": [ { "data": "" } ] } ``` Response: ```json { "ok": true, "seq": 57, "tick_ms": 5000, "log": [ { "seq": 43, "kind": 1, "sender": "ac_x", "data": { "op": "set", "path": "...", "value": 1 } } ], "members": [ ... ], "signals": [ ... ], "relay": [ { "from": "ac_x", "data": "" } ], "applied": 1, "rejected": [ { "index": 0, "code": "rule_range", "message": "..." } ], "server_ms": 1785554479390 } ``` Essential points: - `since` is the last `seq` received. The server only returns what follows. - `$me` in a path is replaced by the calling player's identifier. - `tick_ms` is the recommended rate for the next call. **Respect it**: it is the main cost lever of the API, and it varies from one call to the next. Typical values: | Situation | `tick_ms` | |---|---| | WebRTC negotiation in progress | 250 | | Server relay, small room | 100 | | Server relay, large room | up to 250 | | Server relay, no traffic | 1000 | | Waiting lobby | 2000 | | Peer-to-peer established | 5000 | | Room with no one else | 10000 | Relay rate widens with headcount: each player re-reads what everyone else deposited, so the cost of a room grows with the square of player count. The server compensates so a room's bill stays stable. It also relaxes when the tick carried nothing — no relayed packet, no write, no delta. A turn-based game without peer-to-peer is classified as relay even though nothing is exchanged while the opponent thinks: the server then polls once per second, and resumes the fast rate as soon as the first packet arrives. - `kind` in the log: `1` state, `2` event, `3` presence. - If the client falls too far behind, the server returns full state with `resync: true` instead of a delta list. - A rejected operation does not cancel the others, unless you pass `"atomic": true`. ### State operations | Operation | Effect | |---|---| | `set` | writes a value | | `inc` | increments a number | | `push` | appends to an array | | `del` | deletes a key | | `setnx` | writes only if absent | | `cas` | writes only if the current value equals `expect` | `cas` is the tool for turn-based games: it makes it impossible to play the same turn twice, even if two clients send at once. --- ## 6. The three channels This is the most structuring choice in an integration. | | `relay`/`send` | `ops` (state) | `events` | |---|---|---|---| | Path | direct between players | server | server | | Delivery | best effort | guaranteed | guaranteed and ordered | | Survives reconnection | no | yes | no | | Received by a player who joined later | no | yes | no | | Server cost | zero in peer-to-peer | one write | one write | In practice, for a shooter: position over the direct channel, hit points and score in state, a rematch request as an event. **WebRTC**: the SDK establishes connections on its own. Signaling travels in `signals`, included in the tick the client emits anyway. If peer-to-peer fails — typically behind a symmetric NAT — traffic falls back to `relay`, slower and costlier, but the game continues. `GET /v1/rt/ice` returns the ICE servers to use. **The direct channel also wakes the others.** Once peer-to-peer is established, the rate drops to five seconds — too slow for shared state, which always goes through the server. After each write, the SDK therefore sends peers a ~10-byte warning on the direct channel, which makes them read immediately. A turn-based player's move thus arrives in a few tens of milliseconds while the API is only called when useful. **If you write your own client rather than using the SDK**, reproduce this behavior: broadcast `{"__mp":1}` on your data channels after a write, and trigger a sync on receipt. Such a message is never surfaced to the game. --- ## 7. Declarative rules Rules describe what a client is allowed to write. They are enforced on every sync. Without rules, a safe default applies: a player can write under `players.`, everything else is reserved to the host. ```json { "state": { "players.$self.hp": { "write": "self", "type": "int", "min": 0, "max": 100, "delta_max": 100 }, "players.$self.name": { "write": "self", "type": "string", "max_len": 24 }, "players.$self.score":{ "write": "host", "type": "int", "monotonic": "up" }, "world.**": { "write": "host" }, "chat": { "write": "any", "append": true, "max_items": 50, "rate": 2 } }, "events": { "chat": { "write": "any", "rate": 2, "max_bytes": 512 }, "match.*": { "write": "host" } }, "stats": { "kills": { "delta_max": 1, "match_max": 60, "requires": "attestation" }, "coins": { "server_only": true } }, "defaults": { "unknown_paths": "implicit", "unknown_stats": "attestation" } } ``` - `$self` denotes the writing player's identifier; `*` covers one segment, `**` the rest of the path. - A rule on `$self` also covers the neighbor's path. Declaring `players.$self.score` as `self` therefore forbids writing another player's score: the rule applies to the key, not only yours. If you want an arbiter to write everyone's scores, declare it `host` or `server`. - `write`: `any`, `self`, `host`, `server`, `none`. - `rate`: writes per second allowed on this path. - `delta_max` bounds a spontaneous client write; `match_max` bounds what a countersigned match result can grant. The two are distinct, because a clash legitimately produces in one shot what an isolated write is not allowed to claim. - `requires: "attestation"` forbids direct writes: the value can only come from a match result confirmed by other players. - `server_only` reserves the key to the secret key. **Events are declared too.** The `events` section covers `emitEvent` topics, with the same `write` and `rate`, plus `max_bytes` for payload size. The most specific pattern wins: `match.ended` beats `match.*`. Without an `events` section, every topic remains allowed — this is the historical behavior. Set `defaults.unknown_events: "deny"` to accept only declared topics. This is a protection that is often missing: a game can carefully fence its state and still let any player emit `manche.gagnee` instead of the host. Refusals come back in `rejected` with a code (`rule_denied`, `rule_range`, `rule_type`, `rule_rate_limited`…) and an explicit message. Repeated serious refusals feed dashboard reports. Editable live via `PUT /v1/admin/games//rules`, without redeploying the game. --- ## 8. Persistent progression ```http POST /v1/stats { "stats": { "kills": 1 } } GET /v1/stats/me?fresh=1 GET /v1/leaderboards/?period=weekly&limit=20 POST /v1/leaderboards/ { "score": 1500, "mode": "best", "periods": ["all","weekly"] } GET /v1/inventory POST /v1/inventory/equip { "item_id": "hat_gold" } GET /v1/items catalogue POST /v1/items (secret key) declares the catalogue POST /v1/inventory (secret key) grants an item ``` - Leaderboard windows: `all`, `daily`, `weekly`, `monthly`. - Aggregation modes: `best`, `last`, `sum`, `min`. - Counter increments are batched then written in bulk; add `fresh=1` to force an up-to-date read. - Item grants require the secret key. This is deliberate: without it, the game's economy would be at the mercy of the first modified client. --- ## 9. Anti-cheat Peer-to-peer moves authority to a player. The answer spans three levels, from least to most constraining. **Rules** bound what a client can write. They suffice for everything verifiable locally. **Attestation** covers match results. The host submits the result, other players countersign, and rewards are paid only once a majority is reached: ```http POST /v1/matches { "room": "ABC123", "result": { "winner": "ac_x", "score": "10-7" }, "rewards": { "ac_x": { "stats": { "kills": 10 }, "scores": { "arcade": 1500 } } } } POST /v1/matches//attest { "agree": true } ``` Verdicts: `pending`, `confirmed`, `disputed`, `unconfirmed` (no one responded within five minutes), `solo` (no witnesses). An isolated host therefore cannot grant themselves anything, and a disagreement leaves a trace in the dashboard. **Your server**, holding the secret key, remains the sole authority no one can bypass. For a high-stakes leaderboard, have it arbitrate. ### Be notified rather than poll Declare an address in the game configuration and the API calls you: ```json { "webhook": { "url": "https://mygame.example.com/mp-hook", "secret": "long-random-string", "events": ["match.ended", "cheat.flagged", "player.banned"] } } ``` Available events: `match.ended` (with detail of what was actually paid out, rule refusals included), `match.disputed`, `cheat.flagged`, `player.banned`. Without an `events` list, everything is sent. Each call carries a header you must verify: ``` X-Mp-Signature: t=1785660000,v1=")> ``` ```php [$t, $v1] = sscanf($_SERVER['HTTP_X_MP_SIGNATURE'], 't=%d,v1=%s'); $body = file_get_contents('php://input'); $mine = hash_hmac('sha256', $t . '.' . $body, $secret); if (!hash_equals($mine, $v1) || abs(time() - $t) > 300) { http_response_code(403); exit; } ``` Without this verification, anyone can make you believe a match ended. The timestamp is part of the signed text: rejecting it beyond five minutes prevents replay of a captured call. Respond 2xx. A failure is retried six times, from fifteen seconds to one hour apart; the dashboard Notifications tab shows what is still pending and why. The address must be https and public: it is resolved before each send, and anything pointing to an internal network is refused. --- ## 10. Errors, limits, and misc Common codes: `unauthorized`, `forbidden`, `not_found`, `bad_request`, `rate_limited`, `room_full`, `room_locked`, `not_in_room`, `conflict`, `payload_too_large`, `rule_denied`, `quota_rooms`. A `429` comes with a `Retry-After` header in seconds. The SDK waits and retries automatically when the delay is short. **Batch calls** — PHP startup costs more than useful work, so batching divides the cost accordingly: ```http POST /v1/batch { "calls": [ { "path": "me", "method": "GET" }, { "path": "stats/me", "method": "GET" }, { "path": "leaderboards/arcade", "method": "GET" } ] } ``` Responses come back in order, each with its own status; one failure does not cancel the others. Twenty calls maximum. **Clock** — `GET /v1/time` estimates offset between the player's clock and the server's. Essential as soon as you interpolate between peers: a client clock can drift by several minutes. **msgpack** — send `Accept: application/x-msgpack` for more compact binary responses. **TURN servers** — public STUN suffices for most players, but not behind a symmetric NAT: direct connection fails there and everything falls back to server relay, the costliest mode. TURN changes that, and it is the best investment for a real-time game. Two forms in the game configuration: ```json { "turn": { "urls": ["turn:turn.example.com:3478"], "secret": "...", "ttl": 3600 } } { "ice": [ { "urls": "turn:...", "username": "...", "credential": "..." } ] } ``` Prefer the first: the secret never leaves the server and each client receives an expiring credential, per the coturn convention — understood as-is by Cloudflare, Twilio, Xirsys, and most managed services. The SDKs renew the list on their own; `GET /v1/rt/ice` returns it with its `ttl`. The dashboard Usage tab shows the share of ticks actually held in peer-to-peer: that is the number that tells whether TURN would help you. --- ## 11. Recipes Full playable games exist, readable without a build step: https://multiplayerapi.com/demo/morpion/ for turn-based (tic-tac-toe), https://multiplayerapi.com/demo/arene/ and https://multiplayerapi.com/demo/slither/ for real-time (.io arena), https://multiplayerapi.com/demo/fps/ for shooting. ### Turn-based (cards, board, puzzle) Preset `turn_based`. Everything goes through shared state. Still leave peer-to-peer active — that is, do not touch the `p2p` option: the direct channel carries no moves, but it lets the SDK warn the opponent that one was just played, and therefore to call the server only every five seconds without losing responsiveness. Disabling it would mean polling the server once per second for the entire thinking period. ```js const room = await mp.quickmatch({ mode: 'ranked', max_players: 2 }); // A move is two inseparable writes: place the piece and pass the turn. `atomic` // guarantees we never apply one without the other, and `cas` ensures we do not // play the same turn twice even if both clients write at the same instant. room.set(`board.r${round}.4`, myMark); room.cas('turn', mp.account.id, opponentId); await room.flush({ atomic: true }); // rejects: handle in a catch room.state.watch(`board.r${round}.*`, redraw); room.state.watch('turn', (who) => setMyTurn(who === mp.account.id)); ``` Useful rules: `"turn": { "write": "any", "type": "string" }` and `"board.**": { "write": "any", "immutable": true }` — a placed cell cannot be taken back, which makes client-side checks unnecessary. For a rematch, count rounds in an integer `monotonic: "up"` and advance it with `cas`. Set it with `setnx('round', 0)` at open: a `cas` expecting `0` fails on a missing key, whose current value is `null`. ### Real-time (arena, .io, FPS) Preset `realtime_arena`. Position goes live; everything else in state. ```js const room = await mp.quickmatch({ mode: 'ffa', max_players: 16 }); setInterval(() => { // Replaced in 50 ms: losing it in transit does not matter. room.send({ x: me.x, y: me.y, a: me.angle }); }, 50); room.on('peer', (p, from) => ghosts[from]?.moveTo(p.x, p.y, p.a)); room.state.watch('players.*.hp', (hp, path) => updateHealthBar(path, hp)); // Hit points must survive reconnection: they go through state. room.mine('hp', myHp); ``` Always interpolate display on `mp.now()` rather than the local clock, and show other players with about 120 ms delay: that leaves time for the next packet to arrive, and movement stays continuous instead of jumping point to point. For a pickup, `setnx` decides without an arbiter: the first arrival keeps it, the second gets an `immutable` refusal that is enough to listen for and respawn the item locally. ### Cooperative (shared world, survival) Preset `coop`. The world is written by everyone, with bounded throughput. ```js room.inc('world.resources.wood', -10); room.set(`world.buildings.${id}`, { type: 'wall', x, y, by: mp.account.id }); room.emitEvent('alert', { text: 'Wave incoming' }); ``` Rules: `"world.**": { "write": "any", "rate": 10 }`. For an item two players might grab at once, use `setnx`: the second gets an explicit refusal rather than an inconsistent state. --- ## 12. Pitfalls to avoid - **Ignore `tick_ms`.** Syncing at 100 ms when the server advises 5000 multiplies load fiftyfold, without helping the player. - **Route positions through shared state.** Every write is logged and validated; at sixty frames per second, that is indefensible. Use the direct channel. - **Put in the direct channel what must persist.** A reconnecting player received nothing, and neither did a player who joined mid-match. - **Trust the host blindly.** Anything that matters beyond the match must go through attestation or your server. - **Forget host migration.** The host leaves more often than you think; handle the event, or the match freezes. - **Publish the secret key in a client.** It authorizes everything. If it happens, regenerate it from the dashboard. - **Send separately two writes that only make sense together.** Without `flush({ atomic: true })`, the second may be refused while the first passed, and the match ends up in a state its own logic did not expect. - **Expect `0` by `cas` on a key never written.** Its current value is `null`, not `0`: set it first with `setnx`. - **Fence state and leave events open.** Without an `events` section in rules, any player can emit any topic at any rate — including the one that announces the end of the round. - **Accept a notification without verifying its signature.** Your webhook URL is public as soon as it is called once; without verification, anyone can make you credit a player.