# Multiplayer Agent: full corpus > Make any game multiplayer with one key, no engineering, no code. This file contains everything published on https://multiplayeragent.com/: the complete SDK reference, every guide and every comparison. It is generated from the same sources as the website, so it is never a different version of it. Index: https://multiplayeragent.com/llms.txt --- # Connecting an AI agent (Model Context Protocol) Endpoint: https://api.multiplayeragent.com/mcp (streamable HTTP) Auth: Authorization: Bearer , starting sk_, from https://multiplayeragent.com/dashboard/key { "mcpServers": { "multiplayer-agent": { "type": "http", "url": "https://api.multiplayeragent.com/mcp", "headers": { "Authorization": "Bearer sk_your_secret_key" } } } } The secret key is per studio. It is not the publishable key that ships inside a game, and it must never appear in client code. The tool server is a deterministic API rather than a language model: your agent supplies the reasoning, this supplies the platform. --- # Pricing Billed by monthly active players rather than concurrent connections. A traffic spike costs nothing extra; an idle game costs nothing. Player and traffic limits are measured, not enforced: crossing one raises an alert rather than dropping players. Features are identical across tiers; only limits differ. - Free: free: 1,000 monthly active players, 3 games, 30 concurrent per game, 10 GB traffic, 3 team seats, 7 days of analytics history - Indie: $15/month or $150/year: 25,000 monthly active players, 12 games, 200 concurrent per game, 100 GB traffic, 6 team seats, 90 days of analytics history - Pro: $59/month or $590/year: 75,000 monthly active players, 25 games, 800 concurrent per game, 300 GB traffic, 20 team seats, 270 days of analytics history - Studio: $199/month or $1,990/year: 250,000 monthly active players, 100 games, 2,000 concurrent per game, 1,000 GB traffic, 100 team seats, 720 days of analytics history Ad revenue is kept in full by the developer (bring your own ads id). A cut is taken only on in-game purchases sold through the platform. Full pricing page: https://multiplayeragent.com/pricing --- # SDK reference # Multiplayer Agent: SDK reference > **⚠️ This is the public reference: the key below is a PLACEHOLDER. Sign up and create a game to get your own.** Everything below is wired to ONE game: **(unnamed game)** (`pk_live_create_a_game_first`). A studio holds many games and each has its own key: confirm that is the game you are building before you copy anything, because the publishable key routes players, saved progress and leaderboards. Ship the wrong one and nothing errors: the room works and your players quietly accumulate in another game. The publishable key (`pk_…`) is safe to ship in client HTML; it routes the tenant and resolves player scope. Never put a secret key (`sk_…`) in a client. Your values: - game: (unnamed: name it with set_game_name) - publishableKey: `pk_live_create_a_game_first` - realtime endpoint (wsEndpoint): `wss://live.multiplayeragent.com` - control-plane (apiUrl): `https://api.multiplayeragent.com` - SDK bundle (sdkUrl): `https://cdn.multiplayeragent.com/sdk/mp.global.js` ## What Multiplayer Agent is Multiplayer Agent is a hosted backend for browser games. You write the game the way you already would: HTML, JS, canvas, or any web engine, and call this SDK for the parts that need a server: the other players, saved progress, scores, purchases. There is no server to run, no netcode to write, no database to model, and no schema to declare before you start. **What one key gets you.** The publishable key above routes every service below, and each has its own section further down: - **Realtime rooms**: live shared state between the players in a room, plus reliable ordered events for shots, hits, pickups and abilities. A room is just a name, and it auto-creates the first time somebody joins it. - **Player identity**: anonymous-first, survives a reload, claimable into a real username/password login, and linkable to a portal account (Y8, Poki, CrazyGames, Google) so the same player carries across devices. - **Player datastore**: a JSON document per player for saves, stats, inventory and loot. - **Leaderboards**: named boards with max/inc/set submits, top-N, each player's rank, and the board's total size for "rank 7 of 412". - **Player-created content**: a shared per-game library players publish levels, ships, decks or ghosts into, with share-code deep links, voting, reporting and an owner moderation queue. - **Authoritative server logic**: deploy JS that runs the room on the server, so clients send inputs instead of state and cannot cheat the rules, the saves, or the scores. Versioned, with logs and rollback. - **Monetization**: one-time Stripe purchases granting server-verified entitlements a client can read but can't fake, plus bring-your-own ads (you keep 100% of the ad revenue). - **Analytics, errors & bug reports**: anonymous first-party sessions on every join, custom events, auto-captured client errors, and in-game player bug reports, all on the owner's dashboard. No third-party trackers, ever. **Two ways to build.** Drop the ` ``` (ESM: `import * as MP from "@mp/sdk"`.) ## Realtime room: generic, schemaless, authoritative-by-ownership One generic room hosts every game; a "game" is just a room name. A client may only write its OWN entity (`me.set`); `setShared` writes room-wide state. ```js const mp = await MP.join({ key: "pk_live_create_a_game_first", room: "my-game", endpoint: "wss://live.multiplayeragent.com", apiUrl: "https://api.multiplayeragent.com" }); mp.me.set("x", 100); // your own entity, one field mp.me.set({ x: 100, y: 50 }); // or several mp.setShared("turn", "x"); // room-wide field mp.onState((state) => { // fires on every change // state.entities.players[id].x ; state.shared.turn }); mp.onJoin((id) => {}); // another player joined mp.onLeave((id) => {}); // another player left mp.sessionId; // this connection's id await mp.leave(); ``` A room holds **32 players by default**; raise it with `maxClients` in the room spec (`define_room_type`), up to 256, and never above your plan's per-game concurrent-player cap, which is what actually rejects the join ("this game is at its concurrent-player limit"). **Reconnection is built in.** If a client drops (tab backgrounded, mobile handoff, flaky wifi) the SDK reconnects in the background and the server holds that seat + entity for ~30s, so the SAME player resumes instead of respawning. Show a hint rather than freezing or kicking: ```js mp.onDisconnect(() => showBanner("Reconnecting…")); mp.onReconnect(() => hideBanner()); // state re-syncs automatically ``` **Leave on unload, or every reload leaves a ghost.** That same 30s seat-hold means a refresh that didn't leave cleanly shows the player twice to everyone. Always: ```js window.addEventListener("pagehide", () => mp.leave()); ``` **Pass both URLs, and don't confuse them:** `endpoint` (`wss://…`) is the realtime server; `apiUrl` (`https://…`) is the control plane behind analytics, player identity, leaderboards, and bug reports. Include both on every `MP.join` (and `apiUrl` on every `MP.auth`), copying the exact values from "Your values" above. Omitting `apiUrl` makes the realtime room still work but silently disables everything else (it falls back to `localhost`). ### State limits: they fail SILENTLY, so design inside them A write past any cap below is **dropped with no error and no exception**: the value simply never appears, and the game looks frozen rather than broken. (A real game stalled at 54% because it stored one shared field per grid cell and every write past the cap vanished.) Current caps: | Cap | Limit | | --- | --- | | Room-wide shared fields (`setShared`) | **2048**: raised from 128, so one field per cell of a ~45×45 grid is now a fine design | | Fields per player entity (`me.set`) | **64** | | Characters in one field value | **2048** | | Characters in one event / input payload (`send` / `sendInput`) | **2048** | | Messages per second, per client | **60** (burst 120) | Need more than a few thousand shared fields? Pack state into fewer fields (one string per grid ROW instead of one per cell) rather than assuming the write landed. The whole room state broadcasts to every client at ~20/s, so keep it lean even well inside the caps. ## Events: discrete, reliable messages (shot / hit / pickup / ability) For fire-and-forget events that must NOT be lost between state snapshots (don't use monotonic counters on state for these). Every event is delivered, in order. ```js mp.send("shot", { x: 10, y: 20, dir: 1.5 }); // send a named event mp.on("shot", (data, from) => spawnVFX(data, from)); // from = sender sessionId ``` - Relay rooms: the server fans your event out to the OTHER clients (you render your own locally). - Authoritative rooms (Tier-3a): your event goes to the server logic's onEvent(id, type, data); the server validates and emits back via emit()/emitTo() (those arrive with from === "server"). ## Player identity: anonymous-first, claimable Players are scoped to the account (shared across all your games) by default; `set_player_scope` can isolate a game to its own players, but it's a set-once decision (it locks once the game has players). ```js const player = MP.auth({ key: "pk_live_create_a_game_first", apiUrl: "https://api.multiplayeragent.com" }); const me = await player.getPlayer(); // durable anonymous player (persisted in localStorage) // me = { id, username: null, claimed: false } await player.claim("ada", "a-long-password");// upgrade the SAME id to a login await player.login("ada", "a-long-password");// sign in on another device player.logout(); // next getPlayer() makes a fresh anon ``` **Portal / federated logins (Y8, Poki, CrazyGames, Google sign-in, …).** If your game runs on a web portal that has its own accounts, map a portal login to the SAME durable MPA player across devices, no username/password typed by the player. Get the token from that portal's SDK and hand it to `loginExternal`; the server verifies it against the provider (never trusting the token on face value) and returns the durable player for that provider account. The player's first sign-in absorbs this device's anonymous progress (same as `claim`); afterwards it always resolves to that player. ```js // e.g. Y8: const t = await ID.getGuestLoginToken?.() ?? y8AccessToken; // e.g. Poki: const t = await PokiSDK.getToken(); await player.loginExternal({ provider: "y8", token: t }); // CrazyGames-hosted games can keep the shorthand: await player.crazygames(await window.CrazyGames.SDK.user.getUserToken()); ``` Each provider (except the built-in `crazygames`) must be configured for your game first: that's an operator step (it holds the provider's verification secret server-side), so ask your MultiplayerAgent contact to enable a provider; you can't self-serve it, and the `pk_` stays public/safe. Providers a game has enabled are viewable (names only, never secrets) via the studio dashboard. ## Player datastore: saves / stats / inventory / loot One JSON document per player (per optional namespace), with optimistic concurrency: pass the version you read back; a stale write throws `ConflictError`. ```js const save = await player.data.get(); // { namespace, data, version } await player.data.set({ level: 3, gold: 120 }); // version auto-tracked await player.data.set({ coins: 5 }, { namespace: "world-2" }); // separate per-game doc ``` One write request is capped at 256KB, so keep a single document under ~200KB, split anything bigger across namespaces. **Trust:** `player.data.set` is a CLIENT write: fine for cosmetics, settings, and local prefs, but a cheater can call it with any values, so never trust it for currency, unlocks, or scores. For data that must be trustworthy, have your **authoritative server logic** write it instead (see "Trustworthy saves & scores" under Authoritative server logic) and read it back here with `player.data.get("@save")`: server-managed (`@…`) namespaces are read-only to the client, so they can't be faked. ## Leaderboards A board is a name within the player's scope (org-wide by default). ```js const board = player.leaderboard("highscores"); await board.submit(900); // mode "max" (default = keep best) await board.submit(10, "inc"); // or "inc" (running total) / "set" (overwrite) const { entries, total } = await board.top(10); // entries: [{ rank, playerId, username, score }] const mine = await board.myRank(); // { score, rank, total }, score/rank null if unranked ``` Every read returns `total`: how many players are on the board, so you can show "rank ${mine.rank} of ${mine.total}". It's the FULL board size, not `entries.length` (which the limit caps), and it's present even when the player has no score yet. `submit()` also returns `{ score, rank, total }`, so a post-game screen needs one call, not two. Board names are 1–64 characters, and `top()` returns at most 100 entries per call (higher limits are clamped, not rejected), page or show a top-N plus the player's own rank rather than fetching a whole board. ## Player-created content (UGC): level sharing, ghosts, ships, decks The datastore above is PRIVATE to one player, so it can hold "my levels" but never "everyone's levels". Content is the shared half: one player publishes, every other player of that game can browse and load it. A collection is just a name, created by its first publish: nothing to configure. ```js const levels = player.content("levels"); const { id } = await levels.publish({ // requires a CLAIMED player title: "Cave of Wonders", data: { tiles, spawns }, // the level itself (≤64KB) meta: { difficulty: "hard", thumb: dataUri }, // small preview, shown in listings (≤2KB) }); const { items, total } = await levels.list({ sort: "top", limit: 20 }); // or "new" const full = await levels.get(items[0].id); // full.data = the level to load await levels.vote(id); // one vote per player; vote(id, false) undoes await levels.report(id, "not a level"); // routes to the studio; auto-hides if enough agree const { items: mine } = await levels.mine(); // this player's own items await levels.update(id, { title: "Cave v2" }); // author only await levels.remove(id); // author only ``` Key facts, all deliberate: - **Scope is THIS game.** One game's library is invisible to every other game, including others under the same account. - **Listings never carry `data`**: only `meta`, so browsing 20 levels doesn't download 20 levels. Call `get(id)` when the player actually picks one. - **`id` is a short URL-safe share code.** Deep-link with `?level=${id}` and load it with `content("levels").get(id)`; that's the whole "share my level" loop. - **Publishing needs a claimed player** (`player.claim(username, password)` or a portal login). An anonymous identity vanishes with its localStorage, taking any way to edit, or answer for: the content with it. Reading and voting stay open to anonymous players. - **Moderation is built in.** Any player can `report`; enough distinct reports auto-hide an item pending review; the owner sees a review queue in the portal and can restore or delete. Wire a report button: UGC without one is a liability. - **Caps:** `data` ≤64KB, `meta` ≤2KB, 100 items per player per collection, and a per-game library cap from the account's plan. There is no file/asset upload reference art the game already ships, or embed a small data URI in `meta`. - **Trust:** a published item is CLIENT data. Validate it on load (an impossible level, a hostile payload) exactly as you would any player input. - To play a shared level together, broadcast the id in room state (`mp.setShared`) and have every client `get` it: content is durable storage, not room state. ## Analytics: anonymous, automatic, first-party Every `MP.join` starts an anonymous session (a device-scoped id in localStorage no login, no PII) and tracks `session.start`, a periodic `session.heartbeat`, and `session.end` for you. `session.start` automatically carries coarse traffic-source context (page host/path, referrer host, iframe-embedded flag, language, timezone, and whether storage persists: blocked storage means player counts inflate per visit) so the owner can see where players come from: nothing to wire up. **Required: pass `apiUrl` to `MP.join`** (`"https://api.multiplayeragent.com"`, exactly as in the snippets above): that single argument is all sessions need; there is no other setup. WITHOUT it the SDK falls back to `http://localhost:3001` and every event silently fails from a player's browser, so your dashboard stays empty even though multiplayer works: the #1 cause of "no analytics". (The same `apiUrl` powers player identity, leaderboards, and bug reports, so set it everywhere.) To confirm it's wired: open the game, DevTools → Network, and check that POSTs to `https://api.multiplayeragent.com/v1/events` return `202`. Track anything else with `mp.track`: ```js mp.track("level.complete", { level: 3, score: 900 }); mp.track("match.end", { result: "win" }); ``` Use any event name (lowercase, dot-namespaced): it's schema-light, so new events need no setup. Common ones: `session.*`, `match.*`, `round.*`, `level.*`, `economy.purchase`, `ad.complete`, `social.invite_send`. The owner's portal dashboard has a per-game Analytics tab: daily player/session charts, traffic sources, D1/D7 retention, live concurrency, top events, selectable time windows all automatic with no game code beyond `apiUrl`. **Error tracking is automatic too.** During a session the SDK captures uncaught errors and unhandled promise rejections (message + stack, deduped, ≤10/session), failed `MP.join` calls, and server room errors, and they appear grouped on the portal's per-game **Errors** tab alongside the deployed room logic's server logs. **When you build a game, ALSO wire explicit error tracking.** Auto-capture only sees UNCAUGHT errors: a caught-but-broken state (failed asset load, rejected save, a feature silently disabled by a catch block) is invisible without it. The pattern: ```js try { await loadSprites(); } catch (err) { mp.track("error", { message: String(err?.message ?? err), where: "loadSprites" }); showErrorOverlay("Couldn't load game art: check your connection and reload."); } ``` Do this at every failure point that has a catch: asset/audio loading, storage access, external API calls, and any game-logic invariant worth knowing about (`mp.track("error", { message: "score went negative", where: "endRound" })`). Always pair tracking with something visible to the player (an overlay or retry), never a silent freeze, and never put secrets or PII in error props. Best-effort and never blocks gameplay; disable per game with `MP.join({ …, analytics: false })`. ## Bug reports: let players tell you what broke Drop a "Report a bug" button in your game; the message is stored against your game (the `pk_`) and you read/clear it on the portal dashboard, no player login. ```js await mp.reportBug("Stuck on level 3: the door won't open", { email: "me@example.com" }); // standalone, with no room: await MP.reportBug({ key: "pk_live_create_a_game_first", apiUrl: "https://api.multiplayeragent.com", message: "…" }); ``` The current room + your sessionId are attached automatically; pass extra detail (build, platform, repro) as `context` in the second arg. Long messages are truncated server-side; oversized context is dropped. ## Monetization: one-time purchases (Stripe) Sell an unlock with Stripe Checkout. A completed purchase grants a server-verified **entitlement** that the client can read but cannot fake (only Stripe's signed webhook writes it), so it is safe to gate paid content on. ```js // On a "buy" button, send the player to Stripe's hosted checkout page: const { url } = await player.checkout("premium"); // sku; successUrl/cancelUrl default to this page location.href = url; // pay on Stripe, then it redirects back here // On load AND right after they return from checkout, read what they own: const ents = await player.entitlements(); // e.g. { premium: true } if (ents.premium) unlockTheThing(); ``` The webhook lands a moment after the redirect; if the SKU isn't set the instant they return, poll `entitlements()` once or twice over a couple seconds. Buying SKU `"x"` grants `entitlements().x === true`. Catalog (PoC): `tier1` $0.99 · `tier2` $1.99 · `tier3` $2.99 · `tier4` $3.99 · `premium` $4.99 (default). Needs the server's Stripe keys configured; until then `checkout()` returns 503. ## Authoritative server logic (Tier-3a) Deploy server-side code (via the `deploy_room_logic` MCP tool or `POST /v1/rooms/:room/logic` with the secret key) and the room runs AUTHORITATIVELY: the server computes state, clients only send inputs, so they can't cheat. Define handlers as functions over a global `state` (RoomStateView shape); state you set persists across ticks: ```js function onJoin(id) { state.entities.players[id] = { x: 0, score: 0 }; } function onLeave(id) { delete state.entities.players[id]; } function onInput(id, input){ const p = state.entities.players[id]; if (p) p.x += (input.dx || 0); } function onTick(dt) { state.shared.clock = (state.shared.clock || 0) + dt; } // discrete events from clients (mp.send), validate then emit back to clients: function onEvent(id, type, data){ if (type === "shot" && isValidShot(id, data)) emit("hit", { by: id, target: data.target }); // emit(type, data) -> all clients; emitTo(id, type, data) -> one client } ``` NPCs / bots / any non-player entities go in their OWN collection under `state.entities` (e.g. `state.entities.bots`): each entity's fields sync individually, so moving one of 100 bots sends only that bot's changed fields (no JSON-array sharding): ```js function onTick(dt){ state.entities.bots ??= {}; for (const b of Object.values(state.entities.bots)) b.x += b.vx * dt; // field-granular sync } ``` Because clients can't write state in authoritative mode, the server owns roles elect a host with zero client trust: ```js function onJoin(id){ if (!state.shared.matchHost) state.shared.matchHost = id; } function onLeave(id){ if (state.shared.matchHost === id) state.shared.matchHost = Object.keys(state.entities.players)[0] || ""; } ``` ### Trustworthy saves & scores (server-authoritative persistence) The whole point of authoritative mode is that the SERVER decides what's true, so the server, not the client, should write saved progress and leaderboard scores. The client sends only inputs/events ("I touched a coin"); your logic decides the reward and persists it. A cheater can't fake it because they never write it. ```js function onJoin(id){ const save = loadData(id); // this player's server-only save (may be {}) state.entities.players[id] = { x: 0, gold: save.gold || 0, level: save.level || 1 }; } function onEvent(id, type, data){ const p = state.entities.players[id]; if (!p) return; if (type === "coin"){ // client only claims the input… p.gold += 1; // …the SERVER decides the reward, saveData(id, { gold: p.gold }); // …and persists it (can't be faked) } if (type === "finish"){ submitScore(id, "highscores", p.gold, "max"); // server submits the score } } ``` - `loadData(id [, name])` → the player's server-managed save object (synchronous; `{}` if none, or the session isn't identified). Default doc; pass a name for a second doc (e.g. `loadData(id, "inventory")`). - `saveData(id, patch [, name])` → shallow-merge `patch` into that save (queued and written server-side; last-writer-wins, no version juggling). - `submitScore(id, board, score [, mode])` → submit to a leaderboard ("max" (default) / "inc" / "set"). - **Identify the player:** the client must pass its identity to `MP.join` so the server knows whose data to write: `MP.join({ …, player })` where `player = MP.auth({ key: "pk_live_create_a_game_first", apiUrl: "https://api.multiplayeragent.com" })` after `await player.getPlayer()`. Anonymous players work (the durable anon id is the account); without an identity these calls are silently inert. - **Read it on the client** (read-only): `await player.data.get("@save")` (default doc) or `player.data.get("@"+name)`. The client can't write `@…` docs. ```js // CLIENT: identify, join, then render trustworthy saved progress const player = MP.auth({ key: "pk_live_create_a_game_first", apiUrl: "https://api.multiplayeragent.com" }); await player.getPlayer(); const mp = await MP.join({ key: "pk_live_create_a_game_first", room: "my-game", endpoint: "wss://live.multiplayeragent.com", apiUrl: "https://api.multiplayeragent.com", player }); mp.send("coin"); // just an input, server credits the gold const { data } = await player.data.get("@save"); // { gold, level } written by the server ``` Gotchas: if `player` isn't passed to `MP.join`, `loadData/saveData/submitScore` are silent no-ops (the server doesn't know who the session is): that's the usual reason saves don't appear. Writes flush asynchronously (within ~1s, and on leave/room-close), so a client `player.data.get("@save")` right after a `saveData` may not see it yet. `saveData` shallow-MERGES the patch (it won't drop fields you didn't include). Handlers must be synchronous. Client side, send inputs and events instead of setting state: ```js mp.sendInput({ dx: 1 }); // continuous -> onInput(sessionId, input) mp.send("shot", { target: "x" }); // discrete -> onEvent(sessionId, type, data) mp.on("hit", (data) => boom(data)); // server emit() arrives here (from === "server") ``` Runtime errors in your code surface in `state.shared.__error` (and the server log). ## Building real-time games well (prediction, interpolation, limits) The realtime layer is WebSocket-based. It's excellent for turn-based, casual, co-op, and arena games, and casual shooters, IF you apply the patterns below. It is NOT tuned for competitive twitch FPS (instant hitscan, high precision, high ping); that's a UDP tier on the roadmap. For anything fast-moving: - **Client-side prediction (your own player):** apply the local player's input IMMEDIATELY; never wait for the server round-trip. Tag each input with an incrementing `seq`; the authoritative logic echoes it back; on each state update, snap to the server position and replay inputs newer than the echoed seq. - **Entity interpolation (other players):** render remote players ~100ms in the past, lerping between the last two snapshots: don't snap them per tick. - **Fixed rates:** send inputs at 30-60/s, but keep the server `tickRate` at 20-30: state patches broadcast to clients at ~20/s regardless, so a higher server tick burns CPU (and risks tick-budget kills) without making anything smoother for players; smoothness comes from the prediction + interpolation above, not the server rate. - **Prefer projectiles / slower time-to-kill over instant hitscan.** There's no lag compensation yet, so hitscan forces aiming where the *server* sees a target; visible projectile travel hides latency and feels fair. - **Expect brief stalls under packet loss / high ping** (delivery is reliable + ordered, so a lost packet delays later ones). Same-region, low ping feels best. ```js // CLIENT: predict locally, reconcile against server truth using a seq the server echoes let seq = 0; const pending = []; function move(cmd){ cmd.seq = ++seq; pending.push(cmd); applyLocally(cmd); mp.sendInput(cmd); } mp.onState((s) => { const me = s.entities.players[mp.sessionId]; if (!me) return; setLocalPosition(me); // snap to server truth while (pending.length && pending[0].seq <= me.ack) pending.shift(); for (const cmd of pending) applyLocally(cmd); // replay un-acked inputs }); // SERVER logic: echo the seq so the client can reconcile function onInput(id, input){ const p = state.entities.players[id]; if (!p) return; applyMove(p, input); p.ack = input.seq; } ``` ### Deploy lifecycle Each `deploy_room_logic` returns a `version`. Read runtime errors without joining the room with `get_room_logs`; list past deploys with `list_room_logic`; undo a bad deploy with `rollback_room_logic` (restores the previous version by default, or a specific one: history stays intact). Limits: code <= **256KB** (raised from 64KB: a full game's server logic fits), `tickRate` 1-60 (default 20). **What your code can and can't use.** It runs in a locked-down V8 isolate, not Node: - Handlers must be **synchronous**, no `async`/`await`, no promises, no `setTimeout`/`setInterval`. Use `onTick(dt)` for anything time-based. - **No network** (`fetch` doesn't exist) and **no `require`/`import`**. Everything the logic needs must be in the deployed source. - `console.log` is a **no-op**; it goes nowhere. Debug by writing to `state.shared` (visible to clients + `/monitor`) or by reading `get_room_logs`. - `Math`, `JSON`, and `Date` are available. - **State must stay JSON-serializable**: plain objects, arrays, numbers, strings, booleans. A `Map`, `Set`, class instance, or function assigned into `state` will NOT survive to clients. Top-level variables in your source DO persist across handler calls, so keep a richer working copy there and mirror the plain-data view into `state`. - Each room's logic gets its own 128MB heap. **Tick budget:** each handler call has a wall-clock kill-budget (≥25ms) that exists to stop infinite loops; a killed tick is skipped mid-way, so treat repeated `onTick: Script execution timed out` lines in `get_room_logs` as "this logic is too heavy for its tickRate". Fix by lowering `tickRate` (20 is right for almost everything: see Fixed rates above) and keeping `onTick` to a few milliseconds: precompute constants at load, avoid allocating arrays/objects per tick, and scale per-entity work down when the room is full. ## Ads (optional, BYO: Google H5 Games Ads) `get_connection_info` returns the owner's ad publisher id as `adsPublisherId` (a `ca-pub-…` value), or `null`, in which case render NO ad code at all. The owner keeps 100% of ad revenue. These are browser HTML5 games, so use **Google H5 Games Ads (the AdSense Ad Placement API)**, NOT AdMob (that is native-mobile only). It needs an approved AdSense / H5 Games Ads account and only serves on the real hosted page (not localhost, not a sandboxed artifact iframe). Substitute the `adsPublisherId` value for `ca-pub-XXXXXXXXXXXXXXXX` below. 1) Load AdSense with the owner's id (in ``): ```html ``` 2) Enable the Ad Placement API once, on load: ```js window.adsbygoogle = window.adsbygoogle || []; const adBreak = (o) => window.adsbygoogle.push(o); const adConfig = (o) => window.adsbygoogle.push(o); adConfig({ preloadAdBreaks: "on", sound: "on" }); // preload so the next break is instant ``` 3) Interstitial at a natural break (round/level end, never mid-action): ```js function endRound(){ pauseGame(); // pause + mute BEFORE the ad adBreak({ type: "next", // start | next | pause | browse name: "round-end", beforeAd: muteAudio, afterAd: unmuteAudio, adBreakDone: () => resumeGame(), // ALWAYS called (ad shown or not) -> resume here }); } ``` 4) Rewarded ad (explicit opt-in for a reward: extra life, coins): ```js function offerReward(){ adBreak({ type: "reward", name: "extra-life", beforeReward: (showAd) => showWatchAdButton(showAd), // call showAd() when they click adViewed: () => grantReward(), // finished -> give the reward adDismissed: () => {}, // bailed early -> no reward beforeAd: muteAudio, afterAd: unmuteAudio, adBreakDone: () => {}, }); } ``` Rules that keep it working (and policy-safe): - Always resume gameplay in `adBreakDone` / `afterAd`: they fire even when no ad is available, so never block the game waiting on an ad. - Interstitials only at natural breaks, and don't spam them (H5 frequency-caps; over-showing hurts fill and risks policy). Rewarded must be explicit opt-in. - If `adsPublisherId` is `null`, ship no ad code. ## Notes - One studio (secret key) can have many games; each game is its own publishable key. Create one with the `create_game` MCP tool (or the portal). **Say which game every call is for**: pass the `game` argument (a `pk_` or the game's exact name) to any game-scoped tool, or scope the whole MCP connection with the `x-mp-game` header. With neither, tools fall back to your PRIMARY (oldest) game usually your longest-running one, not the one you're building. Tools that CHANGE a game refuse that fallback outright once a studio has more than one game. - Same key + room = the same live game; a new room name auto-creates a new game. - **Plan limits** bound how many games a studio holds and how many concurrent players ONE game may have across all its rooms. Free: 5 games / 256 players per game; Indie 15 / 512; Pro 25 / 1000; Studio 50 / 2000. `create_game` fails with "plan limit reached" at the game cap, and joins past the player cap are rejected with "concurrent-player limit": the owner upgrades self-serve on the portal's Billing page. - All state replicates to everyone in a room (no hidden info): fine for the PoC. - In claude.ai / ChatGPT artifact previews, WebSocket is blocked: SAVE the HTML and open it as a real page (or host it). Claude Code writes to disk + opens a real browser, so it just works. --- # Guides ## How to sync player positions between browsers Write your own position to the room on a fixed tick, ten to twenty times a second, and never once per frame. Then draw other players by moving them toward their last received position rather than snapping to it. Those two rules are most of what makes network movement look right. Source: https://multiplayeragent.com/guides/sync-player-positions-between-browsers, last reviewed 2026-08-19 ### Send on a tick, not on a frame A game loop runs about sixty times a second. Sending a position that often spends bandwidth on differences no player can see, and on a busy room it is the fastest way to hit a rate limit. Ten to twenty updates a second is enough. Keep updating your local player every frame as you already do, and publish on a timer. ```js let last = 0; function loop(now) { updateLocalPlayer(); // every frame, as before if (now - last > 60) { // about 16 times a second mp.me.set({ x: me.x, y: me.y }); last = now; } draw(); requestAnimationFrame(loop); } ``` ### Draw toward the target, never at it Updates arrive in steps, so drawing each one as it lands makes every other player jump. Keep the received value as a target and move a fraction of the remaining distance each frame. This one change is the difference between a game that feels broken on ordinary wifi and one that feels fine. ```js const view = {}; // what we draw, per player id mp.onState((state) => { for (const [id, p] of Object.entries(state.entities.players)) { view[id] ??= { x: Number(p.x), y: Number(p.y) }; view[id].tx = Number(p.x); // target view[id].ty = Number(p.y); } }); function draw() { for (const v of Object.values(view)) { v.x += (v.tx - v.x) * 0.2; // 0.15 to 0.25 feels right v.y += (v.ty - v.y) * 0.2; drawPlayer(v); } } ``` ### Only your own player, by design You can write your own entity and nothing else. There is no message that addresses another player's position, so the most common cheat in a browser game is impossible rather than merely blocked. The trade is that movement is client-authoritative: a player can lie about where they are. That is fine for co-op and sandbox games. When a position decides a score or a hit, move that decision into server logic and send inputs rather than coordinates. ### Two things that will bite you - Leave the room on unload. A refresh that did not leave cleanly holds the seat for about thirty seconds, so you appear twice to everyone. Wire window.addEventListener("pagehide", () => mp.leave()). - Do not send a field per frame per property. Batch into one call with an object, and prefer fewer, larger fields to many small ones. ### Common questions **How many players can move in one room at once?** Up to 256, bounded by your plan's per-game concurrent limit. Because all state replicates to everyone, traffic grows with players; test at your target size rather than assuming. **Do I need client-side prediction?** Not for casual and co-op games, where interpolation alone is enough. It matters when the server decides movement and you want your own input to feel instant. --- ## How to add a leaderboard to a JavaScript game Give the player a durable identity, submit a score to a named board, and read back the top entries plus that player's own rank. The part worth getting right is trust: a score submitted by the browser can be edited by the browser, so anything that matters should be submitted by server logic. Source: https://multiplayeragent.com/guides/add-a-leaderboard-to-a-js-game, last reviewed 2026-08-19 ### A leaderboard needs a player, not an account Scores attach to a durable player id, which every visitor gets automatically on first join. No signup, no login screen, nothing for the player to do. They can claim a username later to carry that record to another device, and the score history follows them. Asking for the signup first is how you lose them before they have played. ### Submit, then read Pick a mode when you submit. Use max for a high score, inc for a running total like lifetime kills, and set when the newest value replaces the old one. Every read gives you the top entries, the player's own rank and the size of the whole board, so a line like "rank 7 of 412" comes from one call rather than three. ```js const board = player.leaderboard("high-scores"); await board.submit(score, { mode: "max" }); // keeps the best const { entries, me, total } = await board.top(10); render(entries); // the top ten show(`rank ${me.rank} of ${total}`); // this player's standing ``` ### Make it mean something Everything above is submitted by the browser, which means a determined player can submit anything. For a personal-best counter that is fine. For a public ranking it is not, and the first person to open the console will prove it. When the ranking matters, move scoring into server logic. The client sends inputs, the server decides the score and submits it, and the browser never gets to name a number. ### Design notes that save a rewrite - Use one board per thing being ranked, named plainly, rather than one board with mixed units. - Scores are scoped to your studio by default, so a player is one person across your games. Switch a game to its own player pool before launch if you want it isolated, since that choice locks once real players exist. - Show the player their own rank even when they are nowhere near the top. It is the number they came for. - Decide early whether the board is all-time or seasonal. Starting a fresh board later is easy; splitting one that already mixed both is not. ### Common questions **Do players need to sign up to appear on a leaderboard?** No. Anonymous players get a durable id automatically and can hold a rank. Claiming a username only matters when they want that record on another device. **Can I stop people faking scores?** Yes, by submitting from server-side logic instead of the client. The server computes the score from the inputs it received, so the browser never supplies the number. **Can one game have several leaderboards?** Yes. Boards are named, so a game can rank score, speed and survival time separately, and read each independently. --- ## How to add multiplayer to a browser game Add one script tag, call join with your key and a room name, set fields on your own player, and read everyone else's from the state callback. There is no server to write and no build step: an existing single-player HTML game usually needs under twenty lines. Source: https://multiplayeragent.com/guides/add-multiplayer-to-a-browser-game, last reviewed 2026-08-19 ### The whole thing This is the complete integration. Everything after it is refinement. ```html ``` Note: Copy your real key and the two URLs from the docs; they are per-account. ### How the state model works There are two places to put data. Your own player's fields, which only you can write, and shared room state that anyone can write. That split is what makes the default safe: a client literally cannot address another player's entity, so the common cheat of writing someone else's position is impossible by construction rather than by validation. ### Wiring it into an existing game loop Most single-player games already have a loop that updates a local player and draws the world. The change is small: keep updating your local player as you do now, push its position with a set call, and draw everyone from the state callback instead of only your own object. Do not send every frame. Setting a field on input or on a fixed tick: ten to twenty times a second, looks identical to a player and uses a fraction of the traffic. ### The two things everyone gets wrong - Not leaving on unload. A refresh that did not leave cleanly holds the seat for about thirty seconds, so the player appears twice to everyone else. The pagehide listener in the snippet above is the fix. - Forgetting the control-plane URL. Omit it and the room still works, which is what makes it confusing, but analytics, identity and leaderboards silently do nothing. ### Then make it fair Everything above is a relay: clients say where they are and are believed. That is correct for a co-op or sandbox game and wrong the moment a score matters. When it does, move the rules into server logic that runs authoritatively, so the client sends inputs rather than outcomes. ### Common questions **Do I need a server?** No. Rooms are created on first join and the server already exists. You only deploy code if you want rules enforced server-side, and that is a small script rather than a service to run. **How many players fit in one room?** Thirty-two by default, raisable to 256 in the room settings, and always bounded by your plan's per-game concurrent-player limit. **Does it work in a single HTML file?** Yes. The script tag exposes a global, so a file you open in a browser works with no build step and no bundler. --- ## How to make an .io game An .io game is a browser game where strangers drop into a shared arena with no lobby and no install. You need three things: many players in one room, movement that stays smooth over a network, and scoring the client cannot fake. The arena and scoring are the hard parts. Source: https://multiplayeragent.com/guides/how-to-make-an-io-game, last reviewed 2026-08-19 ### What the genre actually requires - Instant join, no account, no lobby. A link opens and you are playing. - One shared arena holding dozens of players, not a private match. - Smooth movement despite latency, which means interpolating what you draw rather than snapping to the last message. - Scoring that survives a player opening the browser console. - A reason to come back; a leaderboard is usually enough. ### Start with the arena Put every player in one room named after the arena, and let it be created on first join. Each player writes only their own position; everyone reads the rest from the state callback. That gets you a crowd of moving dots, which is the skeleton of every game in the genre. Room size defaults to thirty-two and can go to 256. For an .io game, more players in one arena is the feature, but a full arena is also more traffic per player, so it is worth testing what your game actually feels like at forty before assuming it wants two hundred. ### Make movement look right Do not draw the last position you received. Store it as a target and move toward it a fraction each frame, so other players glide instead of teleporting. Send your own position on a fixed tick rather than every frame. This single change is the difference between a game that feels broken on ordinary wifi and one that feels fine. ### Then make scoring fair The moment there is a scoreboard, someone will try to write to it. Collisions, scoring and eliminations belong in server-side logic: clients send inputs, the server decides outcomes and updates state. Only then does a leaderboard mean anything. Add the leaderboard once the score is trustworthy, not before, or you are publishing a ranking of who read the documentation for the console. ### Shipping it An .io game lives or dies on whether a link works instantly for a stranger. Test the cold path: open your share link in a private window on a phone, on mobile data, and see how long it takes to be moving. That number is your real conversion rate. ### Common questions **How many players can be in one arena?** Up to 256 in a single room, bounded by your plan's per-game concurrent limit. Because all state replicates to everyone in the room, traffic grows with players, worth measuring at your target size. **Do I need to write a game server?** Not for movement. For fair scoring you deploy a small piece of server logic, which runs authoritatively but is not a service you host or scale. --- ## Make a multiplayer game with Claude Code Connect the Multiplayer Agent tool server to Claude Code with your secret key, then describe the game you want. The agent creates the game, defines the room, writes the client and can deploy server-side rules, so you go from a sentence to a shareable link without reading an API reference. Source: https://multiplayeragent.com/guides/multiplayer-game-with-claude-code, last reviewed 2026-08-19 ### Why this path is different Most backends expect you to read documentation, copy a snippet and wire it up. That is fine, and it is also the slowest part of building a small game. Because the platform exposes a tool interface rather than only a docs site, an agent can do the wiring: create the game, define the room's shape, generate a client that matches it, and hand you a file to open. You review the result instead of assembling it. ### Connecting it Sign up, copy your studio's secret key from the dashboard, and add the tool server to your agent's configuration. The dashboard shows a paste-ready configuration block with your key already in it. Note: The secret key is not the same as the publishable key that ships in your game. Keep the secret one out of your game's source and out of chat. ### What to ask for Describe the game, not the API. The useful prompts sound like a brief: - "Make a two-player tic-tac-toe I can share with a friend: take turns, show who won." - "Build a top-down arena where everyone is a coloured square, arrow keys to move, and a leaderboard for eliminations." - "Add saved progress so a returning player keeps their unlocks." ### Review the parts that matter Agents are good at this and still worth checking on two points. First, whether anything that decides a winner runs on the server rather than in the browser, ask directly if the score can be edited by the client. Second, whether the client leaves the room on unload, because that omission shows up as duplicate players and is easy to miss when testing alone. ### Then share it The output is an ordinary web page. Open it, open it again in a second window, and you are two players. Send the file or host it anywhere; there is no deployment step on our side, because the room already exists. ### Common questions **Which agents does this work with?** Any agent that speaks the Model Context Protocol over HTTP. Claude Code works with a static key today; other MCP-capable clients connect the same way. **Do I need to know how to code?** To get something playable, no: the agent writes the client. To take it somewhere serious, some JavaScript helps, mostly for the drawing and feel of the game rather than the networking. **Is the tool server an LLM?** No. It is a deterministic API: the same operations the dashboard performs, exposed so an agent can call them. Your agent supplies the intelligence; this supplies the platform. --- # Comparisons ## Looking for a Photon alternative? Multiplayer Agent is a Photon alternative built for browser games. The main difference is what you are billed for: Photon meters concurrent connections and bandwidth, we charge by monthly active players. For a web game with spiky traffic, that usually costs less and is far easier to predict. Source: https://multiplayeragent.com/compare/photon-alternative, last reviewed 2026-08-19 ### The real difference is the meter, not the feature list Photon prices by concurrent users: the number of people connected at the same moment. That is the honest unit for infrastructure, because concurrency is what actually consumes a server. It is also close to impossible for a small studio to forecast. You find out your peak after it happens, and a game featured on a portal for one afternoon can spike far past a tier you picked months earlier. We price by monthly active players instead: how many distinct people played at all during the month. It is the number you can estimate from your own traffic, it does not punish a good day, and a game that goes quiet costs nothing. ### You do not write or deploy a server With Photon you generally choose between their hosted rooms and running your own server plugin, and anything authoritative means writing and deploying server code. Here the server already exists. Rooms are created on first join, state is whatever fields you set, and if you need rules that a cheating client cannot bypass you deploy a small piece of game logic that runs server-side, versioned, with rollback and logs, and no container to manage. ### Where Photon is the better answer This is not the right tool for everything, and it is worth being direct about that. - Competitive twitch games: fast shooters, racing. Those want a UDP transport with prediction and rollback. We use a reliable connection, which is right for casual and turn-based play and wrong for frame-perfect combat. - Native engine-first projects. Photon's Unity and Unreal SDKs are mature and widely used; our client is built for the web first. - Games needing hidden information at scale, where the state each player sees must diverge substantially. Note: If your game is a browser game and the players do not need frame-perfect accuracy, the trade is usually in our favour. If it is a competitive native shooter, it is not. ### Common questions **Is Multiplayer Agent cheaper than Photon?** For most browser games, yes, but the reason is the pricing model rather than a discount. Photon bills for concurrent connections and bandwidth; we bill for monthly active players. A game with a large audience that plays in short bursts pays far less on a monthly-players meter than on a concurrency meter. **Can I migrate an existing Photon game?** The networking layer has to be rewritten: the state models are different. In practice this is a smaller job than it sounds for a browser game, because you no longer write a server: you set fields on your own player and read shared state. Most of the migration is deleting code. **Do you support Unity?** Not today. The client SDK is JavaScript and targets the browser. If your game runs in a browser we are a good fit; if it is a Unity or Unreal build, Photon remains the better answer for now. --- ## Looking for a Playroom alternative? Multiplayer Agent is a Playroom alternative that prices the same way, by monthly active players, with no concurrency limits, and adds the parts you reach for after the prototype works: server-authoritative game rules, player accounts and saves, leaderboards and monetization. Source: https://multiplayeragent.com/compare/playroom-alternative, last reviewed 2026-08-19 ### We agree about pricing, and that is the point Playroom got the meter right. Charging by monthly active players rather than concurrent connections is the only unit a solo developer can predict for their own game, and we price the same way for the same reason. So the comparison is not really about cost model. It is about what happens after the first prototype works. ### What you get once the prototype works A lot of multiplayer tooling is excellent at the first hour and thin after it. The things a game needs in week two are the things we have built out: - Server-authoritative logic: rules that run on the server, so scores and outcomes cannot be edited by a client. Versioned, with rollback and error logs. - Player identity and saves: anonymous-first, with an optional username so progress survives a lost device. - Leaderboards, scoped per game or across your whole studio. - Player-created content: publishing, browsing, voting and moderation for player-made levels or decks. - Monetization through Stripe, plus your own ads id so ad revenue stays entirely yours. - First-party analytics: sessions, retention, concurrency and client errors, per game. ### Built to be driven by an agent The whole platform is reachable from an AI coding agent, not just from a documentation site. That means you can describe the game you want in your editor and have the room defined, the logic deployed and the snippet written without switching windows. It matters more than it sounds: the fastest path from idea to two people playing is the one where nobody reads an API reference first. ### Honest limits All room state replicates to everyone in the room, which makes hidden-information games awkward unless you keep the secret in server logic. And this is not built for competitive twitch play, no client prediction or rollback. Casual, co-op, turn-based and .io-style games are the sweet spot. ### Common questions **Do you limit concurrent players?** There is a per-game ceiling on each plan as a safety rail, but it is sized so that a game hitting it is already a hit. You are not billed by concurrency and a traffic spike does not cost extra. **Can I use my own ads?** Yes. Add your ads publisher id per game and keep 100% of the ad revenue. We take a cut only on in-game purchases sold through the platform. --- ## How to choose a multiplayer backend for a browser game For a browser game, pick a backend on three things: what it meters (concurrency or monthly players), whether you have to write and deploy a server, and whether it can enforce game rules the client cannot fake. Feature checklists matter far less than those three. Source: https://multiplayeragent.com/compare/best-multiplayer-backend-for-browser-games, last reviewed 2026-08-19 ### 1. What does it charge for? This is the decision that follows you. Backends bill either for concurrency: people connected at the same moment, or for monthly active players. Concurrency is the honest unit for infrastructure and the impossible one for planning: you cannot know your peak until it has happened, and a single feature placement can blow through the tier you chose. Monthly players is predictable from your own traffic and does not punish a good day. For a web game with spiky, casual traffic, monthly players is almost always the better fit. ### 2. Do you have to run a server? Some backends give you a room service; others give you a place to deploy your own server build. The second is more flexible and much more work: you own the deploys, the crashes and the scaling. For a small team the question is whether the default path requires any server code at all. If adding multiplayer means standing up a service, budget for that, because it never stays a one-afternoon job. ### 3. Can it stop a cheating client? Anything where a score, a reward or a ranking matters eventually needs rules the player's browser cannot rewrite. If everything is client-authoritative, the first person who opens the console wins. Look for the ability to run game logic on the server, and check what it costs to get there. A backend where authoritative logic means a separate deployment pipeline is a different proposition from one where it is a small script you push. ### What to check before you commit - Does the free tier let you ship, or only prototype? A tier that expires is a trial, not a free tier. - Is bandwidth metered, and what happens when you exceed it: a bill, an alert, or dropped players? - Are player accounts and saves included, or a second vendor? - Can you leave? If the state model is proprietary, the migration cost is the real lock-in. - Is there a path to server-authoritative rules that does not involve running infrastructure? ### When not to pick us Competitive twitch games want a UDP transport with prediction and rollback, a different class of tool. Native Unity or Unreal projects are better served by an engine-first vendor today. And games built on hidden information need care, since room state replicates to everyone unless the secret lives in server logic. Casual, co-op, turn-based, party and .io-style browser games are where this is genuinely the fastest route from idea to two people playing. --- ## Looking for a Nakama alternative? Nakama is a full game server you run and extend; Multiplayer Agent is a hosted backend you call from the browser. If you want to own the infrastructure and write server modules, Nakama is more powerful. If you want a browser game playable this week, there is far less to do here. Source: https://multiplayeragent.com/compare/nakama-alternative, last reviewed 2026-08-19 ### The difference is how much you operate Nakama is an open-source game server. You run it, or you pay for a managed instance, and you extend it with modules in Go, Lua or TypeScript. That is a real advantage when you need behaviour the vendor never anticipated, and it is genuinely yours: you can read the source, fork it, and move it. It is also a service to operate. Somebody deploys it, watches it, upgrades it and is responsible when it stops. For a solo developer or a small studio shipping a browser game, that is often the largest single cost of the project, and it is paid in attention rather than money. ### What server-side rules look like on each On Nakama, authoritative logic is a module you write, compile or register, and deploy with the server. That is powerful and it is also a build pipeline. Here it is a small script you push with one call. It runs on a tick, clients send inputs, the server decides outcomes, and every deploy is versioned with one-call rollback and readable error logs. Less room to manoeuvre, much less to stand up. ### Where Nakama is the better answer It is the stronger choice more often than a comparison page usually admits. - You need to self-host, for data residency, procurement or cost reasons at scale. - You are building on Unity, Unreal or a native client. Their SDK coverage is broad; ours targets the browser. - You need matchmaking, parties, chat channels and social graphs as first-class server features rather than things you assemble. - You want to read and modify the server itself, or avoid depending on a vendor at all. Note: If the phrase 'we will run it ourselves' sounds like a feature rather than a chore, Nakama is probably the right call. ### Where this is the better answer - The game runs in a browser and you want a shareable link today. - Nobody on the project wants to own a server, and there is no ops budget. - You would rather describe the game to an agent than write the networking by hand. - You want players, saves, leaderboards, player-made content and payments included rather than assembled. ### Common questions **Is Nakama free?** The server is open source, so self-hosting has no licence cost, but it does have an operating cost in hosting and attention. Managed hosting is a paid product. We are hosted only, and priced on monthly active players rather than on instances. **Can I move off Multiplayer Agent later?** The networking layer would be rewritten, as it would moving between any two of these. What travels with you is the part that usually matters: player records, saves and leaderboard entries are exportable, and your game code is your own. **Do you support matchmaking?** Not as a dedicated service. Rooms are created on first join and a room name is the match, which covers link-sharing and lobby-by-name well and does not replace skill-based matchmaking.