Arcadii Multiplayer Protocol — engine-neutral wire spec (v1)
This document specifies everything a client needs to implement arcadii multiplayer
on ANY platform (web, Godot, Unreal, Unity, native). The TypeScript reference
implementation lives in packages/party-kit/src/; this spec is authoritative for
non-TS clients. See CLIENT_PROMPT.md for generating a client with an LLM.
Base URL: configured per game (PARTY_API_URL, e.g. https://www.sterlinglong.me).
All bodies are JSON. All responses include permissive CORS headers.
1. Authentication
1.1 Credentials
- API key (
mpk_live_…): identifies the game project. Ships in the client. Sent asX-API-Key: <key>on requests. This alone is a complete, working auth setup — new projects need nothing else. - Session token (optional hardening): short-lived HS256 JWT from the token
exchange (§1.2), sent as
Authorization: Bearer <token>. Projects that opt into enforcement (a per-project setting) reject raw keys on gameplay routes; until then both work. Requires attestation configuration on the project. - Room token: per-room scoped JWT returned by room create/join. Used by the realtime signal gateway (§5) and any room-scoped surface. Automatic — no setup.
1.2 Token exchange — POST /api/auth/token (optional hardening)
Headers: X-API-Key. Body:
{
"platform": "web" | "steam" | "dev" | "mobile",
"attestation": "<turnstile token | steam auth-session ticket hex>",
"steam_id": "<optional claimed steamid64, steam only>",
"device_id": "<stable client-generated uuid, for anonymous identity>"
}
Response 200:
{ "session_token": "<jwt>", "token_type": "Bearer", "expires_in": 600,
"player_id": "steam:<id64>" | "anon:<device_id>" }
Rules:
- Refresh proactively 30s before
expires_inelapses. Cache one in-flight refresh (never mint concurrently). - On exchange failure: back off ≥60s and fall back to
X-API-Keyon subsequent requests (until enforcement turns on). NEVER retry the exchange per-request. device_id: generate a UUID once, persist it (localStorage / config file); it makesplayer_idstable for telemetry/attribution.- Errors:
401bad key;403origin not allowed or attestation failed;429rate limited.
2. Rooms
2.1 Create — POST /api/rooms
Body:
{ "game_id": "<game>", "display_name": "…", "host_kind": "screen",
"max_peers": 8, "password": "…", "visibility": "public"|"private",
"metadata": { } }
Response 201:
{ "room_id": "<uuid>", "join_code": "ABCD", "host_secret": "…",
"host_peer_id": "<uuid>", "host_peer_secret": "…", "expires_at": "<iso>",
"room_token": "<jwt: {t:'room', rid, peer:'host', role:'host', exp}>",
"ice_servers": [ { "urls": [ "stun:…", "turn:…?transport=udp", … ],
"username": "…", "credential": "…" } ] }
2.2 Join — POST /api/rooms/{roomId}/peers
Body: { "kind": "screen"|"phone", "display_name": "…", "password": "…", "metadata": {} }
Response 201: { "peer_id", "peer_secret", "room_token", "slot", "kind", "display_name", "ice_servers" }
Errors: 404 not found, 409 full, 423 not joinable.
2.3 Discovery
GET /api/rooms?game_id=X→{ rooms: [{ room_id, join_code, display_name, status, max_peers, peer_count, … }] }(public+joinable only, max 50).GET /api/rooms/lookup?code=ABCD→ single room by join code (uppercase).
2.4 Lifecycle
PATCH /api/rooms/{roomId}body{ host_secret, status|visibility|joinable|max_peers|metadata }.DELETE /api/rooms/{roomId}body{ host_secret }ends the room. Send with keepalive semantics on shutdown; a server cron sweeps stragglers atexpires_at.DELETE /api/rooms/{roomId}/peers/{peerId}body{ peer_secret }on leave.
3. Signaling (WebRTC handshake relay)
Signals are durable rows with a monotonically increasing serial id per room.
3.1 Send — POST /api/rooms/{roomId}/signals
Body:
{ "recipient_peer_id": "<peer uuid | 'host'>",
"signal_type": "offer" | "answer" | "ice_candidate",
"payload": { … },
"host_secret": "…" // when sending AS the host
// or: "sender_peer_id": "…", "peer_secret": "…" (as a joiner)
}
- The HOST is always addressed as the literal peer id
host. - Payloads: offer/answer
{ "type": "offer"|"answer", "sdp": "…" }; ice_candidate = the standard RTCIceCandidate JSON (candidate,sdpMid,sdpMLineIndex,usernameFragment).
3.2 Receive (poll) — GET /api/rooms/{roomId}/signals?recipient_peer_id=X&since_id=N&limit=50
Response: { "signals": [{ "signal_id", "sender_peer_id", "signal_type", "payload", "created_at" }], "next_since_id": N }.
- Poll every 1500ms (baseline). Persist
next_since_idas the cursor. - Keep polling for the entire session — mid-game ICE restarts arrive here.
- Dedupe by
signal_id— the push path (§5) delivers the same rows; apply each signal at most once (cursor: only process ids > last-applied).
4. WebRTC transport
4.1 Channel topology (by remote peer kind)
| kind | reliable channel | unreliable channel |
|---------|------------------|--------------------|
| screen | state (ordered) | input (ordered:false, maxRetransmits:0) |
| phone | data (ordered) | input (same) |
| default | data (ordered) | — |
Binary type MUST be arraybuffer. The offerer creates the channels; the
answerer accepts them. High-rate traffic (inputs, snapshots) goes on input;
app-level redundancy/sequencing recovers loss. Reliable channels are for
lobby/roster/critical state only.
4.2 Roles + handshake
- Offerer = whichever side the app designates (host↔guest pairs: host offers to screens, guests offer to the host — follow the reference: the JOINING side of a pair is told its role by app logic; both roles must be implemented).
- Trickle ICE: send the offer/answer immediately, then each ICE candidate as
its own
ice_candidatesignal. Buffer remote candidates that arrive beforesetRemoteDescription; flush after. - Use the
ice_serversfrom YOUR room create/join response (they contain short-TTL credentials minted for you). Never hardcode TURN.
4.3 Timeouts + recovery ladder (normative constants)
CONNECT_TIMEOUT_MS = 15000per connection attempt (polling signaling).- On
connectionState/iceConnectionState=failed|disconnectedAFTER being connected: do NOT tear down. Start recovery:- Arm a
RECOVERY_GRACE_MS = 10000timer. - Offerer sends an ICE-restart offer (
iceRestart: true); the answerer re-answers when it receives an offer while already connected. MaxMAX_ICE_RESTARTS = 2per outage; reset the budget when healthy again. - Tier-2, once per outage — if still unhealthy when the grace timer
fires: the offerer fetches fresh ICE servers via
POST /api/rooms/{roomId}/refresh-ice(body:{ host_secret }or{ peer_secret, peer_id }→{ ice_servers }with NEW short-TTL creds — the originals expire ~10 min after join, which is why plain ICE restarts fail deep into a match), builds a brand-new peer connection, and re-offers with"renegotiate": trueadded to the offer payload. An answerer that receives arenegotiateoffer while it already has a remote description mirrors: refresh-ice, rebuild its peer connection, then answer normally. A freshCONNECT_TIMEOUT_MSbudget applies. - If the renegotiated attempt also fails → the peer is disconnected.
- Arm a
bufferedAmountguard: if a data channel's buffered amount exceeds 512KB, DROP unreliable sends (return false) instead of queueing — a stalled peer must never grow an unbounded send queue.
5. Realtime signal push (WSS gateway) — OPTIONAL fast path
Endpoint: wss://<SIGNAL_GW_HOST>/rooms/{roomId}?token=<room_token>
- On connect the gateway validates the room token and subscribes you to signals
addressed to YOUR
peerclaim. - Messages (server→client):
{ "type": "signal", "signal": { same row as §3.2 } }. - Heartbeat: server pings every 15s; reply pong (or send
{"type":"ping"}and expect{"type":"pong"}if your WS library lacks frame-level ping). - Reconnect with exponential backoff (1s → 2s → … cap 30s).
- While the socket is OPEN: relax polling to 5000ms (reconciliation). On error/ close: resume 1500ms polling immediately. The POST path (§3.1) is unchanged.
- Everything received here MUST go through the same
signal_iddedupe cursor.
6. Telemetry — POST /api/telemetry/connect
Fire-and-forget after every connection outcome (never block gameplay; ignore failures). Body:
{ "outcome": "connected"|"timeout"|"failed"|"recovered"|"gave_up",
"role": "host"|"peer", "game_id": "…", "room_id": "<uuid>",
"connect_ms": 2100, "candidate_type": "host"|"srflx"|"prflx"|"relay",
"relay_host": "<turn url when relayed>", "ice_restarts": 0,
"signaling_path": "poll"|"push", "ua_hint": "<coarse platform tag>" }
Report: connected (with connect_ms + selected candidate) when the first
channel opens; recovered when a recovery succeeds; timeout/failed/gave_up
terminally. Selected candidate: from getStats — the nominated succeeded
candidate-pair's local candidate type (+ its server url when relay).
7. Players — login, linking, identity (OPTIONAL)
Persistent player accounts across sessions/devices. Fully optional — rooms and
signaling never require a player. The zero-friction path is anonymous:
{"provider":"anon","device_id":"<persisted uuid>"} gives every install a
player with NO login UI; real providers can be LINKED later to make the
account recoverable.
7.1 Login — POST /api/players/login
Headers: X-API-Key (or session token). Body: { "provider", "create": true, "display_name"?, ...proof }. Providers + proof fields:
| provider | proof fields | project config (dashboard → Settings) |
|---|---|---|
| anon | device_id | — |
| steam | ticket (hex auth session ticket), steam_id? | Steam publisher key + App ID |
| gamecenter | public_key_url, signature (b64), salt (b64), timestamp (ms), player_id (teamPlayerID) | Apple bundle id |
| apple | id_token (Sign in with Apple identity token) | Apple bundle id |
| google | id_token (Sign in with Google ID token) | Google web OAuth client id |
| discord | code, redirect_uri | Discord client id + secret |
| email | access_token (from the hosted signup/sign-in — §7.4) | — (hosted; zero setup) |
Response 200: { "player_token", "token_type": "Bearer", "expires_in": 86400, "player_id", "display_name", "created" }. Errors: 403 proof rejected or
banned; 404 when create:false and no such player.
7.2 Player token
24h HS256 JWT — the game's credential for player-scoped calls. Re-login
silently on expiry. Also valid as ATTESTATION for the session-token exchange:
POST /api/auth/token { "platform": "player", "attestation": "<player_token>" }
binds the multiplayer session to the player (player_id: player:<uuid> in
telemetry).
7.3 Provider discovery — GET /api/players/providers
Headers: X-API-Key. Returns which sign-in methods the project supports (so
the game renders the right buttons) plus the hosted-email connection info:
{ "providers": { "anon": true, "email": { "auth_url", "anon_key" }, "steam": bool, "gamecenter": bool, "apple": bool, "google": bool, "discord": { "client_id" } | false } }.
7.4 Hosted email accounts (zero setup)
Full email accounts — signup, password sign-in, magic links, password resets —
hosted by the platform. Use the auth_url + anon_key from §7.3 with these
REST calls (any HTTP client; the apikey header is the anon key):
- Sign up:
POST {auth_url}/signup{ "email", "password" } - Sign in:
POST {auth_url}/token?grant_type=password{ "email", "password" }→{ "access_token", "refresh_token", ... } - Refresh:
POST {auth_url}/token?grant_type=refresh_token{ "refresh_token" } - Magic link:
POST {auth_url}/magiclink{ "email" }(link redirects must be coordinated with the platform; password flow needs no redirects — prefer it for games)
Then trade the access token for a player:
POST /api/players/login { "provider": "email", "access_token": "<token>" }.
The email account is global to the platform, but the resulting PLAYER is
scoped to your project (the same email in another game is a different player).
7.5 Identity management (Bearer player_token)
GET /api/players/me→ player + linked identities (subjects masked).PATCH /api/players/me{ display_name }.POST /api/players/link{ provider, ...proof }— attach another provider.409 identity_already_linkedwhen it belongs to a different player (no force-link).POST /api/players/unlink{ provider }—400 cannot_unlink_last_identityguards the final credential.
8. Player content — save & share (OPTIONAL)
Cloud storage for replays, levels, saves — owned by players, shareable by
visibility (private | unlisted | public) and an 8-char share_code.
8.1 Create (small JSON, ≤512KB) — POST /api/player-content
Bearer player_token. Body: { "content_type": "level"|"replay"|"save"|…, "name", "description"?, "game_id"?, "visibility"?, "data": <any JSON> }
→ 201 { id, share_code, size_bytes }. 402 on plan quota.
8.2 Create (large/binary, ≤10MB) — POST /api/player-content/upload-url
Same metadata + { "size_bytes", "content_mime" } → { id, share_code, upload_url, token }. PUT the raw bytes to upload_url
(x-upsert: true header not needed; single-use), then
POST /api/player-content/{id}/finalize — verifies the object + real size and
flips it live. Replays: for deterministic sims, store seed + input stream —
compact and perfectly reproducible.
8.3 Read / browse
GET /api/player-content?mine=true(player token) — own content.GET /api/player-content?visibility=public&content_type=level&game_id=…&limit=20(API key) — public browse, newest first,beforecursor.GET /api/player-content?share_code=AB23CD45— fetch shared (public/unlisted).GET /api/player-content/{id}→ metadata +download_url(1h signed URL);?inline=truereturns small JSON directly as{ ..., data }.
8.4 Manage (owner, Bearer player_token)
PATCH /api/player-content/{id} { name?, description?, visibility? };
DELETE /api/player-content/{id}.
Plan caps: free 200 items / 100 MB per project; pro 10,000 items / 5 GB.
9. Test flags (all clients SHOULD implement)
relay=1→iceTransportPolicy: "relay"(forces TURN; connectivity matrix).turnproto=tcp|tls→ filter the minted ice_servers to onlyturn:…transport=tcp/turns:entries before constructing the peer connection.net=debug→ verbose connection logging.
10. Conformance checklist
- Token exchange with refresh-before-expiry, single-flight, 60s failure backoff.
- Create/join/list/lookup rooms; secrets + room_token stored.
- Signals: send offer/answer/trickle-ICE; poll with cursor; whole-session polling.
- Channel topology incl. UNRELIABLE input channel; arraybuffer binary.
- Recovery ladder with the §4.3 constants; buffered-amount drop guard.
- Signal-id dedupe shared by poll + push paths; WSS fast path optional but recommended.
- Telemetry on every outcome.
- Test flags §7; connect under 15s on normal networks, works with
relay=1.