multiplayerapi

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.

01 · players

Connect

Accounts scoped per game, matchmaking, WebRTC signaling.

02 · state

Arbitrate

Writes to shared state are validated against rules you declare.

03 · save

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.

5.9 KBSDK gzip · zero dependencies
3 callslogin → match → play
5 stick once peer-to-peer is up

Keys and authentication

HeaderValueGrants access to
X-Mp-Keymp_pk_…player operations
X-Mp-Keymp_sk_…everything, including privileged writes
AuthorizationBearer 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.

CallEffect
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
Rule of thumb If losing this message does not matter because another arrives in 50 ms, use the direct channel. Otherwise, go through state.
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
Pathbetween playersserverserver
Deliverybest-effortguaranteedguaranteed and ordered
Survives reconnectnoyesno
Received by a late joinernoyesno
Validated by rulesnoyespartially
Server costzero on peer-to-peerone writeone 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.

OperationEffect
setwrites a value
incincrements a number
pushappends to an array
deldeletes a key
setnxwrites only if the key is absent
caswrites 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:

SituationRecommended cadence
Negotiating direct connections250 ms
Server relay, small match100 ms
Server relay, large matchup to 250 ms
Server relay, nothing exchanged1 s
Waiting lobby2 s
Match with peer-to-peer established5 s
Single-player match10 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 }
  }
}
KeyRole
writeany, self, host, server, none
$selfthe id of the player who is writing
* and **one segment, or the rest of the path
ratewrites per second allowed on this path
monotonicup or down: forbids going backwards
delta_maxcap on a spontaneous client write
match_maxcap on what a countersigned result may grant
requiresattestation: forbids direct writes
server_onlyreserved for the secret key
max_bytesmax 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);
});
VerdictMeaning
pendingwaiting for signatures
confirmedmajority agrees, rewards paid
disputeddisagreement: nothing paid, a report is opened
unconfirmednobody answered within five minutes
solono 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.

EventTrigger
match.endeda result is settled, with its verdict and what was paid
match.disputedplayers disagree: nothing was paid
cheat.flaggeda report is opened on an account
player.bannedan 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.

MetricWhat it says
Peer-to-peer sharethe fraction of ticks held direct; below two thirds, TURN pays for itself
Average latencytime spent server-side, excluding network
Slow requestsbeyond 25 ms: often shared state that grew too large
Rejection ratewrites 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.

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.

RouteEffect
POST/v1/gamescreate a game and its keys
POST/v1/auth/guestinstant guest account
POST/v1/auth/registersign up
POST/v1/auth/loginsign in
POST/v1/auth/upgradeupgrade a guest account
POST/v1/auth/recoverreset token (secret key)
POST/v1/auth/resetset a new password
GET/v1/meplayer profile
PATCH/v1/meupdate profile
POST/v1/me/dataversioned save
POST/v1/rooms/quickmatchjoin or create
GET/v1/roomssearch rooms
POST/v1/rooms/{ref}/joinjoin by code, or role: "spectator"
POST/v1/rt/syncthe game loop
GET/v1/rt/iceICE servers
POST/v1/statsincrement counters
GET/v1/leaderboards/{board}read a leaderboard
GET/v1/inventoryplayer inventory
POST/v1/matchessubmit a result
POST/v1/matches/{id}/attestcountersign
POST/v1/batchbatch up to twenty calls
GET/v1/timeserver 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.

ResourceUse
/llms.txtshort overview, load first
/llms-full.txtself-contained reference: integrate everything without another source
/openapi.jsonOpenAPI 3.1 schema to generate a client

Pitfalls to avoid