Turn your game idea into something playable
Choose your game with your agent. Replace <YOUR_WRITE_TOKEN> in its MCP credentials with a token from Connected agents, or connect with OAuth.
Contents
- 1. What a Game is
- 2. The runtime
- 3. Player identity
- 4. Saves
- 5. Leaderboards
- 6. Store and entitlements
- 7. Tips
- 8. Multiplayer rooms
- Realtime multiplayer
- Common realtime mistakes
- 9. Errors and limits
- 10. Publishing: the MCP tools
- 11. Validation rules
- 12. Minimal compliant Game
- 13. Card images and snapshots
- 14. Host implementation notes (not part of the Creator contract)
MiraDock Game SDK Specification
Version: 1 (MiraDock.version === 1) Status: current contract for Games published on MiraDock Audience: AI coding agents and humans building Games for MiraDock, and the MiraDock engineers implementing the host side
This document is the product contract. Every Game published to MiraDock is built to it, and every host feature (play page, bridge, Store, saves, leaderboards, rooms) is implemented to it. Changes are additive; nothing here is removed or changed in meaning without a major version bump.
1. What a Game is
A MiraDock Game is one self-contained HTML file.
- Maximum 2 MiB (2,097,152 UTF-8 bytes). The limit applies to the file after sanitization, not to what you submit. Sanitization adds the host CSP and viewport tags and a small host script, so it can add a few KB; leave headroom.
- Inline
<script>and<style>only. External scripts are stripped at publish time. - Images and fonts may load from any
https:URL or adata:URI. Nothing else leaves the sandbox. - No network calls of any kind from Game code.
fetch,XMLHttpRequest, andWebSocketare blocked by CSP (connect-src 'none'). Everything a Game needs from the platform comes throughwindow.MiraDock. - No
localStorage,sessionStorage,indexedDB, or cookies. The iframe runs withsandbox="allow-scripts"and withoutallow-same-origin; these APIs throw. UseMiraDock.save. - The file must declare the SDK version it targets:
<meta name="miradock-sdk" content="1">in<head>. - The Game must call
MiraDock.ready()within 10 seconds of load. If it does not, the play page shows an error state and the load is not counted as a successful play session.
Publishing is free, with no subscription or cap on Game count, private drafts, unlisted Games, versions, cover images, leaderboards, or Store SKUs. The following abuse controls apply to claimed accounts:
- Game HTML stays limited to 2 MiB after sanitization.
publish_gameandupdate_gameshare a per-account budget of 30 HTML writes per hour and 200 per day, enforced atomically in the database. Fixed UTC hour/day windows reset at the next boundary. Arate_limitederror names the limit, window, UTCreset_at, andretry_after_seconds. Validation, failed writes, and metadata-only edits do not consume this budget.- Account Game storage is 500 MiB of sanitized UTF-8 HTML across current and all archived versions of all Games. The Creator dashboard displays the meter and a notice at 80% (400 MiB). A write that would exceed 500 MiB is refused with
storage_limit_exceeded; at 100%, new versions cannot be saved. The message points to support@miradock.com and pruning old versions from the dashboard. Existing Games remain playable and metadata remains editable. - Each Game detail page has Delete old versions. After confirmation, it permanently removes versions older than the latest 10 (the current version plus nine previous versions). It never deletes the current version. There is no automatic version-count cap.
- Unclaimed anonymous sandbox tokens can create and update drafts only: at most three Game drafts, within the sandbox size quota (10 MiB by default). Claiming the account is required for publishing, unlisting, leaderboards, SKUs, monetization, and stats.
Game metadata (title, slug, description, genre tags, cover image, orientation, player count) is not in the HTML. It is supplied through the MCP tools (section 10) so a Creator can change it without republishing the Game file.
2. The runtime
The platform injects window.MiraDock into every Game at render time, before any Creator script runs. Games never install, import, or version the SDK themselves. All methods return Promises. All calls are relayed over postMessage to the host page, which performs the real work and holds the real state. Game code never sees keys, tokens, prices it did not ask for, or raw server responses.
const ctx = await MiraDock.ready();
// ctx.player { id, displayName, avatarUrl, isGuest } (section 3)
// ctx.game { id, slug, title }
// ctx.entitlements [{ sku, kind, quantity, grantedAt }] (section 6)
// ctx.capabilities { saves, leaderboards, shop, donate, tip, multiplayer, report }MiraDock.version is the integer SDK version (1).
MiraDock.capabilities (also returned as ctx.capabilities) reports which subsystems are available for this Game on this load: { saves, leaderboards, shop, donate, tip, multiplayer, report }. multiplayer is { realtime: true } when available, or false; the other fields are booleans. A Game must check before relying on an optional subsystem.
saves,leaderboards, andreportaretrue;multiplayeris{ realtime: true }.shopistrueonly when platform payments are enabled, the Creator has an active payout account, and the Creator enabled the Store for this Game.donateistrueonly when platform payments are enabled, the Creator has an active payout account, and the Creator enabled tips for this Game. (The field keeps its original name; it gatestip.open.)tipis an alias ofdonateand always has the same value. New Games should checkcapabilities.tip;capabilities.donatestays supported.
ctx.entitlements is empty unless shop or donate is true and the Player is signed in.
Card image: MiraDock.snapshot(dataUrl) offers an image of the Game for its card. Call it once after the first meaningful frame. See section 13.
Events: MiraDock.on(event, handler) and MiraDock.off(event, handler). Both return Promises. Registering an event this host does not offer rejects with unsupported.
| Event | Fires when |
|---|---|
entitlements | The Player's entitlements changed: a purchase or tip completed, or shop.consume succeeded. Payload: the full entitlement list. Available only when shop or donate is true. |
joined | The host auto-joined an invite room after ready(). Payload: { roomId, joinCode, players }; see section 8. |
pause | A host-rendered checkout sheet opened over the Game. |
resume | The checkout sheet closed, or the play area entered or left fullscreen. A Game may receive resume without a preceding pause. |
player | Reserved. Registration succeeds, but the current host never emits it: signing in reloads the play page with a new ctx.player. |
The host does not send pause when the browser tab is hidden. Use the page's own visibilitychange event for that.
3. Player identity
ctx.player // { id, displayName, avatarUrl, isGuest }idis a stable, per-Game pseudonymous id for signed-in Players. The same MiraDock account gets the sameidin the same Game forever, and a differentidin every other Game. Creators cannot correlate a Player across Games.isGuestistruefor visitors who are not signed in. Guests can play. A guestidstarts withguest-and is new on every page load. Guest saves live only for the page session.- Purchases, tips, leaderboard submission, and creating a multiplayer room require a signed-in Player. Calling
shop.purchase,tip.open(ordonate.open),leaderboard.submit, ormultiplayer.createas a guest resolves with{ status: "signin_required" }and the host offers a sign-in prompt.shop.consumeas a guest rejects withsignin_required. displayNameis the Player's onboarding name, trimmed, stripped of control/format characters, and capped at 80 Unicode code points; an empty name becomesPlayer. Guests areGuest Player. It does not require a published Creator profile.avatarUrlis currently alwaysnull. Never store or display anything else about a Player.
4. Saves
Per Player, per Game, JSON-serializable values.
await MiraDock.save.set("progress", { level: 4, coins: 120 });
const v = await MiraDock.save.get("progress"); // null if unset
await MiraDock.save.delete("progress");
const keys = await MiraDock.save.list(); // array of key stringsKeys are strings of 1–64 Unicode code points without NUL characters. Values are normalized with JSON.stringify; values that cannot be serialized (cyclic objects, undefined) reject with invalid_argument.
Limits: 32 keys per Player per Game, 64 KiB per value (UTF-8 JSON), 512 KiB total. Writes are last-write-wins. Exceeding a limit rejects with { code: "limit_exceeded" }. Save calls are limited to 60 per minute per Player per Game.
Guests: values are held in host memory for the page session and discarded on leave or reload. The same key and size limits and the 60 calls per minute limit apply. A Game may show "sign in to keep your progress"; the host also offers this itself.
5. Leaderboards
Boards are declared by the Creator through MCP (set_game_leaderboards, section 10), not created from Game code. Each board has an id (1–64 lowercase letters, digits, _ or -), a sort (desc for high score, asc for fastest time), and a period (alltime, weekly, daily; weekly and daily periods reset at UTC week and day boundaries).
await MiraDock.leaderboard.submit("highscore", 4200); // resolves null
const top = await MiraDock.leaderboard.top("highscore", { limit: 10 });
// [{ rank, playerId, displayName, score, at }]
const me = await MiraDock.leaderboard.me("highscore");
// { rank, score } or nullscoremust be a safe integer (Number.isSafeInteger). Other values reject withinvalid_argument.topaccepts an optional{ limit }; the default is 10 and values above 100 are clamped to 100.playerIdis the per-Game id from section 3.meresolvesnullfor guests and for Players with no score in the current period.- A board id that the Creator has not declared rejects with
not_found. - Guests calling
submitreceive{ status: "signin_required" }.
The host keeps a Player's best score per board per period. Submissions are rate limited to 10 per minute per Player per Game. Leaderboards in v1 are unverified: the host cannot prove a score was earned. Games with paid rewards must not gate rewards on leaderboard position.
6. Store and entitlements
Everything a Creator sells is a SKU. SKUs are registered through MCP (set_game_skus, section 10) with a sku id, name, description, kind, and priceCents in USD. Prices live in the platform, never in the Game file. The Game only needs to know the SKU ids it cares about.
| kind | What it means | Entitlement behavior |
|---|---|---|
item | A thing the Player owns (a skin, a weapon) | Granted once, permanent. Buying again rejects with already_owned. |
access | Unlocks part of the Game (a level pack, the full Game) | Granted once, permanent. Buying again rejects with already_owned. |
consumable | A thing that gets used up (lives, boosts) | Quantity increments by 1 per purchase, decrements on consume |
donation | A tip with a Player-chosen amount | Grants the supporter entitlement (section 7). Managed by the platform; Creators cannot register tip SKUs. |
const items = await MiraDock.shop.list();
// [{ sku, name, description, kind, priceCents, owned, quantity }]
const ents = await MiraDock.shop.entitlements();
// [{ sku, kind, quantity, grantedAt }]
const result = await MiraDock.shop.purchase("level-pack-2");
// { status: "purchased", entitlement } | { status: "cancelled" } | { status: "signin_required" }
const used = await MiraDock.shop.consume("extra-life", 1);
// { ok: true, quantity: 2 } (remaining quantity)shop.list()returns activeitem,access, andconsumableSKUs in the Creator's sort order, with the Player'sownedflag andquantity. Whencapabilities.shopisfalseit resolves[].shop.entitlements()resolves[]for guests. It requiresshopordonate; otherwise it rejects withunsupported.shop.purchase(sku)requirescapabilities.shop. Failures reject:already_owned,not_found(unknown or inactive SKU),rate_limited(including when a checkout sheet is already open),timeout,unsupported.shop.consume(sku, quantity)takes a positive integer quantity and works only onconsumableentitlements. Using more than the Player has rejects withinsufficient; a SKU of another kind rejects withinvalid_argument. Delisted consumables can still be consumed.
Rules the Game must follow:
- The host is the source of truth. Gate content on
ctx.entitlementsand theentitlementsevent, never on local variables set after a purchase call. A purchase is not complete until the entitlement appears in that list. purchase()opens a host-rendered checkout sheet on top of the Game. The Game receivespause, thenresumewhen the sheet closes. The Game never renders prices or payment UI itself;shop.list()is for showing what exists, with the price the host reports.- Like every SDK call,
purchase()times out after 10 seconds. If the Player has not confirmed by then, the sheet closes and the call rejects withtimeout. A payment the Player already confirmed still completes, and theentitlementsevent delivers the result. - Purchases and tips are paid from the Player's prepaid MiraDock balance. If the balance is too low, the sheet says so and the Player can cancel. A Creator cannot buy from their own Game.
- Payment, balance, refunds, and payouts are entirely host concerns. The Game has no API for them and never will.
- A Game may be fully free, free with tips, paid SKUs only, or paid SKUs plus tips. The Creator chooses with
set_game_monetizationor the Creator dashboard, not in Game code.
7. Tips
A Creator may enable tips on any Game, including one with paid SKUs. A tip is for the Game the Player is playing: it can only be given from that Game's play page, for content the Player already has.
- The play page shows a "Tip the creator" button outside the iframe, with the line "Tips are for the game you're playing", so it cannot be spoofed by Game code.
- The Game may also open the same tip sheet:
await MiraDock.tip.open({ suggestedCents: 300 }).suggestedCentsis optional; when present it must be an integer from 100 to 50000, otherwise the call rejects withinvalid_argument. Resolves{ status: "donated" | "cancelled" | "signin_required" }("donated"is the historical wire value for a completed tip). It requirescapabilities.tip(the alias ofcapabilities.donate) and shares the 10-second timeout described in section 6. MiraDock.donate.openis the original name ofMiraDock.tip.open. It opens the same tip sheet, takes the same argument and resolves the same way. It stays supported for existing Games; new Games should calltip.open.- The tip sheet reads "Tip <Creator> for <Game title>". The Player chooses the amount there: $1.00 to $500.00, with presets of $1, $3, $5, and $10. The sheet shows the split from the live platform rate, for example "The creator receives 80%; MiraDock keeps 20%".
- A completed tip grants a
supporterentitlement (kinddonation, quantity = number of tips). Games may read it to say thanks or unlock a cosmetic. A Game must not paywall core content behindsupporter; that is whataccessSKUs are for. - The platform keeps a percentage of every tip and every SKU purchase. Rates are platform configuration, published on the Balance terms page, shown in the tip sheet, and shown to Creators in the dashboard; they are not exposed to Game code.
8. Multiplayer rooms
Reference: Two-player Tap Duel (source) is a complete turn-based room Game.
Rooms are a host-relayed, event-based system, not an authoritative game server. Any member can send any JSON payload. Game state and rule validation live in clients. Never grant paid rewards based on multiplayer outcomes. Games still cannot access the network; the host owns Realtime credentials, presence, persistence, and sender identity.
const ctx = await MiraDock.ready();
const room = await MiraDock.multiplayer.create({ maxPlayers: 2 });
// Or: const room = await MiraDock.multiplayer.join(joinCode);
// room.roomId and room.joinCode identify this room; show the code to a friend.
if (room.status !== "signin_required") {
let myTurn = true; // Creator starts; joining client initializes this to false.
await room.on("message", ({ from, seq, payload }) => {
if (from !== ctx.player.id && payload?.type === "pass") myTurn = true;
});
await room.on("players", players => console.log(players));
await room.on("closed", ({ reason }) => console.log(reason));
// In your Pass button handler:
async function passTurn() {
if (!myTurn) return;
await room.send({ type: "pass" });
myTurn = false;
}
// On exit: await room.leave();
}create({ maxPlayers })requires an options object with an integermaxPlayersfrom 2 to 16, and resolves a room object with{ roomId, joinCode, send, on, leave }. Signed-in Players may create. Guests receive{ status: "signin_required" }and the host offers sign-in. Guests may join using a code; they appear asGuest Playerwith a session-only id.join(joinCode)returns the same room interface. Codes are six uppercase letters/digits excluding O, I, 0, and 1; input is trimmed and case-insensitive. Joining fails withfull,closed,wrong_game, ornot_found, and a malformed code rejects withinvalid_argument. Only one room is active per play session; entering another leaves the previous one. The same Player cannot occupy two slots in the same room from different sessions (already_joined).send(payload)returns a Promise. Each payload is limited to 4 KiB of UTF-8 JSON (as stored by the database); larger payloads reject withlimit_exceeded, and cyclic/non-JSON values reject withinvalid_argument. The host permits 20 sends per rolling second per play session and rejects excess calls withrate_limited; it never silently drops a rate-limited send.on("message", handler)delivers{ from, seq, payload }to all members, including the sender.fromis the per-Game Player id andseqis a room-local, gapless, increasing database sequence. Account IDs and credentials are never delivered to Games. The host orders and deduplicates live/catch-up messages. Only the last 200 messages are retained: late joiners replay that suffix, and clients must request a current state from peers if earlier history is needed. Persistence precedes broadcast; the host also checks for missed messages every 5 seconds, which repairs a lost final broadcast.on("players", handler)delivers[{ id, displayName, isHost, online }]. Membership is persisted;onlineis advisory Realtime presence, not proof of identity or game state. Register handlers immediately after joining; initial membership and catch-up events are buffered until registration.- Room events are
message,players,joined, andclosed; any other name rejects withinvalid_argument. - Event ordering:
messageevents are delivered inseqorder per room, without gaps or duplicates. For the same membership change,playersis delivered before anymessagethat follows the change, so a new member appears inplayersbefore its first message arrives. During host migration, the host change (theplayersupdate with the newisHost, andrt.on("host")for realtime rooms) is delivered before any further messages. - The play page provides a room lobby outside the iframe: code and invite-link copy controls, live Players with a host marker, capacity, and Leave. Fullscreen offers a lobby overlay toggle. Opening
/play/SLUG?room=CODEauto-joins once afterready(). Invalid/full/closed invites show a plain host message; the Game continues in its single-player state. MiraDock.on("joined", handler)receives{ roomId, joinCode, players }after an invite auto-join. Register afterready(); the current invite join is buffered for late registration and cleared on leave. Callawait MiraDock.multiplayer.join(joinCode)in the handler to obtain the already-joined room object without another server join. Registerroom.on("players", ...)androom.on("message", ...)immediately; initial presence and catch-up messages are buffered.room.on("joined", handler)also receives this invite event. Explicit create/join calls still return their room directly.on("closed", handler)receives{ reason }when the Player leaves (left), the room closes or expires (closed), membership is lost (not_member,not_found,signin_required), or the connection fails (error).onandleavereturn Promises. A room object rejectssendwithclosedafter closing.- On host leave, the earliest remaining member becomes host, including guests. Empty rooms close immediately. Rooms idle for 30 minutes close on the next 15-minute expiry run; presence and reading do not extend the lifetime. Sends and membership changes count as activity. Expiry deletes messages immediately; messages from other closed rooms are removed after one hour. A disconnected member remains until leave or room expiry; page teardown attempts a leave but is best effort.
- Realtime uses private channels and room-scoped tokens valid for two minutes. Only the host server can broadcast messages. Authorization refreshes with each token; a departed member's existing subscription can remain valid until that token expires. Clients must treat all peer payloads as untrusted and must not send secrets in rooms.
Realtime multiplayer
Call const rt = await room.realtime() on every client immediately after create or join (and in the joined handler), not only on the host. The host page starts connecting each member as soon as it joins the room, whether or not the Game has called realtime(); the call attaches to the existing or in-progress connection and resolves when it is ready or has fallen back. rt exposes { isHost, hostId, transport, relayAvailable, peers }. Each peer is { id, transport, rtt }, where transport is connecting, webrtc, or relay, and RTT is milliseconds or null until measured. rt.stats() returns the current values. The room lobby also shows peer transport and RTT.
const rt = await room.realtime();
rt.on("data", ({ from, payload, fast }) => applyPeerData(from, payload));
rt.on("peers", peers => renderNetwork(peers));
rt.on("host", ({ hostId, reason }) => resumeFromLatestSnapshot(hostId));
await rt.send({ type: "input", dx: 1 }, { to: rt.hostId, fast: true });- The room host's browser is authoritative for the realtime simulation. It opens one
RTCPeerConnectionto each other member; each member connects only to the host. The host creates orderedreliableand unorderedfast(maxRetransmits: 0) data channels per pair. Games never obtain the connections, ICE servers, signaling, or credentials; the iframe sandbox and CSP stay unchanged. A host can cheat. Never base paid rewards on realtime outcomes. rt.send(payload, { to?, fast? })accepts JSON or anArrayBuffer, up to 16 KiB. The host may address one member or"all"; a non-host can address only the host ("all"also means the host). Malformed options, unknown player ids, and a non-host addressing another member reject withinvalid_argument. Rate limits are 60 fast and 20 reliable messages per rolling second per peer, enforced by the host page withrate_limited.fastdefaults tofalse. There is no automatic game-state history.rt.sendresolves{ dropped }, an array of player ids, empty when nothing was skipped. A recipient whose connection is not set up yet (at join, or right after a host migration) is not an error: a fast send skips it and lists its id indropped; a reliable send waits up to 20 seconds for the connection, then delivers, or rejects withtimeout. The host that just left counts as such a recipient, because the Game may still address it until itshostevent arrives. It never reconnects, so switch targets in thehosthandler.rt.on("data", handler)receives{ from, payload, fast }.fromis stamped by the host page.rt.on("peers", handler)receives the live peer list;rt.on("host", handler)receives{ hostId, reason: "host_left" }when the existing room host succession picks a new host. On migration, every remaining member reconnects to the new host. The game must carry over its own state, typically from its last snapshot.- On migration the new host's Game receives the
hostevent first, then the remaining members reconnect to it, then data flows again. Membership does not change during migration: no remaining member leaves the room. Data a member sends before it learns of the new host goes to the host that left and is lost; a send racing a departure resolves without delivery instead of rejecting. After thehostevent, sends to the new host follow the pending rules ofrt.senduntil its connection is up. - SDP offers, answers, and ICE candidates use a distinct, private room Realtime event sent through membership-checked host requests authorized by the room token. They are ephemeral, never stored, and never delivered to games. Public STUN is the default. If server-only Cloudflare TURN credentials are configured, the host obtains one-hour credentials for room members and sets
relayAvailable: true; otherwise it reportsfalse. If a pair does not connect within 10 seconds, its transport becomesrelayand game data for that pair uses membership-checked room Realtime relay events. The game continues through fallback. On relay,rt.sendresolves once the host server accepts the message for every destination; peers on relay report an RTT measured by a round trip through the relay (a ping on the reliable path every 2 seconds, and once as soon as the pair settles). - Recommended loop: the host simulates at 30 Hz and broadcasts snapshots at 20 Hz on
fast; peers send inputs onfast; round start, end, and other discrete events go onreliable. Interpolate snapshots on peers. See Arena Dash (source) for a 2–6 Player reference with a snapshot card call.
Common realtime mistakes
- Calling
room.realtime()only on the host, or only after a round starts. Call it on every client right after joining or creating, and registerrt.on("data")before anything can be sent to you. - Sending round start, round end, scores, or any other must-arrive event on
fast. Use the reliable channel with{ to: "all" }(the default whenfastis omitted);fastmessages can be dropped. - Gating game logic on
rt.transport === "webrtc"or a peer's transport. The same message may arrive overrelaywith higher latency; treat both transports the same. - Treating a missing snapshot as "the round has not started". A peer that sees no snapshot for a few ticks is lagging (for example during fallback or host migration); keep the last state and interpolate or hold until snapshots resume.
- Assuming the old host's state carries over on migration. Rebuild from your last snapshot in the
hosthandler.
9. Errors and limits
Players and guests can report a problem using the host control next to Like and Support, or a Game can call await MiraDock.report.open({ category?, note? }). The method opens the same host sheet, prefills its category and description, and resolves { status: "submitted" | "cancelled" }. Categories are bug, crash, inappropriate, cheating, and other; note is at most 2000 characters. The Player chooses whether to include technical details. The Game cannot submit without that confirmation or read reports. A report sheet remains open until the Player submits or cancels, so this interactive call is exempt from the ordinary 10-second timeout. Signed-in Players can submit 10 reports per hour; guests can submit 3 per hour per host guest key. Suspended accounts cannot report. Signed-in reporters see statuses and Creator notes at /app/reports; Creators receive no reporter identity.
Every rejected Promise rejects with { code, message }. Codes: unsupported, signin_required, suspended, limit_exceeded, insufficient, already_owned, rate_limited, invalid_argument, not_found, full, closed, wrong_game, not_member, already_joined, timeout, error. Messages are fixed, human-readable strings; never parse them.
Every call except the interactive report.open sheet times out at 10 seconds with { code: "timeout" }, except as follows.
multiplayer.create and multiplayer.join allow 20 seconds: entering a room covers a database write, a private channel subscribe, and a presence sync, and a slow create must not read as an unresponsive host.
room.realtime() allows 15 seconds so its 10-second connection fallback can complete after ICE configuration.
rt.send allows 30 seconds: a reliable send can wait 20 seconds for a pending connection, then relay.
| Limit | Value |
|---|---|
| Save calls | 60 per minute per Player per Game (guests: 60 per minute per page session) |
| Leaderboard submissions | 10 per minute per Player per Game |
Store calls (shop.list, shop.entitlements, shop.consume, opening a purchase sheet) | 30 per minute per Player per Game, shared |
| Opening a tip sheet | 5 per minute per Player per Game |
| Submitting a Game report | 10 per hour per signed-in Player; 3 per hour per guest key |
| Room sends | 20 per rolling second per play session |
Checkout confirmations have their own budgets of 30 purchases and 5 tips per minute. Games should debounce saves.
10. Publishing: the MCP tools
Creators connect their AI coding tool to the MiraDock MCP server with a write token or an OAuth client (mcp:read and mcp:write). The current tools are:
| Tool | Purpose |
|---|---|
get_game_sdk_spec | Returns this document. Agents call it first. Read credentials suffice. |
validate_game | Lints an HTML file ({ html }) against section 1 and section 11 without publishing. Returns { ok, errors[], warnings[], info[] }, each issue { code, message, line? }. Agents iterate until ok; info holds optional hints. Read credentials suffice. |
publish_game | Creates a Game: html plus metadata (title required; slug, description, genreTags[], coverImageUrl (HTTPS), orientation (any, landscape, portrait), minPlayers, maxPlayers, publish). Returns { gameId, slug, status, playUrl, validation }. |
get_game | Owner-only read of a Game's current settings by gameId or slug: title, description, genre tags, status, image URLs (1280 and 640) and image source, orientation, player limits, SDK version, leaderboards, SKUs, monetization flags, current version ID (the ID get_game_reports shows for the current HTML, or null until a report records it) and version count (including the current version), play URL, staged (null or { hasHtml, metadata, updatedAt, previewUrl }), accessMode, memberCount, and review (null or { status, reason, pendingChanges, requestedAt }). Requires mcp:read. Read a Game before updating it. |
update_game | New HTML version and/or metadata changes for an existing Game (by gameId or slug), or a status change to draft, published, or unlisted. Previous versions are kept. With stage: true the change becomes the Game's staged version instead of going live (see Staged versions below); staged updates cannot change status, publish or slug. |
publish_staged | Owner-only. Makes the staged version live ({ gameId or slug }) and archives the previous live HTML as a version. |
discard_staged | Owner-only. Deletes the staged version; the live version is unchanged. |
set_game_access | Owner-only. { gameId or slug, mode } with mode everyone (default) or members. |
add_game_member | Owner-only. { gameId or slug, username }: gives a MiraDock member access to a members-only Game. |
remove_game_member | Owner-only. { gameId or slug, username }: removes that access. |
set_game_skus | Replaces the Game's SKU list (up to 64): { sku, name, description?, kind, priceCents, sortOrder? }. Prices are integer USD cents, minimum 50. Existing entitlements for a removed SKU are kept; the SKU just cannot be bought again. |
set_game_leaderboards | Replaces the Game's leaderboard definitions (up to 64): { boardId, name, sort, period }. Removed boards lose their entries. |
set_game_monetization | { donations: true or false, shop?: true or false }. donations turns tips on or off (the field keeps its original name). Enabling either requires an active payout account. While the Store and tips are not open on MiraDock, enabling returns a validation_error that says so. |
set_game_image | Owner-only card and share image: { gameId or slug, imageBase64, mimeType } with raw base64 (no data: prefix) and image/png, image/jpeg or image/webp. Processed and limited as in section 13. Returns { gameId, imageSource, imageUrl, thumbnailUrl }. An upload always replaces a snapshot. |
get_game_stats | Owner-only analytics: { playCount, uniquePlayers, leaderboards: [{ boardId, entries }], revenue: null, metrics }. |
get_game_reports | Owner-only report list by gameId or slug, with optional status and limit. Requires mcp:read. Reporter identity is reduced to a Player or a guest; diagnostics and played version are included. |
update_game_report | Owner-only acknowledge, resolve, or dismiss by reportId, with optional creatorNote shown to the reporter when resolving. Requires mcp:write. |
report_issue | Sends a bug report or unexpected-behavior report to MiraDock (category, title, body, optional severity, context, game_id). |
Games have a status: draft (Creator only), published (listed and playable), unlisted (playable by link), suspended (platform action). publish_game creates in draft unless publish: true is passed. The Visible switch on a Game's dashboard page moves between published (visible) and unlisted (hidden, playable by link); agents make the same change with update_game status.
Staged versions. A Game has one live version, which is what Players, discovery and share previews get, and at most one staged version. Update a live Game with update_game and stage: true: the HTML and metadata are validated as usual and saved as the staged version, merged with any staged version already there. Only the owner (and MiraDock admins) can play it, at /play/<slug>?version=staged, under a "Preview: not live" banner; snapshots taken there never replace the live image. publish_staged swaps it in and archives the previous live HTML; discard_staged drops it. Restoring an archived version from the Game page stages it; it never publishes directly. Staged HTML counts toward storage and shares the HTML write budget.
Access. A Game's access is everyone (default) or members. A members-only Game needs sign-in and membership to play (the owner and MiraDock admins always can), is left out of discovery, search, the sitemap, the homepage and public profiles, and its multiplayer rooms reject non-members. Members see it under Shared with me in Play mode. Creators can also invite by email on the dashboard; an invite becomes a membership when that address signs in.
Publish review. MiraDock can require review before Games go public. When it is on, a Game's first publish and title, description or uploaded-image changes to a live Game wait for an admin: the Game stays a draft (or keeps its current title, description and image) until approved. Tool results include review: { status: "pending", pendingChanges, message }, and get_game shows the review status and any rejection reason. Staged HTML updates to an approved Game never need review. When review is off, publishing is immediate.
Paid SKUs and tips become available only after the Creator completes payout onboarding in the dashboard and platform payments are enabled. Until then set_game_skus accepts the definitions, set_game_monetization refuses to enable sales, and the play page shows nothing for sale.
11. Validation rules
validate_game enforces, in this order. Errors block publish; warnings do not.
Errors:
html_too_large: file exceeds 2 MiB after sanitization. The message reports the sanitized byte count and the limit; sanitization can add a few KB to the submitted file.missing_sdk_version: missing<meta name="miradock-sdk" content="1">in<head>.external_script: external<script src>present (it would be stripped, so the Game would break).missing_ready: no call toMiraDock.ready(found in Game script.forbidden_api: any use oflocalStorage,sessionStorage,indexedDB,document.cookie,fetch(,XMLHttpRequest, orWebSocketfound in Game script. The message names the API found and the issue'slineis its first occurrence.unknown_sdk_method: aMiraDock.method or property referenced that does not exist in this spec.
MiraDock.report.open and MiraDock.capabilities.report are supported without validator warnings.
Warnings:
missing_title: no<title>.missing_viewport: noviewportmeta tag.shop_not_configured:MiraDock.shop.referenced; the Game needs SKUs and an enabled Store.donations_not_enabled:MiraDock.tip.orMiraDock.donate.referenced but tips are not enabled.publish_gameandupdate_gameomit this warning when tips are enabled.frequent_save_writes: save writes appear inside arequestAnimationFrameor interval callback (likely to hit rate limits).
Info (optional hints; never an error or warning):
snapshot_missing: the Game never callsMiraDock.snapshot. See section 13.
12. Minimal compliant Game
Full source of the published reference Games, read-only: Tap Counter, Two-player Tap Duel, and Arena Dash.
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="miradock-sdk" content="1">
<title>Tap Counter</title>
<style>body{margin:0;font-family:system-ui;display:grid;place-items:center;height:100vh}</style>
</head>
<body>
<button id="tap">Taps: 0</button>
<script>
(async () => {
const ctx = await MiraDock.ready();
let taps = (await MiraDock.save.get("taps")) ?? 0;
const btn = document.getElementById("tap");
const render = () => btn.textContent = `Taps: ${taps} (${ctx.player.displayName})`;
render();
btn.onclick = async () => {
taps++;
render();
await MiraDock.save.set("taps", taps);
if (taps % 10 === 0 && MiraDock.capabilities.leaderboards) {
await MiraDock.leaderboard.submit("taps", taps);
}
};
})();
</script>
</body>
</html>13. Card images and snapshots
Every Game can have an image. It appears on Game cards (Browse, search, Creator profiles, the Creator dashboard) and as the Open Graph and Twitter image when a play link is shared. Without one, cards show a color gradient.
There are two sources:
- Upload. The Creator uploads an image on the dashboard Game page or with the
set_game_imageMCP tool. An upload always wins: snapshots never replace it. - Snapshot. The Game offers a picture of itself:
MiraDock.snapshot(canvas.toDataURL()) // → Promise<{ status: "accepted" } | { status: "ignored", reason }>- Pass a
data:image/png,data:image/jpegordata:image/webpbase64 URL, typicallycanvas.toDataURL(). Games without a canvas can draw a title card on an offscreen canvas. - Call it once, after the first meaningful frame is on screen (after
ready()and the first render, not a blank or loading screen). - Call it unconditionally. The host accepts a snapshot only when the viewer is the signed-in owner of the Game, the Game has no uploaded image, a moderator has not removed its image, the page is not a staged preview, and fewer than 5 snapshots were accepted for the Game that UTC day. Otherwise it resolves
{ status: "ignored", reason }withreasonone ofnot_owner,uploaded_image,removed_by_moderator,daily_limit,invalid_imageorstaged_preview. It never rejects for these cases, so Players never see an error and gameplay should never wait on it. - It rejects only with
invalid_argument(not exactly one string argument) or the usualtimeout.
Every image, from either source, is decoded and re-encoded server-side: PNG, JPEG or WebP input only (never SVG), at most 5 MB, at least 320×180 pixels. The host produces WebP at 1280×720 and 640×360 with a centered 16:9 cover crop and strips all metadata (EXIF, including location). Stored object keys include the Game ID and a content hash, so a new image always has a new URL.
A moderator can remove an image. After a removal, snapshots are ignored with removed_by_moderator until the Creator uploads a new image.
validate_game never errors or warns about snapshots. If a Game never calls MiraDock.snapshot, it adds one info hint, snapshot_missing.
14. Host implementation notes (not part of the Creator contract)
- The Game renders in a
srcdociframe withsandbox="allow-scripts"and a meta Content-Security-Policy (default-src 'none', inline scripts and styles,img-src https: data:,font-src https: data:,connect-src 'none'). The bridge is a singlepostMessagechannel with a per-load nonce. Every request carries{ nonce, id, method, args }and gets exactly one{ nonce, id, result | error }reply. Host events arrive as{ nonce, event, payload }. - Game exceptions from
window.onerrorandunhandledrejectionare sent over the nonce-checked iframe channel. The host retains at most the last 20 Game messages and stacks, capped at 4 KiB total; it never captures host-page logs. If technical details are checked, the report includes the played version ID, parsed browser and OS families, viewport, and room transport and measured RTT when present. No raw user agent, IP, or identifiers enter diagnostics. - The injected SDK is a small script inserted directly after the CSP in the sanitized
srcdoc, before any Creator script, versioned with the platform and never fetched by the Game. - Entitlement checks, save writes, and leaderboard writes are server actions authenticated by the host page's session, never by anything the Game supplies. Checkout confirmation is a separate host-only action; the iframe channel has no confirmation method.
player.idfor a signed-in Player is HMAC-SHA256 ofuser_id:game_idwith the server-onlyGAME_PLAYER_ID_SECRET, truncated to 20 bytes and lowercase base32 encoded (32 characters). Guest ids areguest-plus 20 random bytes in the same encoding.- Rate limits are enforced host-side per method; the Game gets
rate_limited, never a silent drop. - The play host creates one
play_sessionsrow per page load, independently of the SDK. Successfulready()marks it ready and links the existing deduplicatedgame_play_loadsrow. The sandbox cannot invoke session or Like actions, read the session token, or choose timestamps. - Guest session identity is a random UUID stored by the top-level play page at localStorage key
miradock.play.guest-key.v1. Signed-in sessions store only the account ID; neither IPs nor fingerprints are used. If storage is unavailable, the host uses a random identity for that load. Deleting an account replaces its session identities with unrelated random values so the exactly-one-identity constraint remains valid. - The host sends heartbeats every 30 seconds while visible, pauses them while hidden, and resumes on visibility.
pagehidesends/api/play/endonly{ sessionId, token }; a domain-separated HMAC using the server-onlyGAME_PLAYER_ID_SECRETauthenticates that session. End calls are idempotent, and stale end calls use the last heartbeat. Back/forward-cache restoration reloads the host for a fresh session. Creation is limited to 20 sessions per minute per account or guest key across Games using the durable rate-limit RPC; render retries reuse the same session. - The Like control lives outside the iframe. Its visible-time timer enables after a minute; the database requires a play session of at least 60 seconds before toggling a Like (
play_required). A reload retains the Like state and count, with the current session's timer starting again. game_metrics_refresh()runs hourly at/api/cron/metricswith the sameCRON_SECRETprotection as payouts and writesgame_metrics_refreshaudit events.game_metrics_refresh_oneupdates a Game after a Like. These service-only aggregates also appear inget_game_stats.metricsand the Creator's Game detail page; this does not affect ranking.- The trailing seven-day window includes successful sessions started in the window, including guests. Unique Players use distinct, separately namespaced account IDs and guest keys. Return rate is the fraction of those Players with a successful session before the window. Median duration includes ended sessions and unended sessions whose last heartbeat is over ten minutes old, treating the latter as ended at that heartbeat without writing or sweeping them. Ongoing sessions are excluded from the median. Likes are lifetime totals; purchases and tips count distinct Player ledger transaction IDs within the window, excluding fee/earning legs. Empty groups are zero.
- Multiplayer: all room tables and RPCs are service-only. The host binds Game, Player, account and play-session identities into encrypted server actions and polls catch-up every 5 seconds.
host_player_idis the current host; when a guest succeeds it, the required non-nullhost_user_idretains the last signed-in host as internal metadata. It is never used to authorize guest host privileges.SUPABASE_JWT_SECRETmust match the project's active legacy HS256 JWT signing secret; configure it server-side before enabling room traffic. Projects using only asymmetric signing must supply an equivalent supported signer before enabling rooms; no public-channel fallback is used. Private channel RLS permits broadcast reads and presence tracking, but only service-role broadcasts. Client presence is advisory. Supabase caches authorization until token refresh/expiry, so revocation is bounded by the two-minute token lifetime. - Realtime data channels and WebRTC signaling are owned by the top-level
PlayFrame, never the sandboxed Game. Server actions verify active room membership and peer destination before forwarding ephemeral signaling or fallback packets. Set server-onlyCLOUDFLARE_TURN_KEY_IDandCLOUDFLARE_TURN_API_TOKENto enable short-lived Cloudflare TURN credentials. The key and API token never reach a browser; only minted ICE credentials do. A 10-second pair timer selects the room Realtime relay if direct/ICE connectivity fails. A local-only browser test hook,window.__MIRADOCK_TEST_BLOCK_ICE = truebefore page scripts, forces this fallback. - Store:
ECONOMY_ENABLED=trueturns on platform payments. Purchases and tips run in one database transaction (economy_transact) that checks the price, balance, owner guard and payout account, writes the ledger, and grants the entitlement. The platform fee isfloor(price * fee_bps / 10000)fromplatform_fees; the Creator receives the remainder as pending earnings.