Multiplayer API docs · $19.99/mo
Any game.
Online.
Multiplayer API documentation — make any game multiplayer, whatever the genre, without running a game server.
What the Multiplayer API server does
The Multiplayer API server does not simulate your game. It does three things, and only three.
Connect
Accounts scoped per game, matchmaking, WebRTC signaling.
Arbitrate
Writes to shared state are validated against rules you declare.
Persist
Progression, items, and leaderboards survive the match.
Everything else — positions, animations, physics — flows directly between players over WebRTC. That is what lets a realtime game run without a game server: once connections are up, the server only sees a presence call every five seconds per player.
Nothing in the API knows what a “shooter match” or a “hand of cards” is. There are rooms, a JSON state document, counters, and leaderboards. What those objects mean is decided by your game.
Start in ten lines
Create a game. No prior account, no UI: one call is enough, and that is the path meant for an agent integrating the API on its own.
curl -X POST https://multiplayerapi.com/v1/games \
-H 'Content-Type: application/json' \
-d '{"name":"My Game","preset":"realtime_arena"}'
The response contains a public key mp_pk_…, to place in the
client, and a secret key mp_sk_… shown only once, to keep on your
server.
import { Multiplayer } from 'https://multiplayerapi.com/sdk/mp.esm.min.js';
const mp = new Multiplayer({ key: 'mp_pk_...', displayName: 'Alice' });
await mp.login(); // the player is in
const room = await mp.quickmatch({ mode: 'ffa', max_players: 8 });
room.on('members', (m) => console.log(m.length, 'players'));
room.on('peer', (data, from) => ghosts[from]?.moveTo(data.x, data.y));
room.state.watch('players.*.hp', (hp, path) => updateBar(path, hp));
setInterval(() => room.send({ x: me.x, y: me.y }), 50); // direct, between players
room.mine('hp', 100); // state, guaranteed
using MultiplayerApi;
var mp = new Multiplayer("mp_pk_...") { DisplayName = "Alice" };
await mp.Login();
var room = await mp.Quickmatch(new JObject { ["mode"] = "ffa", ["max_players"] = 8 });
room.OnPeerMessage += (bytes, from) => Ghosts[from].Apply(bytes);
room.OnMembersChanged += (members) => Spawn(members);
void Update() {
room.Send(new { x = transform.position.x, z = transform.position.z });
}
POST /v1/auth/guest X-Mp-Key: mp_pk_...
→ { account, session }
POST /v1/rooms/quickmatch Authorization: Bearer mp_at_...
{ "mode": "ffa", "max_players": 8 }
→ { room, me, members, tick_ms }
POST /v1/rt/sync ← the loop, one call every tick_ms
{ "room": "ABC123", "since": 42, "ops": [...], "relay": [...] }
Three calls and the game is multiplayer. The JavaScript SDK is 5.9 KB once compressed and has no dependencies.
Keys and authentication
| Header | Value | Grants access to |
|---|---|---|
X-Mp-Key | mp_pk_… | player operations |
X-Mp-Key | mp_sk_… | everything, including privileged writes |
Authorization | Bearer mp_at_… | identifies the player |
The secret key must never leave your server. It can grant an item, force a score, or ban. If it leaks, regenerate it from the console: the old one stops working immediately.
Origins. Leaving the list empty allows every origin, which is fine for development. In production, declare your domains. A Unity or native client sends no origin and is never blocked by this filter.
Player accounts
Accounts are scoped per game: the same handle can exist in two unrelated games.
Guest accounts — almost always the right choice
Requiring signup before the first match loses most players. Let them in first:
const mp = new Multiplayer({ key: 'mp_pk_...', displayName: 'Alice' });
await mp.login(); // resumes the stored session, or creates a guest account
The account is real: it has stats, an inventory, a place on the leaderboard. When the player commits to the game, they upgrade it without losing anything:
await mp.upgrade('alice', 'a solid password'); // POST /v1/auth/upgrade
Two limits to know. Creation is throttled by address: thirty accounts in a row, then one every twenty seconds — enough for a classroom behind one connection, not enough for an account farm. And a guest account left sixty days with no login is deleted, along with its inventory and stats — the price of an account created without asking anyone for anything. An upgraded account is kept.
Forgot password
The API does not send email: it knows neither your sender name, nor your tone, nor your domain. It builds the proof; your server delivers it.
// Your server, with the secret key
const r = await fetch('https://multiplayerapi.com/v1/auth/recover', {
method: 'POST',
headers: { 'X-Mp-Key': process.env.MP_SECRET, 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier: 'alice@example.com', ttl: 3600 }),
});
const { reset_token } = await r.json(); // drop into your email
// The client, from your reset page
await mp.resetPassword(tokenFromURL, 'new password');
The token is signed and timestamped, never stored: nothing to purge, nothing
to steal from the database. It stops working as soon as the password changes, so
it is single-use, and the reset disconnects every session on the account — a
takeover must be complete. recover returns the same response whether
the account exists or not, so a leaked secret key cannot become a player
directory.
Profile and save data
await mp.profile({ display_name: 'Alice' });
await mp.save({ level: 7, unlocked: ['forest'] }); // free-form JSON document
const { data, version } = await mp.load();
Saves are versioned: send back the version you received so the
server can detect a conflict if the player played meanwhile from another
device.
Rooms and matchmaking
One call is enough to put a player in a match. quickmatch joins
the best matching room, or creates one:
const room = await mp.quickmatch({
mode: 'duel',
meta: { map: 'desert', rank: 'gold' }, // free-form, filterable metadata
max_players: 4,
});
Metadata has no meaning to the API: it stores it and can search on it. Your game decides what “rank: gold” means.
| Call | Effect |
|---|---|
mp.create({ … }) | creates a room; the caller is the host |
mp.list({ mode, 'meta.map' }) | search joinable rooms |
mp.join('B2K2D3') | join by code, speakable to a friend |
mp.watch('B2K2D3') | join as spectator, no write rights |
room.settings({ phase: 'playing' }) | host only |
room.leave() | clean leave |
Spectators
A spectator reads the match without taking part. They do not count toward capacity, cannot become host, and the server rejects any state write, event, or relayed packet from them — including WebRTC signaling. With no direct channel, they cannot slip anything to players that the server would not see.
const seat = await mp.watch('B2K2D3');
seat.isSpectator; // true
seat.state.get('board'); // they can read everything
room.players; // those who play
room.spectators; // those who watch
Each spectator polls the server like a player: a match watched by a hundred
people would cost a hundred times an ordinary match. The feature is therefore
off by default. Enable it by setting max_spectators (1 to 64) in
the game configuration; the server then caps their cadence at one second.
In peer-to-peer, a spectator only sees shared state: positions exchanged directly never go through the server. For a real realtime broadcast, have the host write a summary into state at a reasonable rate.
The host and migration
The first player is host. If they leave, the oldest remaining player becomes host: the rule is deterministic, so every client converges on the same host without negotiation.
room.on('host', (hostId) => {
if (hostId === mp.account.id) startSimulation(); // that's me, I take over
});
The host leaves more often than people expect. A game that does not listen for this event freezes the first time a player closes their tab.
The three channels
This is the most structural choice in an integration. Three ways to move information, with three different trade-offs.
Pipe 01
Direct
room.send()
Peer-to-peer. For anything you will replace within a hundred milliseconds.
- Path between players
- Delivery best-effort
- Cost zero on P2P
Pipe 02
State
room.set() · ops
Validated and kept for the match. Health, score, whose turn it is.
- Path through server
- Delivery guaranteed
- Survives reconnect
Pipe 03
Events
room.emit()
Delivered once, then gone. Chat, rematch, one-shots.
- Path through server
- Delivery ordered
- Late join missed
room.send({ x, y, angle }); // 60 times per second, no regret
room.mine('hp', 80); // must survive a reconnect
room.emitEvent('rematch', { by: mp.account.id }); // one-shot, everyone sees it
send |
ops |
emit |
|
|---|---|---|---|
| Path | between players | server | server |
| Delivery | best-effort | guaranteed | guaranteed and ordered |
| Survives reconnect | no | yes | no |
| Received by a late joiner | no | yes | no |
| Validated by rules | no | yes | partially |
| Server cost | zero on peer-to-peer | one write | one write |
Shared state
Every room carries a JSON document. You do not replace it; you apply path-based operations, so several players can write at once without overwriting each other.
| Operation | Effect |
|---|---|
set | writes a value |
inc | increments a number |
push | appends to an array |
del | deletes a key |
setnx | writes only if the key is absent |
cas | writes only if the current value is the expected one |
room.set('world.door', 'open');
room.inc('score.team_a', 3);
room.push('chat', { by: mp.account.id, text: 'gg' });
room.mine('ready', true); // shortcut: players.<me>.ready
// cas makes it impossible to play the same turn twice, even under a
// simultaneous double send: the second gets an explicit rejection.
room.cas('turn', mp.account.id, opponentId);
await room.flush(); // push now, without waiting for the tick
Reads are reactive: the SDK applies received deltas and notifies watchers.
room.state.watch('score.team_a', (v) => scoreboard.set(v));
room.state.watch('players.*.hp', (hp, path) => updateBar(path, hp));
const doc = room.state.get(); // full snapshot
Realtime and WebRTC
The whole loop fits in one call, POST /v1/rt/sync. It carries
writes, events, WebRTC signaling, and relayed packets, and returns everything
the client has not seen yet since the sequence number it reports. The SDK
handles it; you call nothing by hand.
Cadence — the main cost lever
The response contains a tick_ms to respect:
| Situation | Recommended cadence |
|---|---|
| Negotiating direct connections | 250 ms |
| Server relay, small match | 100 ms |
| Server relay, large match | up to 250 ms |
| Server relay, nothing exchanged | 1 s |
| Waiting lobby | 2 s |
| Match with peer-to-peer established | 5 s |
| Single-player match | 10 s |
Syncing at 100 ms when the server advises 5000 multiplies load by fifty
with no benefit to the player, since gameplay data already goes direct. The SDK
follows the indicated cadence and briefly accelerates when you call
flush().
Relay cadence widens with match size. Each player rereads what every other has deposited: work grows with the square of player count, and the server compensates so the cost of a match stays roughly constant. Measured at about 5% of a core for eight players on relay, versus 0.1% for the same eight on peer-to-peer.
It also relaxes when nothing is circulating. A turn-based game that disabled peer-to-peer is on relay, but exchanges nothing while the opponent thinks: the server then moves to one second and returns to normal cadence on the first packet. Measured on four-player matches, that relaxation alone cuts load by five — 543 ms average cadence instead of 100.
Established peer-to-peer raises the inverse question: at a 5 s cadence, a shared-state write, which always goes through the server, would take five seconds to be seen. The SDK therefore notifies other players on the direct channel after each write, so they read immediately at no server cost. A turn-based game stays instant while only calling the API when something happens.
Fallback to relay
The SDK sets up direct connections on its own: signaling rides in the tick the client already emits, with no extra call. When peer-to-peer fails — typically behind a corporate network or symmetric NAT, about one player in ten — traffic falls back to the HTTP relay. Slower, more expensive, but the game continues and the player notices nothing.
room.on('transport', (mode) => { // 'p2p' | 'mixed' | 'relay'
if (mode === 'relay') reduceUpdateRate();
});
A TURN server brings the excluded back to direct
Public STUN is enough for most players, but not behind symmetric NAT. Those fall back to relay, the service’s most expensive mode. A TURN server brings them back to a direct connection, and it is by far the highest-ROI setting for a growing realtime game.
{
"turn": {
"urls": ["turn:turn.example.com:3478"],
"secret": "the-shared-secret-with-your-turn",
"ttl": 3600
}
}
In this form, the secret never leaves the server: each client receives an
expiring credential, following the coturn convention — the one Cloudflare,
Twilio, Xirsys, and most managed services expect. The SDKs refresh the list on
their own before expiry. If your provider only gives fixed credentials, use
ice instead.
The Usage tab in the console shows the share of ticks actually held on peer-to-peer. Below two thirds, TURN pays for itself quickly.
Clock
A client clock can drift by several minutes. Always interpolate on
mp.now(), which applies the offset measured against the server,
rather than on Date.now().
Declarative rules
Rules describe what a client is allowed to write; the server applies them on every sync. Without rules, a safe default applies: a player may write under their own node; everything else is host-only.
{
"state": {
"players.$self.hp": { "write": "self", "type": "int", "min": 0, "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": 1, "max_bytes": 400 },
"round.*": { "write": "host" },
"emote": { "write": "any", "rate": 3 }
},
"stats": {
"kills": { "delta_max": 1, "match_max": 60, "requires": "attestation" },
"coins": { "server_only": true }
}
}
| Key | Role |
|---|---|
write | any, self, host, server, none |
$self | the id of the player who is writing |
* and ** | one segment, or the rest of the path |
rate | writes per second allowed on this path |
monotonic | up or down: forbids going backwards |
delta_max | cap on a spontaneous client write |
match_max | cap on what a countersigned result may grant |
requires | attestation: forbids direct writes |
server_only | reserved for the secret key |
max_bytes | max event size, on events |
The events section constrains room.emit() the same
way state constrains writes: subjects filter with *
and **, the most specific rule wins. Without it a player can
broadcast any subject, including ones your code treats as round-ending. Reserve
for the host any subject that decides something, and give a rate to
those the player triggers.
delta_max and match_max are deliberately distinct:
a fight may legitimately produce in one go what an isolated write is not allowed
to claim. A player cannot award themselves sixty kills in one request, but a
countersigned match can.
Rejections come back in the response with a code and a message; repeated serious rejections feed console reports. Rules can be changed live, without redeploying the game.
Persistent progression
await mp.addStats({ kills: 1, matches: 1 });
const stats = await mp.stats();
await mp.submitScore('arcade', 1500, { mode: 'best', periods: ['all', 'weekly'] });
const board = await mp.leaderboard('arcade', { period: 'weekly', limit: 20 });
const inv = await mp.inventory();
await mp.equip('hat_gold');
Leaderboard windows: all, daily,
weekly, monthly. Aggregation modes:
best, last, sum, min — the
last for a timer, where the smallest wins.
Granting an item requires the secret key, so your server. That is intentional: without it, the game economy would be at the mercy of the first modified client.
Anti-cheat
Peer-to-peer moves authority to a player, who can lie. The answer is three levels, from least to most constraining. Choose by stakes: a board game among friends does not need what a competitive ladder does.
1. Rules
They bound what a client can write, and are enough for anything locally verifiable: hit points between 0 and 100, a score that never goes down, ten messages per minute at most.
2. Attestation
It covers match results. The host deposits the result, the other players countersign it, and rewards are paid only once a majority is reached.
// The host, at the end of the match
await room.report(
{ winner: winnerId, score: '10-7' }, // result
{ [winnerId]: { stats: { kills: 10 }, scores: { arcade: 1500 } } }, // rewards
);
// The other players, notified by a match event
room.on('event:match.report', ({ match_id, result }) => {
room.attest(match_id, result.winner === myView.winner);
});
| Verdict | Meaning |
|---|---|
pending | waiting for signatures |
confirmed | majority agrees, rewards paid |
disputed | disagreement: nothing paid, a report is opened |
unconfirmed | nobody answered within five minutes |
solo | no witness: only what does not require attestation is paid |
An isolated host therefore cannot award themselves anything that matters.
3. Your server
Holding the secret key, it remains the only authority nobody can bypass. For a high-stakes ladder, have it arbitrate: the client sends it the result, it checks what it wants, then writes with its key.
Notifications to your server
Some things deserve to be known without a client reporting them: a finished match to credit in your shop, a banned player to kick from your Discord, a report to push into your moderation tool. Declare an address in the console’s Notifications tab, and the server calls you.
| Event | Trigger |
|---|---|
match.ended | a result is settled, with its verdict and what was paid |
match.disputed | players disagree: nothing was paid |
cheat.flagged | a report is opened on an account |
player.banned | an account is banned, temporarily or not |
Each delivery carries an X-Mp-Signature header of the form
t=…,v1=…: the timestamp, then the HMAC-SHA256 signature of
t.body with your secret. Verify it on the raw body, before any
JSON.parse — a re-encoded object does not yield the same bytes, and
the signature will never match.
import { createHmac, timingSafeEqual } from 'node:crypto';
app.post('/mp-hook', express.raw({ type: 'application/json' }), (req, res) => {
const [t, v1] = req.get('X-Mp-Signature').split(',').map((p) => p.split('=')[1]);
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return res.sendStatus(400);
const mine = createHmac('sha256', process.env.MP_HOOK_SECRET)
.update(`${t}.${req.body}`).digest('hex');
if (!timingSafeEqual(Buffer.from(mine), Buffer.from(v1))) return res.sendStatus(401);
handle(JSON.parse(req.body));
res.sendStatus(200); // reply quickly: beyond two seconds, we retry
});
Compare in constant time, reject a timestamp older than five minutes, and make your handler idempotent: a failed delivery is retried, with a delay that doubles each attempt, up to six times. The address must be HTTPS and public: the server resolves it before each send and rejects anything pointing at an internal network, which would turn the notification into a probe of the host’s network.
Observe and tune
The Usage tab in the console shows what your game actually costs and why. Four numbers deserve a regular look.
| Metric | What it says |
|---|---|
| Peer-to-peer share | the fraction of ticks held direct; below two thirds, TURN pays for itself |
| Average latency | time spent server-side, excluding network |
| Slow requests | beyond 25 ms: often shared state that grew too large |
| Rejection rate | writes rejected by rules; a sharp rise signals cheating, a slow rise a poorly set rule |
These metrics are aggregated by day, with nothing stored per request: they cost almost nothing and cannot track anyone.
Two guardrails complete the picture. A game cannot exceed its live-room quota
(max_rooms, generous by default): an overrun returns
quota_rooms, ongoing rooms stay joinable, and new ones become
possible again as they end. And spectators are capped per room, since each costs
like a player.
Recipes by genre
Recipes exist as complete, playable games. Code served as-is, with no build — and replayed as integration tests on every API change.
realtime · both pipes
FPS
Positions and shots over P2P; health and kills in state.
realtime · room.send
Arena
Orbs in realtime, score arbitrated by the server.
turn-based · room.set
Tic-tac-toe
Shared state, atomic moves, turn lock.
.io · setnx
Slither
P2P trajectory, pellets reserved server-side.
Turn-based — cards, board, puzzle
Preset turn_based. Everything goes through shared state; the
direct channel is useless, because a few moves per minute do not justify opening
connections.
const room = await mp.quickmatch({ mode: 'ranked', max_players: 2 });
function play(cell) {
room.set(`board.${cell}`, myMark); // the cell, once and for all
room.cas('turn', mp.account.id, opponentId); // the turn lock
// Atomic: placing the piece without passing the turn would leave the match
// in a state no player can exit.
return room.flush({ atomic: true });
}
room.state.watch('board.*', redraw);
room.state.watch('turn', (who) => setMyTurn(who === mp.account.id));
Realtime — arena, .io, shooter
Preset realtime_arena. Position goes direct; the rest goes in
state.
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, t: mp.now() });
}, 50);
room.on('peer', (p, from) => ghosts[from]?.target(p.x, p.y, p.a, p.t));
room.state.watch('players.*.hp', (hp, path) => updateBar(path, hp));
room.mine('hp', myHp); // must survive a reconnect
Co-op — shared world, survival
Preset coop. The world is written by everyone, with a bounded
rate.
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' });
// Unique item two players may claim at once: the second gets an explicit
// rejection rather than an inconsistent state.
room.setnx(`world.loot.${id}.owner`, mp.account.id);
Associated rule: "world.**": { "write": "any", "rate": 10 }.
Route reference
The full specification is published as OpenAPI 3.1, usable as-is by a client generator.
| Route | Effect |
|---|---|
POST/v1/games | create a game and its keys |
POST/v1/auth/guest | instant guest account |
POST/v1/auth/register | sign up |
POST/v1/auth/login | sign in |
POST/v1/auth/upgrade | upgrade a guest account |
POST/v1/auth/recover | reset token (secret key) |
POST/v1/auth/reset | set a new password |
GET/v1/me | player profile |
PATCH/v1/me | update profile |
POST/v1/me/data | versioned save |
POST/v1/rooms/quickmatch | join or create |
GET/v1/rooms | search rooms |
POST/v1/rooms/{ref}/join | join by code, or role: "spectator" |
POST/v1/rt/sync | the game loop |
GET/v1/rt/ice | ICE servers |
POST/v1/stats | increment counters |
GET/v1/leaderboards/{board} | read a leaderboard |
GET/v1/inventory | player inventory |
POST/v1/matches | submit a result |
POST/v1/matches/{id}/attest | countersign |
POST/v1/batch | batch up to twenty calls |
GET/v1/time | server clock |
Errors and limits
Every response carries ok: true, or ok: false with
an error object containing a stable code, a readable
message, and sometimes details.
{ "ok": false, "error": { "code": "room_full", "message": "This room is full" } }
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 on its own when the delay is short.
Batching calls
PHP startup costs more than the useful work of most requests. Batching five calls therefore cuts cost by roughly as much:
const [me, stats, board] = await mp.batch([
{ path: 'me' },
{ path: 'stats/me' },
{ path: 'leaderboards/arcade', query: { period: 'weekly' } },
]);
render(me.account, stats.stats, board.entries);
Responses come back in order, each with its own status and
ok; one failure does not cancel the others. Twenty calls at most,
no nesting.
Agent integration
The API was designed to be integrated by an agent, with no human in the loop: creating a game needs neither an account nor a UI, and rules are declarative rather than written in code.
| Resource | Use |
|---|---|
| /llms.txt | short overview, load first |
| /llms-full.txt | self-contained reference: integrate everything without another source |
| /openapi.json | OpenAPI 3.1 schema to generate a client |
Pitfalls to avoid
- Ignore
tick_ms. It is the first cost driver and the first cause of rate limiting. - Put positions through shared state. Every write is logged and validated; at sixty frames per second, that is indefensible. Use the direct channel.
- Put lasting data on the direct channel. A reconnecting player received nothing, and neither did a mid-match joiner.
- Take the host at their word. Anything that matters beyond the match must go through attestation or your server.
- Forget host migration. Without it, the match freezes as soon as the host closes their tab.
- Ship the secret key in a client. It authorizes everything, including banning players.
- Leave events without rules. The event channel is as open as shared state. A subject your code treats as round-ending must be host-only.
- Verify a notification signature on re-encoded JSON. Sign and verify the raw body, or the comparison will always fail.