---
name: build-game
version: 1.7.0
description: "Build, validate, test, and publish a MiraDock game with the Game SDK and MCP tools."
---

# Build a Game

Use this skill when a human wants to build or publish a game on MiraDock. Ask for their game idea if it is not already supplied.

## Connect and read the spec

Connect to the Streamable HTTP MCP server at https://miradock.com/mcp. Use a write token from https://miradock.com/app/mcp in the Authorization: Bearer <YOUR_WRITE_TOKEN> header, or OAuth 2.1 with mcp:read and mcp:write via https://miradock.com/.well-known/oauth-protected-resource. Configure credentials in your MCP client, never in game HTML. Game management and stats are owner only. Games belong to the Creator's account, so any write token or OAuth grant for that account can manage them.

No account yet? Register anonymously for a sandbox write token (details in https://miradock.com/auth.md):

```http
POST https://miradock.com/agent/auth
Content-Type: application/json

{
  "type": "anonymous",
  "requested_credential_type": "mcp_write_token",
  "agent_label": "optional agent name"
}
```

The 201 response's credential is the token. Anonymous sandbox tokens can call get_game_sdk_spec and validate_game, and publish_game with publish: false only. They may keep at most 3 drafts, within the sandbox size quota (10 MiB by default). Draft HTML and metadata can be updated while status stays draft. Before publishing or unlisting, setting leaderboards, SKUs or monetization, or reading stats, complete the email claim step at https://miradock.com/agent/auth/claim. The drafts transfer to the claimed account.

Call get_game_sdk_spec first. Read the complete spec, including the minimal compliant game in section 12. The same spec is at https://miradock.com/docs/game-sdk and https://miradock.com/llms-full.txt. Guides: https://miradock.com/docs/multiplayer and https://miradock.com/docs/store. Read-only credentials can read and validate; obtain write access before publishing.

## Scaffold

Read-only source of every reference Game: https://miradock.com/docs/samples/tap-counter.html, https://miradock.com/docs/samples/two-player-tap-duel.html, https://miradock.com/docs/samples/arena-dash.html.

Start with the section 12 HTML example and adapt it to the human's idea. Supply title, slug, description, genreTags, coverImageUrl, orientation, minPlayers, and maxPlayers as MCP metadata, not in the HTML.

- Deliver one self-contained HTML file, at most 2 MiB of UTF-8 after sanitization, with inline scripts and styles.
- Include <meta name="miradock-sdk" content="1"> and call MiraDock.ready() within 10 seconds. The host injects window.MiraDock; do not install or import the SDK.
- No network calls: no fetch, XMLHttpRequest, or WebSocket. Images/fonts may use the origins allowed by the spec.
- No browser storage: no localStorage, sessionStorage, indexedDB, or cookies. Use MiraDock.save and debounce writes.
- Check MiraDock.capabilities before optional operations. Multiplayer rooms support 2–16 players; guests join, signed-in players create.
- `MiraDock.capabilities.multiplayer.realtime` is true when fast-action realtime rooms are available.
- Never place prices in game HTML. Configure prices only with set_game_skus; let the host render checkout and payment UI. Gate paid content on host entitlements and the entitlements event.

## Multiplayer

Use MiraDock.multiplayer.create({ maxPlayers: 2 }) or MiraDock.multiplayer.join(code), then register room.on("message"), room.on("players"), and room.on("closed"). Share room.joinCode. The relay is not authoritative: validate peer payloads in clients and never base paid rewards on outcomes. Send at most 20 messages/second, each at most 4 KiB; only 200 messages are retained. Rooms expire after 30 idle minutes at the next 15-minute cleanup. Guests may join but must sign in to create. Full guide: https://miradock.com/docs/multiplayer.

The complete multiplayer reference is [Two-player Tap Duel](https://miradock.com/play/two-player-tap-duel), with source at https://miradock.com/docs/samples/two-player-tap-duel.html (under 20 KB). It includes Create room and Join by code, guest guidance, solo practice, and the host lobby's invite auto-join via `MiraDock.on("joined", ({ joinCode }) => MiraDock.multiplayer.join(joinCode).then(attachRoom))`.

For fast action, call `const rt = await room.realtime()`. The host browser is authoritative and each peer has a WebRTC pair to the host only. Use `rt.send(payload, { to: "all", fast: true })` for snapshots, `{ to: rt.hostId, fast: true }` for client inputs, and reliable sends for discrete events. Register `rt.on("data", ...)`, `rt.on("peers", ...)`, and `rt.on("host", ...)`; read `rt.stats()` for RTT and transport. Limit payloads to 16 KiB and sends to 60 fast or 20 reliable per second per peer. After 10 seconds without connectivity a pair uses the room relay. Host migration reconnects peers; the Game must preserve its own state. A host can cheat, so paid rewards must not depend on realtime results. The realtime reference is [Arena Dash](https://miradock.com/play/arena-dash), source https://miradock.com/docs/samples/arena-dash.html (under 40 KB). It simulates at 30 Hz, sends 20 Hz snapshots, interpolates clients, and calls snapshot for the card image.

Common realtime mistakes:
- Calling `room.realtime()` only on the host. Call it on every client immediately after `create` or `join`; the host page has already started connecting, so the call attaches to that connection.
- Sending round start or other must-arrive events on `fast`. Use the reliable channel with `{ to: "all" }`.
- Gating on `transport === "webrtc"`. The same message may arrive over relay with higher latency; handle both the same.
- Treating a missing snapshot for a few ticks as "not started". It is lag (fallback or host migration); hold the last state.
- `multiplayer.create` and `join` can take up to 20 seconds before timing out; show a joining state.

Event order: `players` arrives before any `message` from the same membership change, a host change arrives before further messages, and messages arrive in `seq` order per room.

Protocol walkthrough: the current host sends `{ t: "start", at }`, where `at` is the shared start time in epoch milliseconds. Both clients reset scores and use `at + 30000` as the deadline. Each tap sends `{ t: "tap" }`; both clients increment the score for the relay-stamped `from`, including their own echoed messages. The host sends `{ t: "end" }` at the deadline and both clients show results. Only the current host's start/end messages are accepted. A successor host uses the same deadline. Taps are spaced at least 350 ms apart so a full round fits within the retained 200-message suffix. This is a cooperative reference, not cheat-proof competitive scoring; peers can forge payloads and clocks can differ. The message stream determines the final counts.

Publish both repository samples through the real MCP path with `MIRADOCK_URL=https://miradock.com MIRADOCK_WRITE_TOKEN=... node scripts/publish-sample-games.mjs`. Supply the token through your environment; never commit it. The script validates, creates or updates by slug, configures the Tap Counter leaderboard, disables monetization, and publishes. `SAMPLE_SLUG_PREFIX` isolates local E2E seeds.

Minimal turn passing (creator starts; joining player sets myTurn = false):

```js
const ctx = await MiraDock.ready();
const room = await MiraDock.multiplayer.create({ maxPlayers: 2 });
if (room.status !== "signin_required") {
  let myTurn = true;
  await room.on("message", ({ from, payload }) => {
    if (from !== ctx.player.id && payload?.type === "pass") myTurn = true;
  });
  async function passTurn() {
    if (!myTurn) return;
    await room.send({ type: "pass" });
    myTurn = false;
  }
}
```

## Validate → fix → validate

Call validate_game with { html }. It returns { ok, errors, warnings, info }; validation writes nothing. info holds optional hints, such as snapshot_missing.

1. If ok is false, inspect every error (code, message, and optional line), fix the HTML, and call validate_game again on the complete revised file.
2. Repeat this validate-fix loop until ok is true. Do not publish while any validation error remains.
3. Review warnings, fix applicable ones, and rerun validation after every HTML change. Shop/tip warnings can reflect settings that have not been configured yet. Static validation cannot prove runtime behavior or the ready deadline.

## Card image: snapshot the first frame

Once the first meaningful frame is on screen (after ready and the first render, not a blank or loading screen), call MiraDock.snapshot(canvas.toDataURL()) once. Games without a canvas can draw a title card on an offscreen canvas and pass that. Call it unconditionally: it resolves { status: "accepted" } only when the signed-in owner is playing and no image was uploaded, and { status: "ignored", reason } otherwise. Never block gameplay on the result. When the human supplies artwork, upload it with set_game_image { gameId, imageBase64, mimeType } instead; uploads always win over snapshots.

## Publish a draft and self-test

Before updating a Game, call get_game { gameId or slug } to read its current title, description, tags, status, image, leaderboards, SKUs, monetization flags, and version, and change only what the human asked for. Also call get_game_reports and address open bugs; resolve them with a note after publishing the fix. Players can open the host report sheet from the play page or through MiraDock.report.open({ category?, note? }). The Game never sees the reporter's account identity.

Call publish_game with { html, title, ...metadata, publish: false }. Save the returned { gameId, slug, status, playUrl, validation }. Drafts are visible only to the owner.

If a browser is available, open playUrl in the owner's signed-in session. Confirm the game loads, calls ready within 10 seconds, responds to controls, and handles saves, pause/resume, and optional capabilities without console errors. Do not make it public just to bypass draft access. If no browser or owner session is available, record that runtime testing was not performed and give the human the draft URL.

For runtime fixes, edit locally, run the validate-fix loop again, then call update_game with { gameId, html } to update the existing draft. Reload playUrl and retest. Do not create duplicate games for revisions.

## Configure leaderboards, SKUs, and monetization

After draft testing, configure in this order using gameId or slug:

1. set_game_leaderboards with the full boards array: [{ boardId, name, sort: "desc" or "asc", period: "alltime" or "weekly" or "daily" }]. The section 12 example uses boardId "taps". Use [] if no boards are needed and remove any calls to unused boards from HTML. Replacement removes omitted boards and their entries, so review removal warnings on existing games.
2. set_game_skus with the full skus array: [{ sku, name, description, kind: "item" or "access" or "consumable", priceCents, sortOrder }]. Prices are integer USD cents, minimum 50. Use [] for a free game. Existing kinds cannot change; removed SKUs become inactive while entitlements remain. Do not use tip SKUs or the reserved supporter ID; tips are configured next. Definitions can be saved before payout onboarding.
3. set_game_monetization with { gameId, donations: false, shop: false } for a free game. The donations field turns tips on or off. Enable requested tips/shop only after the creator completes payout onboarding at https://miradock.com/app/earnings and platform payments are enabled. If onboarding or payments block enabling, leave both false, explain the remaining step, and do not claim sales are active. While the Store and tips are not open on MiraDock, enabling returns a validation_error that says so; SKU definitions stay saved. To open the tip sheet from Game code, call MiraDock.tip.open() (donate.open is the older name); tips are for the Game being played. Store guide: https://miradock.com/docs/store.

If a browser is available, retest configured boards, entitlement handling, and the host shop. Do not make real purchases as a self-test. Fix any HTML through validate_game and update_game before continuing.

## Make the game public

Call update_game with { gameId, status: "published" } when the game is ready to publish. This revalidates the stored HTML. Handle any validation_error through the same validate-fix loop. Never set suspended or combine status with the publish alias.

Return the playUrl, gameId, publication status, validation result, configured boards/SKUs, monetization state, and what was or was not browser-tested. If the human only requested a draft, keep status draft. Later use get_game_stats for owner-only plays, unique players, leaderboard sizes, and metrics; revenue is currently null. If a MiraDock tool misbehaves, send details with report_issue.

If the result includes review: { status: "pending" }, MiraDock publish review is on: tell the human the Game goes live after an admin approves it. get_game shows review.status and, if rejected, review.reason.

## Update a live Game

Players must never get an untested version. For a Game that is published or unlisted, call update_game with { gameId, html, stage: true } (add metadata changes in the same call if needed). The staged version is validated like any update and only the owner can play it at playUrl?version=staged, under a "Preview: not live" banner. Retest it there, stage further fixes the same way, then call publish_staged { gameId } to make it live (the previous version is archived) or discard_staged { gameId } to drop it. Staged updates cannot change status, publish or slug. get_game shows whether something is staged.

## Who can play

Access is everyone by default. If the human wants to share the Game only with specific people, call set_game_access { gameId, mode: "members" } and add_game_member { gameId, username } for each person (remove_game_member to revoke). Members-only Games are hidden from every public listing and need sign-in to play.
