Full SDK reference

Everything on one page, for searching and for reading end to end. The tabbed version lives at /docs, and the same text as plain markdown is at /llms-full.txt. Snippets use a placeholder key: sign up to get your own.

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 <script> tag below into a page and call the SDK yourself, or point an AI agent at the MCP server and let it create the game, define rooms, deploy server logic and write the client code for you. Both land on the same backend; the agent path just skips the reading.

What it's for. Turn-based, casual, co-op, party, puzzle, and arena/.io games and casual shooters, if you apply the patterns in "Building real-time games well" below. Because a game is just a room name, extra modes and levels are free.

What it isn't for. Competitive twitch action (FPS, racing): the transport is WebSocket: reliable and ordered, but not UDP, with no rollback or lag compensation. Note also that room state replicates to every client in the room, so hidden-information games (hidden hands, fog of war) must keep the secret inside authoritative server logic and reveal it with emitTo, rather than putting it in room state and hoping clients don't look.

Load the SDK

<script src="https://cdn.multiplayeragent.com/sdk/mp.global.js"></script>
<!-- exposes a global `MP` -->

(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.

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:

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:

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:

CapLimit
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 value2048
Characters in one event / input payload (send / sendInput)2048
Messages per second, per client60 (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.

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).

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.

// 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.

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).

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.

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:

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:

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.

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.

// 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:

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):

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:

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.

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.
// 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:

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.
// 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 <head>):

<script async
  src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-XXXXXXXXXXXXXXXX"
  crossorigin="anonymous"></script>

2) Enable the Ad Placement API once, on load:

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):

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):

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.