miniantgames-mcp 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/guide.json +1 -1
- package/dist/index.js +18 -18
- package/package.json +2 -2
package/dist/guide.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"pages":[{"slug":"introduction","title":"Introduction","section":"Introduction","agentSummary":"Portal overview and reading map (which guide pages a game actually needs based on its capabilities), plus the publishing lifecycle and supported engines.","checklist":[],"body":"# Introduction\n\nMiniant Games (`miniant.games`) is a game portal: a curated catalog of web games, each entered\nthrough a Miniant-owned mode picker rather than at the game's own address. These docs are for\ndevelopers who want to publish a game onto that portal — what your build needs to look like, how\nthe SDK your game talks to the portal works, and what happens between requesting a publish and\ngoing live.\n\n> **The platform layer is live.** Every game is entered through a Miniant-owned mode picker at\n> `miniant.games/play/<your-slug>` — never at your game's own subdomain directly. A player picks a\n> mode there, sees their Miniant balance, and only then gets handed into your game inside a\n> portal-owned frame. Your build needs a valid `miniant.json`, your publish request needs the\n> anti-cheat questionnaire, and your game needs to call the `@mag/game-sdk` contract to ever\n> receive a play session — a game that skips the SDK cannot start a session, cannot report a\n> score, and is never shown to a player.\n\n## What you actually need to read\n\nThe contract is capability-scoped, so most developers don't need every page:\n\n| Your game | Read |\n|---|---|\n| Every game, no exceptions | [Requirements](/guide/requirements) and [Publishing your game](/guide/publishing), plus the core of the [SDK reference](/guide/sdk). You can skip the `Miniant.net` relay contract if your game is solo/vs-AI only — but still read \"Spectating\" if you declare `capabilities.spectate`, even without `relay: true` |\n| Multiplayer (`capabilities.relay`) | + [Multiplayer requirements](/guide/requirements/multiplayer) and the full relay contract in [Multiplayer](/guide/sdk/multiplayer) |\n| Sells anything (items, currency, ad removal) | + [In-game purchases](/guide/sdk/purchases) |\n| Has its own backend server (tier 2) | + [Receipt verification](/guide/sdk/receipts) |\n\nPages you skip still bind you if you later add the capability — adding `store.skus` or `relay` to\nyour manifest means the matching rules apply from that build onward.\n\n## Why publish here\n\n**A real in-game purchase system.** If your game sells anything — a gem pack, an undo, a skin,\n\"remove ads\" — you declare it as a SKU in your manifest, and the portal handles the wallet, the\npurchase confirmation UI, and entitlement tracking for you. Your game never sets a price and never\ntouches currency directly; it asks the portal to buy/read/burn a SKU it already declared. If you\nrun your own backend, you can also verify a purchase independently with tier-2 offline receipt\nverification — a signed receipt your server checks against the platform's public key, with an\noptional live revocation check. See [Receipt verification](/guide/sdk/receipts) for the full contract.\n\n**Multiplayer infrastructure built in.** A relay for exchanging gameplay messages between seats, a\nmatchmaking path with AI fill when no human opponent is available (`online_random`), spectating so\na friend can watch a match read-only without joining it, and a friends/participants model the\nportal already resolves for you — your game never builds its own lobby, friend list, or invite UI.\nSee [Multiplayer](/guide/sdk/multiplayer) for the relay contract and [Spectating](/guide/sdk/spectating).\n\n**An AI-agent publishing spec.** The \"Copy agent instructions\" button on this guide hands your\ncoding agent a complete integration spec, so it can implement against the platform directly instead\nof you transcribing these docs into a prompt by hand.\n\n**Ad revenue share, for every game.** The portal runs its own ad placements around your game —\nan interstitial when a player chooses a rematch or waits for an online opponent, and opt-in\nrewarded offers to double a round's XP, waive an unaffordable in-game spend, or spectate a friend\n— and bills each of them to your game at a 70/30 split in your favour, paid out manually against\nmonthly statements (see [Monetization](/guide/publishing/monetization)). No ad code is required for\nthat. `Miniant.ads` (see [Video ads](/guide/sdk/video-ads)) only adds inventory the portal cannot\nsee, such as a level end inside a longer session. Players never cash out either way — tournament\nand event rewards stay virtual (in-portal currency and items), with no cash-out path for them.\n\n## The publishing lifecycle\n\nPublishing is a single stage, end to end:\n\n1. **Sign in** at `publish.miniant.games` — you'll only see games the Miniant team has invited you\n to publish.\n2. **Connect your repo** — point the portal at your GitHub repository and branch.\n3. **Request a publish** — this queues a build, which is validated automatically.\n4. **The site owner reviews and approves** — a Miniant team member looks at the build (including a\n screenshot and console output from a headless run) and approves or rejects it. This is a manual,\n human step.\n5. **Your game is live** at `<your-slug>.miniant.games`, served inside the portal's own frame.\n\nThere is no soft launch, staged rollout, or limited-audience phase — a build is either awaiting\nreview, live, or not live. Only the site owner can approve a build or roll back what's currently\nlive; the developer portal is read-only for that part of the pipeline.\n\n## Supported engines\n\n- **Phaser / plain JavaScript** — any build that outputs static files to a directory you point the\n portal at.\n- **Godot 4.2.2** — via an HTML5 export preset named exactly `Web`.\n- **Prebuilt web export (any engine)** — Construct, GameMaker, Cocos, and similar: export to a\n static web build yourself and commit it; the pipeline verifies it rather than building it.\n- **Unity (WebGL export)** — same prebuilt path, via a dedicated wrapper. See\n [`/guide/sdk/engines`](/guide/sdk/engines) for the full walkthrough.\n\n## Test before you submit\n\nMost first-publish failures are avoidable. Download the local validator once and run it against\nyour build output — it runs the same manifest and structural checks the platform runs, in seconds,\nwith no install (a few server-only scans, like the sandboxed malware scan, only run once your build\nreaches the pipeline — see [Publishing your game](/guide/publishing)):\n\n```sh\ncurl -fsSLO https://publish.miniant.games/tools/miniant-validate.mjs\nnode miniant-validate.mjs ./dist # or your custom build output directory\nnode miniant-validate.mjs ./dist --json # machine-readable output\n```\n\nBuilding with an AI coding agent? Install the `miniantgames-mcp` server\n(`npx -y miniantgames-mcp`, or in Claude Code `claude mcp add miniantgames -- npx -y miniantgames-mcp`)\nand your agent can read this guide, pull checklists, scaffold a starter game and run this same\nvalidator as tools.\n\nSee [Publishing your game](/guide/publishing) for the full pre-publish checklist.\n\n## Where to go next\n\n- [Requirements](/guide/requirements) — what your game and manifest need to satisfy before you can\n publish.\n- [Publishing your game](/guide/publishing) — the full walkthrough: preparing your repo, connecting\n it, the review process, and common gotchas.\n"},{"slug":"requirements","title":"Requirements","section":"Requirements","agentSummary":"Index of the seven requirement categories a build and publish request must satisfy, plus what's required outside the build itself.","checklist":[{"id":"req.metadata-required","text":"Provide a description and control instructions for your game before requesting a publish.","verify":"manual"},{"id":"req.cover-art-required","text":"Provide the required cover art per the Game covers spec before requesting a publish.","verify":"manual"},{"id":"req.questionnaire-required","text":"Complete the anti-cheat questionnaire before every publish request, not just your first.","verify":"manual"},{"id":"req.quality-guidelines-optional","text":"Treat Quality guidelines as optional best practice — nothing there blocks a build on its own.","verify":"manual"}],"body":"# Requirements\n\nBefore your game goes live at `<your-slug>.miniant.games`, it has to clear two gates: an\nautomated build validator that runs on every publish request, and a Miniant team member who\nmanually reviews the approved build before it ships. This section covers what's actually checked\n— by the validator, and by convention — organized by area.\n\nThis is a **single-tier set of requirements**. There's no \"basic\" vs. \"full\" split — every game\npublishing here goes through the same validator and the same manual review, regardless of size or\nengine.\n\nRequirements apply based on what your game actually does, not just what it looks like today. A\ncategory you can skip right now — say, Multiplayer, because your game is solo-only — still applies\nthe moment you add the matching capability (`relay`, `wallet`, `spectate`, …) to your manifest in a\nlater build. Read the category up front if there's any chance you'll need it eventually; it's\neasier than discovering the requirement at review time.\n\n| Category | What we require | Details |\n|---|---|---|\n| [Technical](/guide/requirements/technical) | A build that passes the automated validator: a valid `miniant.json` manifest, a resolvable entry file, no disallowed or sensitive files, the SDK loaded from the right URL, and an archive under the size cap | [Technical](/guide/requirements/technical) |\n| [Gameplay](/guide/requirements/gameplay) | Gameplay that fits the platform's play model — modes, sessions, and results reported through the SDK | [Gameplay](/guide/requirements/gameplay) |\n| [Advertisement](/guide/requirements/advertisement) | What's allowed (and not) if your game shows ads | [Advertisement](/guide/requirements/advertisement) |\n| [Account integration](/guide/requirements/account-integration) | How your game reads player identity and (optionally) saved state through the SDK, never building its own sign-in | [Account integration](/guide/requirements/account-integration) |\n| [Multiplayer](/guide/requirements/multiplayer) | Declaring modes and lobby sizes, the relay message-rate budget, spectating, and chat/moderation expectations for any relay-backed game | [Multiplayer](/guide/requirements/multiplayer) |\n| [Game covers](/guide/requirements/game-covers) | Cover art specs for how your game is presented across the portal | [Game covers](/guide/requirements/game-covers) |\n| [Quality guidelines](/guide/requirements/quality-guidelines) | Best-practice recommendations for a good player experience | [Quality guidelines](/guide/requirements/quality-guidelines) |\n\n## Beyond the build itself\n\nA publish request needs more than a passing build:\n\n- **Game metadata** — a description and control instructions for your game, shown to players on\n its listing page.\n- **Cover art** — see [Game covers](/guide/requirements/game-covers) for the specs.\n- **The anti-cheat questionnaire** — a short, prefilled form that's required before every publish\n request, not just your first. It declares your score bounds, session length, message rate, and\n whether your game sells anything or calls external endpoints. See\n [Publishing](/guide/publishing) for the full workflow.\n\n## What's required vs. optional\n\nEverything above is required, with one exception: **Quality guidelines are optional best\npractice**, not something the validator or a reviewer blocks a build on. Treat that page as\nrecommendations for a better player experience, not a checklist you have to clear.\n"},{"slug":"requirements/technical","title":"Technical","section":"Requirements","agentSummary":"The automated build validator's exact checks (manifest schema, entry and reference resolution, sensitive files, size cap), plus what your game must never build.","checklist":[{"id":"req-tech.manifest-valid","text":"Include a valid miniant.json in your build output or repo root, matching the required schema.","verify":"validator:manifest.schema"},{"id":"req-tech.progression-declared","text":"Declare progression — \"levels\" or \"continuous\" — to say whether your game has discrete levels or is one continuous run. It has no default because it sets what a player pays to Resume.","verify":"validator:manifest.schema"},{"id":"req-tech.mode-rules-consistent","text":"Keep every mode's players range, playerCounts, sync, and unavailable declarations consistent with the manifest's cross-mode rules.","verify":"validator:manifest.mode-rules"},{"id":"req-tech.ai-declared-for-ai-backed-modes","text":"Declare AI — capabilities.ai true, or a vs_ai mode — if your manifest declares online_random or an unavailable policy of auto.","verify":"validator:manifest.mode-rules"},{"id":"req-tech.spectate-declared","text":"Declare capabilities.spectate: true — spectating is a core portal feature every game must support; the validator fails a build without it.","verify":"validator:manifest.spectate-required"},{"id":"req-tech.online-ladder-complete","text":"Consider declaring online_friends, online_random, and vs_ai together if your manifest declares any online mode — suggested, never required.","verify":"validator:modes.online-ladder"},{"id":"req-tech.entry-exists","text":"Make sure the entry file named in miniant.json exists in your build output.","verify":"validator:manifest.entry-exists"},{"id":"req-tech.references-resolve","text":"Make sure every static reference in your entry HTML resolves to a real file in the build output, at matching case.","verify":"validator:html.references-resolve"},{"id":"req-tech.sdk-loaded-from-www","text":"Load the SDK from https://www.miniant.games/sdk/v1.js, never from the apex miniant.games domain.","verify":"validator:html.sdk-url-www"},{"id":"req-tech.store-skus-valid","text":"If you declare store.skus, give every SKU a unique id and valid price fields, and set capabilities.wallet true.","verify":"validator:manifest.schema"},{"id":"req-tech.no-sensitive-files","text":"Keep the build output free of anything that looks like a secret — env files, key or cert files, git or ssh directories, credentials files.","verify":"validator:files.no-secrets"},{"id":"req-tech.no-lfs-pointers","text":"Push real Git LFS objects — an unresolved pointer file where a tracked asset should be fails the build.","verify":"validator:files.no-lfs-pointers"},{"id":"req-tech.no-disallowed-executables","text":"Don't ship disallowed executable extensions, or archive entries using path traversal or an absolute path.","verify":"validator:files.no-executables"},{"id":"req-tech.artifact-under-size-cap","text":"Keep your build output archive under the 300MB artifact size cap.","verify":"validator:files.size-cap"},{"id":"req-tech.no-service-worker","text":"Don't register a service worker anywhere in the build.","verify":"validator:sw.none"},{"id":"req-tech.no-external-payment-sdk","text":"Don't reference a known external payment SDK, such as Stripe, Razorpay, PayPal, or Paddle, anywhere in your build.","verify":"manual"},{"id":"req-tech.no-mode-selector-or-lobby","text":"Never build your own mode selector, friend list, matchmaking, lobby, or invite UI — read context.mode and context.participants instead.","verify":"manual"},{"id":"req-tech.no-own-store-ui-or-payment","text":"Never build a store UI, invent prices, or wire up an external payment SDK — sell only through Miniant.wallet.purchase() with a declared skuId.","verify":"manual"},{"id":"req-tech.no-own-networking","text":"Never open your own transport for gameplay traffic — use Miniant.net, unless you run your own developer_server_validates tier-2 server.","verify":"validator:sdk.no-own-networking"},{"id":"req-tech.no-parent-frame-access","text":"Never navigate the top window, open the portal in a frame, or read the parent document from inside your game.","verify":"manual"},{"id":"req-tech.orientation-any","text":"Declare orientation \"any\" and adapt: background to every edge, playfield scaled by the short side (never stretched), HUD beside it on wide screens, redone on resize. Cite the layout code.","verify":"manual"}],"body":"# Technical\n\nEvery publish request runs through an automated validator before a human ever sees it. This page\ndocuments exactly what that validator checks — nothing more, nothing rounded up. For the build\npipeline itself (repo connection, build statuses, what a failure looks like on your build card),\nsee [Publishing](/guide/publishing). For the SDK calls your game makes at runtime, see\n[SDK](/guide/sdk).\n\n## Enforced by the validator\n\nThese are the actual checks the validator runs against your build archive, one row per named\ncheck. Most are hard failures — the build stops at *validating* and can't proceed until you fix\nthem. Five are warnings shown to the reviewer at approval but don't block the build by\nthemselves; those are marked below.\n\n| Check | What it verifies | Blocks the build? |\n|---|---|---|\n| Manifest (`miniant.json`) | A `miniant.json` exists in your build output (or at your repo root, in which case the builder copies it in for you) and matches the required schema | Yes |\n| Cross-mode sync rules | Every `online_friends`/`online_random` mode's `sync` (declared or defaulted to `\"realtime\"`) is consistent with the manifest's other declarations — see the `modes[].sync` rules just below the manifest example | Yes |\n| Online mode coverage | If `modes` declares any online mode (`online_friends`/`online_random`), whether it also offers the rest of the ladder (`online_friends`, `online_random`, `vs_ai`) — see [Multiplayer](/guide/requirements/multiplayer#the-full-online-ladder-is-suggested-not-required) | **No** — suggestion only |\n| AI capability for AI-backed modes | If `modes` declares `online_random`, or any mode declares `unavailable` with `policy: \"auto\"`, the manifest declares AI — `capabilities.ai: true`, or a `vs_ai` mode — see [Multiplayer](/guide/requirements/multiplayer#onlinerandom-and-auto-need-your-game-to-have-ai) | Yes |\n| Spectate capability | The manifest declares `capabilities.spectate: true` — spectating is a core portal feature every game must support (friend watching, tournament broadcasts, the Live hub) — see [Multiplayer](/guide/requirements/multiplayer#spectating) | Yes |\n| Stand-in for spectatable roomless games | A manifest that declares `capabilities.spectate: true` and a `vs_ai` or `online_random` mode must also declare `capabilities.standin: true`, so a sleeping player's match keeps playing for watchers — see [Spectating](/guide/sdk/spectating#stand-in-keep-a-roomless-match-playing-while-the-phone-sleeps). A game with only `online_friends` (and no roomless mode) is exempt | Yes |\n| Adapts to any screen shape | The manifest declares `orientation: \"any\"` — see [Your game must fit every screen shape](#your-game-must-fit-every-screen-shape) | **No** — warning only, shown to the reviewer |\n| Cross-mode `unavailable` rules | Every mode's `unavailable` is meaningful where declared (online, non-`parallel` sync) and the manifest clears the network-headroom floor when any mode declares it — see \"Player availability\" just below the manifest example | Yes |\n| Entry file and references | The `entry` file named in `miniant.json` exists in your build output, and every static reference in it (`<script src>`, `<link href>`, `<img src>`, `<audio>`/`<video>`/`<source>` `src`, and `<link rel=\"stylesheet\"\\|\"icon\"\\|\"manifest\">`) resolves to a real file at matching case | Yes |\n| SDK loaded from `www`, not apex | Your entry HTML doesn't load the SDK from `https://miniant.games/sdk/` — the apex domain redirects in a way that breaks under the play frame's isolation, so the SDK silently never loads. Use `https://www.miniant.games/sdk/v1.js` | Yes |\n| Store SKUs (`store.skus`) | If your manifest declares a `store.skus` block, every SKU matches the schema — unique `id`, valid price fields, the 200-SKU cap, and `capabilities.wallet: true` being set. Skipped entirely (not failed) when there's no manifest to check | Yes, when the manifest declares a `store` block with problems |\n| No sensitive files | Nothing that looks like a secret is anywhere in the archive — see \"Disallowed extensions and sensitive files\" below | Yes |\n| No unresolved Git LFS pointers | No small LFS pointer stub file (~130 bytes) where a real tracked asset should be | Yes |\n| No disallowed executables | No file with a disallowed extension, and no archive entry using a path-traversal (`../`) or absolute (`/…`) name — see below | Yes |\n| Artifact size under the cap | Your build output archive is under the size cap — see below | Yes |\n| Archive is readable | The build output is a valid, non-empty, readable zip archive with no zip-bomb characteristics (anomalous compression ratio, entry count, or nesting) | Yes |\n| No service worker registered | No file contains `serviceWorker.register` or `navigator.serviceWorker` | **No** — warning only, shown to the reviewer |\n| No external payment-SDK signals | No `.html`/`.htm`/`.js`/`.mjs` file references a known external payment-SDK host or inline call marker (Stripe, Razorpay, PayPal, Paddle, Lemon Squeezy, Dodo Payments, 2Checkout, Braintree, Square, or a generic `data-checkout` marker) | **No** — warning only, flagged for the reviewer to judge (a tier-2 game legitimately talks to its own server, so a match alone doesn't prove a bypass) |\n| Calls `.reportResult(...)` | Some scanned `.html`/`.htm`/`.js`/`.mjs` file contains a `.reportResult(...)` call — reporting a result is mandatory, no exceptions (2026-09-14) — see [Reporting results is mandatory](/guide/sdk/scores#reporting-results-is-mandatory) | Yes |\n| Levels games report which level completed | If your manifest declares `progression: \"levels\"`, some `.reportResult(...)` call's payload carries `detail.level` — one session is one level, and the portal can't tell them apart without it. Skipped for a `\"continuous\"` game, or when the manifest never parsed | Yes, only for a `\"levels\"` manifest |\n\n**The artifact size cap is 300MB.** This is a generous cap sized for the largest supported\nengine's export (Godot's HTML5 export with its wasm runtime) — it's a sanity/cost guard, not a\nprecision limit tuned to any particular engine.\n\n### Disallowed extensions and sensitive files\n\n**Disallowed file extensions** — a file anywhere in your build output with one of these\nextensions fails the build outright: `.exe`, `.dll`, `.so`, `.dylib`, `.sh`, `.bat`, `.cmd`,\n`.ps1`, `.msi`, `.app`, `.dmg`, `.apk`, `.scr`, `.vbs`. The same check also rejects any archive\nentry whose name contains a `../` path-traversal segment or starts with `/`.\n\n**Sensitive files** — anything that looks like it could leak a credential is a hard failure,\nbecause your build output gets published to a public URL verbatim. Checked by filename:\n\n- `.env` or any `.env.*` file — flagged as an environment file.\n- `.pem`, `.key`, `.p12`, `.pfx`, `.keystore` — flagged as a private key / certificate file.\n- Any file whose name starts with `id_rsa` or `id_ed25519` — flagged as an SSH private key.\n- `.sqlite`, `.db` — flagged as a local database file.\n- Any path containing a `.git/` or `.ssh/` segment — flagged as a version-control/SSH metadata\n directory.\n- A file named exactly `credentials` or `secrets` (with or without an extension) — flagged as a\n credentials file.\n\nThe validator also reads the content of small text-like files (under 64KB) looking for a private\nkey marker (`-----BEGIN PRIVATE KEY`, `-----BEGIN OPENSSH PRIVATE KEY`, `-----BEGIN RSA PRIVATE\nKEY`) even in files that don't match any of the name patterns above — that catches a key\naccidentally embedded in, say, a config file with an unrelated name. This content scan skips\nknown binary asset formats (images, audio, video, fonts, `.wasm`, `.pck`, `.data`, `.zip`, `.gz`)\nsince scanning those for text markers is pointless.\n\nSeparately, a malware scan runs inside the sandboxed build runner itself, as part of the build\npipeline rather than this validator — a build that fails that scan never reaches the validation\nstep at all.\n\n## The `miniant.json` manifest\n\nEvery build must include a `miniant.json` file at the root of its build output (next to\n`index.html`). If your build output doesn't have one but your repo root does, the builder copies\nit in for you automatically and notes this on the build card. If neither location has one, the\nbuild fails immediately, before validation even runs. A manifest that's present but invalid fails\nthe build at the *validating* step, with the specific reason shown on the build card.\n\nThis is the guide's canonical complete, valid manifest — a turn-based online mode with the relay\ndeclared and every cross-mode rule below satisfied:\n\n```jsonc\n{\n \"sdkVersion\": 1,\n \"entry\": \"index.html\", // must exist in your build output\n \"orientation\": \"any\", // \"any\" (expected) | \"landscape\" | \"portrait\" — see below\n // REQUIRED. Says whether your game is played in discrete levels or as one continuous run.\n // It decides what a player is charged to Resume, so there is no default and the validator\n // refuses a manifest without it — see \"Levels or one continuous run?\" below.\n \"progression\": \"levels\", // \"levels\" | \"continuous\"\n \"modes\": [\n { \"id\": \"solo\", \"players\": [1, 1] },\n // `playerCounts` is optional. Omit it and every count in the `players` range is offered\n // (2, 3 and 4 here). Declare it when your game does NOT support every number in between —\n // singles or doubles is [2, 4], never 3 — so the picker can't offer a count you can't start.\n // `teamUp` is optional and defaults to false. Declare it ONLY on a mode your game actually\n // plays as two teams of two — the portal offers the Team Up toggle for that mode and no other.\n // It is per-mode, not per-game: a four-seat online match can be a team game while your local\n // mode stays free-for-all.\n // `sync` is optional, online-mode-only (\"online_friends\"/\"online_random\"), and defaults to\n // \"realtime\" when omitted — see the multiplayer requirements page for the full tier ladder.\n // `unavailable` is optional, online-mode-only, and meaningless on a \"parallel\"-synced mode —\n // omit it and a relay-backed online mode defaults to { policy: \"forfeit\", timeoutSeconds: 30 }.\n // Declaring it requires network.expectedMessagesPerMinute >= 30 — see \"Player availability\"\n // below the manifest example.\n { \"id\": \"online_friends\", \"players\": [2, 4], \"teamUp\": true, \"sync\": \"turn_based\", \"unavailable\": { \"policy\": \"auto\", \"timeoutSeconds\": 20 } },\n { \"id\": \"online_random\", \"players\": [2, 4], \"playerCounts\": [2, 4], \"sync\": \"parallel\" },\n // For \"vs_ai\" these are TOTAL seats and you fill the ones the human doesn't: 4 means one\n // player against three AI opponents. Declaring [1, 1] means the mode has nothing to choose.\n { \"id\": \"vs_ai\", \"players\": [2, 4], \"playerCounts\": [2, 4] }\n // other ids: \"local_multiplayer\" — a mode you don't declare here is never offered to a\n // player.\n ],\n \"capabilities\": { \"relay\": true, \"wallet\": false, \"saveState\": false, \"spectate\": true, \"ai\": false },\n \"network\": { \"expectedMessagesPerMinute\": 60, \"maxMessageBytes\": 2048 } // required iff relay: true\n}\n```\n\n`capabilities.ai` is optional (omitted means `false`) and says your game can drive a seat with its\nown AI — **not** that you offer \"Play vs AI\" as a mode. Declaring a `vs_ai` mode says the same\nthing implicitly, so you never need both. It matters for exactly two things, each of which hands\nyour game a seat only your AI can play: `online_random` (whose queue seats an AI opponent when\nmatchmaking finds nobody) and `unavailable: { \"policy\": \"auto\" }`. See [`online_random` and `auto`\nneed your game to have AI](/guide/requirements/multiplayer#onlinerandom-and-auto-need-your-game-to-have-ai).\n\n`capabilities.spectate: true` is **required, on every manifest** — spectating is a core portal\nfeature (friend watching, tournament broadcasts, the Live hub), and the validator fails a build\nthat omits it or sets it `false`. It works with or without `relay: true`: a room-based game rides\nits own `Miniant.net` broadcasts, forwarded to watchers automatically; a solo/vs-AI game (no\n`relay` declaration needed) calls `Miniant.spectate.publishState(snapshot)` itself instead — and\nimplementing that publish path is part of complying, not optional polish. See\n[Multiplayer](/guide/requirements/multiplayer) and [SDK](/guide/sdk) for both paths.\n\n`modes[].sync` is optional, one of `\"parallel\" | \"turn_based\" | \"realtime\"`, and meaningful only on\n`online_friends`/`online_random` — omit it and an online mode defaults to `\"realtime\"`. See [Choose\na sync mode](/guide/requirements/multiplayer#choose-a-sync-mode-for-an-online-mode) for the full\ntier ladder and what each value commits your game to.\n\n`modes[].variants` is optional: an array of `{ key, label, params? }` objects — `key` and `label`\nare both required strings, `params` is an opaque `Record<string, unknown>` your game defines and\nthe portal never interprets. Declare it to offer a mode in more than one flavor without minting a\nnew mode id — a \"Blitz\" vs. \"Classic\" ruleset for the same board, say. The portal's mode picker\nshows `label` as a selectable option for that mode; whichever variant the player picks reaches your\ngame as `PlayContext.mode.variant` (that entry's `key`, a plain string) — see [Game](/guide/sdk/game)\nfor the full `PlayContext` shape. **`PlayContext.mode.params` now carries that variant's own\ndeclared `params` object** — the portal resolves it server-side at session start (roomless and\nroom-based sessions alike) and hands it through verbatim, so a game may read `mode.params` directly\ninstead of re-deriving behavior from `mode.variant`'s key string, though the key is still there and\nstill the more stable choice for anything you plan to branch on by name. A mode with no `variants`\ndeclared, a session where the player picked no variant, or a picked variant that itself declared no\n`params`, all hand your game `mode.params: {}` — never `undefined`, and never a crash. A mode with\nno `variants` declared still hands your game `mode.variant: null`.\n\nRules the validator checks: every mode's `players: [min, max]` must have `min <= max` and both\n`>= 1`; every `playerCounts` entry (when declared) must fall within that mode's own `[min, max]`\nrange and contain no duplicates; `capabilities.relay: true` requires a `network` block;\n`capabilities.spectate: true` must be declared (see above); `entry`\nmust be a real file in your build output. (Selling anything requires `capabilities.wallet: true`\nbefore a `store.skus` block is accepted — see the \"Store SKUs\" row above.) The validator also\ncross-checks `sync` against its\nsibling mode declarations — `turn_based`/`realtime` need `capabilities.relay: true`, `parallel`\nneeds a `solo`/`vs_ai` mode to launch against, and `sync` on a non-online mode is rejected outright\n— a shape check spanning multiple modes at once, which the manifest's own per-mode schema can't\nexpress on its own. The local `miniant-validate` CLI ([Publishing your game](/guide/publishing))\nruns the identical check, so you catch it before you ever push a build. Separately, the validator\nrequires that declaring `online_random`, or an `unavailable` policy of `\"auto\"`, means declaring AI\n— `capabilities.ai: true`, or a `vs_ai` mode — see [`online_random` and `auto` need your game to\nhave AI](/guide/requirements/multiplayer#onlinerandom-and-auto-need-your-game-to-have-ai). It also\n*suggests*, without ever requiring, that a game declaring any online mode offer the whole ladder of\n`online_friends`, `online_random`, and `vs_ai` — a warning on your build, never a failure. Declare\nthe modes you actually want; `local_multiplayer` is unaffected either way.\n\n### Player availability (`modes[].unavailable`)\n\n`modes[].unavailable` is optional, one of `{ policy: \"auto\" | \"forfeit\", timeoutSeconds: 10-120 }`,\nand meaningful only on a relay-backed online mode — see [the policy\ncontract](/guide/requirements/multiplayer#when-a-player-becomes-unavailable) for what each policy\nmeans and how a game reports around it. The validator (both the local `miniant-validate` CLI and\nthe server-side build validator, running the identical shared rule) rejects three shapes, quoted\nhere verbatim from the CLI's own output:\n\n- **Declared outside `online_friends`/`online_random`:**\n `\"miniant.json\" mode \"{id}\": unavailable is only meaningful on an online mode (online_friends/online_random); \"{id}\" is not online.`\n- **Declared on a mode whose resolved `sync` is `\"parallel\"`:**\n `\"miniant.json\" mode \"{id}\" declares unavailable but resolves to sync: \"parallel\" — a parallel seat has no peers to monitor. Remove unavailable, or declare sync: \"turn_based\"/\"realtime\" instead.`\n- **Declared without enough declared network headroom:**\n `\"miniant.json\" mode \"{id}\" declares unavailable but network.expectedMessagesPerMinute is {expected} — the disconnect-liveness heartbeat needs headroom above the mode's own declared traffic (relay limits are 3x the declared value, and the heartbeat's beacons alone cost ~12/min per seat). Raise network.expectedMessagesPerMinute to at least 30.`\n\nThat floor (`network.expectedMessagesPerMinute >= 30`) applies the moment *any* mode in the manifest\ndeclares `unavailable` — not per mode, since `network` is a manifest-level block. Omit `unavailable`\nentirely on a relay-backed online mode and it defaults to `{ policy: \"forfeit\", timeoutSeconds: 30\n}` — every online mode published before this field existed already behaves exactly this way, so\nnothing here is a breaking change for an existing game.\n\n### Levels or one continuous run?\n\n`progression` is required, and it is the one manifest field that decides what a **player is\ncharged**. There is deliberately no default: a default would let a game ship with a Resume price\nnobody chose, and silence is not a safe answer to a money question. `miniant-validate` fails a\nmanifest without it.\n\n- **`\"levels\"`** — your game has discrete levels or stages the player advances through and keeps. A\n Resume here is a new sitting for a game that already ended, so it starts a new session and costs\n the usual session fee. The picker leads with **Resume** and demotes **Start over** behind it,\n because nobody replays a level-50 campaign from level 1. Start over is not destructive: the\n portal banks the player's furthest run first and offers a way back to it, so you never have to\n warn them.\n- **`\"continuous\"`** — one unbroken run for as long as the player lasts, with no levels to return\n to (an endless merge or arcade game). A Resume here is **free**: the player is continuing the very\n game they already paid to start, and charging twice for one game would be wrong. The picker keeps\n **Resume** and **Start** as equals, because finishing a run and beginning another is the normal\n loop for this kind of game, not an afterthought.\n\nPick the one that describes how your game actually plays, not the one whose price you prefer — a\nmismatch shows up immediately as a player either paying to continue a single run, or continuing a\nfinished campaign for free.\n\n**If your game is `\"continuous\"`, clear or overwrite your save when a run ends.** The portal never\nclears a solo progression slot on your behalf, so a save left behind by a finished run makes Resume\noffer to continue a game that is already over.\n\n`orientation` decides how the portal's play screen frames your game:\n\n- **`\"any\"` (expected)** — your game gets the whole play area, edge to edge, on every screen:\n upright phone, sideways phone, 4:3 tablet, 16:9 desktop. Your game does the adapting — see\n \"Your game must fit every screen shape\" below.\n- **`\"portrait\"`** — full screen on a portrait-shaped viewport, but on every desktop and landscape\n tablet the portal shows your game in a centered 9:16 column with bars either side.\n- **`\"landscape\"`** — a centered 16:9 box with bars above and below. A phone or tablet held upright\n is not asked to rotate: the portal turns its own play screen a quarter turn, so your game still\n gets a landscape-shaped frame and the player simply turns the handset to meet it. (Most people\n have rotation lock on, and a \"please rotate\" prompt is a dead end for them.) **Nothing about this\n is visible to your game** — it receives an ordinary landscape viewport and must not try to detect\n or compensate for the rotation. A tall, narrow desktop *window* is the one case still asked to\n change, since there is no device to turn.\n\nThe validator warns on `\"portrait\"` and `\"landscape\"` (see the table above). The warning never\nblocks a build, because a few games really are one shape: a vertical shooting cage, a side-scrolling\nracer. If yours is one of them, keep the lock — the reviewer sees the warning at approval.\n\n### Your game must fit every screen shape\n\nDeclare `orientation: \"any\"` and build your layout to adapt. The portal hands you whatever shape the\nplayer's screen is, and every bar the portal would otherwise draw is dead space on a screen the\nplayer chose. Concretely:\n\n- **Background to all four edges.** Color, pattern, or art — not gameplay or UI — reaches every\n edge at every aspect ratio, from a tall phone (~9:19.5) to a wide desktop (~21:9). A solid color\n or a repeating pattern is the most reliable; a bespoke illustration has to survive being cropped\n or extended at both extremes.\n- **The playfield keeps its shape.** A board, table, or court scales *uniformly*, sized by the\n screen's **short side** minus what the HUD needs. Never stretch it to fill the long side.\n- **The HUD moves, it doesn't grow.** On a portrait screen, stack the HUD above and below the\n playfield as a phone game normally does. On a landscape screen (wider than about 5:4), move it\n into the spare space on the left and right, so the playfield can take nearly the full height.\n Keep HUD elements close to the playfield rather than pinned to the far screen edges, and keep text\n at phone size. One modest step up on large screens is fine; scaling text with the board is not.\n- **Seats stay by their pieces.** In a board game, put each player's panel on the side nearest\n their own pieces or colour: opponent beside their back rank, each Ludo player beside their\n corner.\n- **A short screen is still a screen.** A phone held sideways gives you ~390px of height. If a\n vertical stack of four player panels doesn't fit, arrange them two by two instead.\n- **Keep honoring the insets.** Read `context.ui.safeAreaInsets` and keep interactive controls clear\n of them in every layout, especially at the bottom, where the portal floats its chat dock.\n- **Re-measure on every layout change.** Anything you position from measured rects must be\n re-measured when the viewport changes shape, including the avatar anchors you send with\n `Miniant.ui.setAvatarAnchors` (see [SDK → Game](/guide/sdk/game)). A phone rotating mid-game is\n a normal event.\n- **Test at five sizes before you publish:** 390×780 (phone), 844×390 (phone sideways), 768×960\n (tablet upright), 1024×700 (tablet sideways), 1440×810 (desktop). Look for hard edges, a\n stretched playfield, unpainted space, and HUD overlapping the playfield or the insets.\n\n#### Republishing an existing `\"portrait\"` or `\"landscape\"` game\n\nNothing breaks today: a locked game keeps its bars and its build still publishes, with the\nvalidator's warning. At your next republish, make the layout adapt using the rules above, then\nswitch the manifest to `\"any\"`. Usually this is a CSS change: lift fixed `max-width` caps sized for\na phone column, add a landscape rule that moves the HUD beside the playfield, and size the\nplayfield from the short side.\n\n### Text selection and double-tap zoom are off inside your game\n\nSince SDK 2026.08.29 the portal installs two rules into your game's own document, so a tap inside\nyour game behaves like a tap anywhere else in Miniant: **nothing selects text, and a double tap\nnever zooms.** They arrive automatically — the browser SDK applies them when it loads, and the game\nhost injects the same stylesheet into every published HTML document, so a build that never calls the\nSDK (a Unity or prebuilt export) gets them too. There is nothing to add to your game.\n\nWhat this means for you:\n\n- **Your own `<input>`, `<textarea>`, and `contenteditable` fields still work normally.** They are\n exempt, because selection is how a player fixes a typo. A name-entry box behaves exactly as\n before.\n- **If some part of your game genuinely needs selectable text**, set `user-select: text` on that\n element. The portal's rule sits on `html`, so any rule of your own on a specific element wins.\n- **If your game does its own multi-touch or pinch handling, it is unaffected.** The rule only\n drops the browser's double-tap zoom; every touch event your code listens for still fires. A canvas\n that sets its own `touch-action` also keeps it — the portal never overrides an element-level rule.\n- **Pinch zoom itself is not blocked**, in your game or anywhere else. The portal caps it at 200%,\n which is what accessibility rules require, and that cap lives on the portal page, not in your\n document.\n\n## What you must not build\n\nThe one sentence that should settle any \"should my game do X\" question: **if it's not gameplay —\nrendering, rules, input — the portal already owns it, and you should not build your own.**\nConcretely:\n\n1. **No mode selector, friend list, matchmaking, lobby, or invite UI.** Read `context.mode` and\n `context.participants` from `init()` and start playing. A mode you didn't declare in\n `miniant.json` is never offered to a player in the first place — there's nothing for a\n game-side selector to add.\n2. **No store UI, no prices you invent, and no external payment SDK, ever.**\n `Miniant.wallet.purchase()` exists, but it only ever buys a manifest-declared, reviewed SKU at\n the portal's own registry price — your game supplies a `skuId`, never an amount, and the portal\n renders its own confirm dialog, never yours. There is still no call that lets your code grant\n currency or set a price. Wiring up your own Stripe/Razorpay/PayPal/Paddle/etc. checkout is a\n platform-policy violation, flagged automatically at build validation (see the \"No external\n payment-SDK signals\" check above) and enforced the same way as any other banned behavior.\n3. **No WebSocket (or any other transport) of your own for gameplay traffic**, unless you're\n declaring `authorityModel: \"developer_server_validates\"` (tier 2) and running your own\n validating server. Tier 0/1 games must use `Miniant.net` — the platform can only apply its\n rules-blind anti-cheat checks to traffic that actually passes through it.\n4. **Never navigate the top window, open the portal in a frame, or read the parent document.**\n The iframe sandbox blocks all of this structurally; attempting it is logged as a protocol\n violation, visible to Miniant staff.\n5. **Never report a result more than once per session.** Call `reportResult` exactly once;\n Miniant rejects a duplicate with `SESSION_ALREADY_SUBMITTED`.\n6. **No loading screen of your own — the portal's splash is the only one a player ever sees.**\n No loading bar, percentage, spinner, \"downloading assets\" message, or branded boot cover\n between the portal's splash retiring and your game being playable. See [No game-owned loading\n UI](#no-game-owned-loading-ui) below for the full rule and what to do if your game genuinely\n has a lot to load.\n\nIf your game needs something not covered here — leaderboards, tournaments, richer presence — it\nisn't available yet at any tier; ask before building a workaround.\n\n## No game-owned loading UI\n\nThe portal shows its own branded splash over your game from the moment the play shell mounts\nuntil you call `Miniant.ready()` (see [`Miniant.ready()`](/guide/sdk/game#miniantready)). That\nsplash is the **only** loading screen a player is meant to see. Never show your own — no loading\nbar, no percentage, no spinner, no \"downloading assets\" message, and no branded boot/splash cover\nof your own — anywhere between the portal's splash retiring and your first interactive screen\nappearing. Your game must hand off directly from the portal's splash to something the player can\nactually play.\n\n**Call `Miniant.ready()` at your first interactive screen, not your first frame.** A menu or a\nstart screen is fine to call it from; a loading indicator of any kind is not — see\n[`Miniant.ready()`](/guide/sdk/game#miniantready) for the full contract, including the ordering\nrule (hide your own cover, let a frame paint, *then* call `ready()`).\n\nThis is enforced at manual review, not by the automated validator — there's no reliable way to\ninspect a build archive and tell a boot cover apart from ordinary game art. The reviewer's smoke\ntest (see [Publishing](/guide/publishing#what-the-reviewer-sees)) already screenshots what a\nplayer sees while the portal waits on `Miniant.ready()`; a build whose screenshot shows a\ngame-owned loading bar, or whose console makes clear one flashed past before the game appeared,\nfails review on this even with every automated check green.\n\n**If your game has a large asset download, report progress with `Miniant.setLoadProgress()`**\nwhile it loads (see\n[`Miniant.setLoadProgress()`](/guide/sdk/game#miniantsetloadprogressfraction)) — this doesn't\nexcuse the rule above, it's what makes a long load survivable under it. Reporting real progress\nturns the portal's own splash into a determinate bar and pushes back its give-up failsafe (12\nseconds by default, up to a hard 30-second ceiling), so a game that's genuinely still downloading\nisn't abandoned mid-load. It's the only lever you have for a slow load — you still can't cover the\nwait with a loading screen of your own.\n\n## Not enforced today\n\nTo be direct about where the line actually is: the validator does **not** currently check any of\nthe following. These are things worth being mindful of for your own players' sake — guidance, not\nrequirements the platform enforces.\n\n- **No file-count limit.** There's no cap on how many individual files your build output\n contains.\n- **No initial-download-size rule.** Nothing measures or limits how much a player has to download\n before your game shows a first frame, beyond the overall 300MB archive cap above.\n- **No time-to-gameplay measurement.** Nothing times how long a player waits between landing on\n your game and being able to play. The reviewer's 30-second `Miniant.ready()` check (see\n [Publishing](/guide/publishing)) is about the manual review step, not an enforced player-facing\n limit. Calling [`Miniant.setLoadProgress()`](/guide/sdk/game#miniantsetloadprogressfraction)\n while you load pushes the splash's 12-second idle failsafe back out, up to that same hard\n 30-second ceiling, so a genuinely slow but honest load isn't the thing that costs you here — a\n loading screen of your own is (see\n [No game-owned loading UI](/guide/requirements/technical#no-game-owned-loading-ui) above).\n\nA game that's slow to start or ships an unnecessarily large download won't fail validation for it\n— but it's still a worse experience for your players, and a reviewer forming an impression of your\ngame during manual review. Keep an eye on it even though nothing here will stop you.\n"},{"slug":"requirements/gameplay","title":"Gameplay","section":"Requirements","agentSummary":"What a human reviewer checks by hand when playing your game: readability, refresh-rate consistency, controls, language, originality, and age-appropriateness.","checklist":[{"id":"req-gameplay.readable-content","text":"Make text and UI legible at normal device pixel ratio, on both desktop and mobile sizes.","verify":"manual"},{"id":"req-gameplay.consistent-refresh-rates","text":"Use delta-time or fixed-timestep simulation so gameplay behaves the same at 60Hz, 144Hz, and 165Hz — never tie physics to raw frame count.","verify":"manual"},{"id":"req-gameplay.english-required","text":"Ship English as a required language, and make sure any additional translation is accurate.","verify":"manual"},{"id":"req-gameplay.locale-for-language-choice","text":"Use player.locale from PlayContext to choose a language, falling back to English — never guess a language another way.","verify":"manual"},{"id":"req-gameplay.intuitive-controls-both-inputs","text":"Make controls intuitive on both desktop (mouse and keyboard) and mobile (touch), since the portal serves your game to both.","verify":"manual"},{"id":"req-gameplay.no-crashes-or-errors","text":"Load quickly and play without visible errors, hangs, or crashes.","verify":"manual"},{"id":"req-gameplay.original-content-only","text":"Use only art, music, and copy you own or have properly licensed.","verify":"manual"},{"id":"req-gameplay.no-own-fullscreen","text":"Don't build your own fullscreen button or call the Fullscreen API — the portal's play shell owns fullscreen.","verify":"manual"},{"id":"req-gameplay.no-cross-promotion","text":"Don't advertise other portals or drive players off-site as a primary call to action from inside the game.","verify":"manual"},{"id":"req-gameplay.age-appropriate","text":"Keep content suitable for a general audience of teenagers and up.","verify":"manual"}],"body":"# Gameplay\n\nThere is no automated gameplay check. A human reviewer plays your game and applies the\nexpectations on this page by hand. The bar is not \"perfect\" — it's \"no broken or\ninappropriate experiences.\" Reviewers are looking for reasons a normal player would bounce\noff your game or feel misled by it, not nitpicking polish.\n\n## Readable content\n\nText and UI must be legible at normal device pixel ratio, on both desktop and mobile sizes.\nIf a label, score, or instruction is only readable on a large monitor, it fails on a phone —\ncheck both before you submit.\n\n## Consistent behavior across refresh rates\n\nYour simulation must behave the same on a 60Hz display, a 144Hz display, and a 165Hz display.\nThe usual bug is tying physics or timing to raw frame count (`update()` called once per\n`requestAnimationFrame`) instead of elapsed time — that makes your game run faster on a\nhigh-refresh-rate monitor. Use a delta-time or fixed-timestep simulation loop instead.\n\n## Language\n\nEnglish is required. If you ship translations on top of it, they must be accurate — a\nmistranslated menu is worse than no translation.\n\nThe portal hands you `player.locale` inside `PlayContext` (see\n[Game](/guide/sdk/game)). Use it to pick a language for players who have one set, and\nfall back to English for everyone else — never guess a language another way.\n\n## Intuitive controls\n\nControls must be intuitive on both desktop and mobile — mouse and keyboard on desktop, touch on\nmobile — because the portal serves your game to both. A control scheme that only makes sense with\na keyboard will fail review on a touch device. There is no device-targeting field in the manifest:\n`orientation` (`\"any\"` — expected — `\"landscape\"`, `\"portrait\"`) is the only related declaration,\nand it does not exempt you from touch support. See [Technical → Your game must fit every screen\nshape](/guide/requirements/technical#your-game-must-fit-every-screen-shape).\n\n## Smooth performance\n\nThe game should load quickly and play without errors or crashes. A game that throws visible\nerrors, hangs on load, or crashes mid-session doesn't meet the bar, regardless of how good it\nlooks otherwise.\n\n## Originality\n\nNames, assets, and content must be your own or properly licensed. Don't ship someone else's\nart, music, or copy without the right to use it.\n\n## Fullscreen belongs to the portal\n\nThe play shell owns fullscreen. Don't build your own fullscreen button or call the\nFullscreen API yourself — it fights the portal's shell, which already decides when and how\nyour game goes fullscreen. See\n[`/guide/requirements/technical`](/guide/requirements/technical) for the full list of things\nthe portal owns that your game must not duplicate.\n\n## No cross-promotion\n\nDon't advertise other portals or drive players off-site from inside the game. A link to your\nown site or community is acceptable in a menu, but never as a primary call to action —\nplayers came here to play, not to be funneled elsewhere.\n\n## Age-appropriate\n\nContent must suit a general audience of teenagers and up. We don't have a formal\ncontent-rating field yet, so this is judged by the reviewer's own read of the content — when\nin doubt, tone it down.\n"},{"slug":"requirements/advertisement","title":"Advertisement","section":"Requirements","agentSummary":"What ad surfaces are and are not allowed: the portal's own ad layer only, no third-party SDKs, no banners, and rewarded ads never grant Tickets.","checklist":[{"id":"req-ads.no-banner-placements","text":"Don't build or reserve space for a banner ad placement inside your game.","verify":"manual"}],"body":"# Advertisement\n\nThe portal runs the ads — your game never does. There is one ad surface, and it lives in the\nportal's play shell, not in your iframe. Nothing interrupts a player on the way into a game: the\nportal shows its own interstitial only when a player chooses a rematch or waits for an online\nopponent, and offers its own opt-in rewarded ads (double a round's XP, waive an unaffordable\nin-game spend, spectate a friend's match). Your game may additionally *ask* for an interstitial at\na level end inside a session, and may optionally *ask* for a rewarded ad on its results screen.\nEvery one of those decisions is the portal's, made against its own frequency and first-session\nrules. See [Ads](/guide/sdk/video-ads) for the `Miniant.ads.*` API.\n\n## Third-party ad networks are not permitted\n\nDon't embed an external ad SDK or ad iframe in a game you submit. This applies to any\nnetwork — banner ads, interstitials, rewarded video, anything. A game that ships with one\nwill be rejected in review. The portal's own ad layer is the only one, and it is what the\nrevenue share (when enabled for your account) is computed from.\n\n## No banners, ever\n\nThere is no banner placement inside a game and none in the portal around it. Don't reserve\nspace for one.\n\n## What a rewarded ad can and cannot do\n\nThe portal offers a \"double this round's XP\" rewarded ad over your results screen on its own;\ncalling `Miniant.ads.requestRewarded()` yourself is optional and only moves that offer into your\nown UI. Either way it resolves `\"rewarded\"` when the player finished the ad, and doubling is the\nonly thing it does. It never grants Tickets to the player — Tickets come only from the portal's\nown flows, so a game cannot mint currency by showing ads.\n\n## Monetization beyond ads is the in-game purchase system\n\nTo sell things, declare SKUs in your manifest; players buy them through the portal's own\nwallet, and your game reads back what they own as entitlements. You never set a price, render\na store UI, or move currency directly — the portal does all of that.\n\nSee [In-game purchases](/guide/sdk/purchases) for the `Miniant.wallet.*` API and\n[`/guide/requirements/technical`](/guide/requirements/technical) for the manifest's\n`store.skus` block.\n"},{"slug":"requirements/account-integration","title":"Account integration","section":"Requirements","agentSummary":"How your game reads player identity and saved progress through the SDK: no login UI, save-at-checkpoint-start granularity, and progress never buys rank.","checklist":[{"id":"req-account.no-own-login","text":"Never build a login screen, ask who the player is, or add a third-party login inside your game.","verify":"manual"},{"id":"req-account.render-displayname-live","text":"Render player.displayName as given and re-read it from init() every session — never derive or persist your own copy.","verify":"manual"},{"id":"req-account.countryflag-no-inference","text":"Never infer a player's country yourself, including by IP geolocation — render countryFlag only if present, and treat null as no country shared.","verify":"manual"},{"id":"req-account.no-blocking-guests-from-wallet","text":"Never block a guest from playing because they don't have a wallet.","verify":"manual"},{"id":"req-account.no-custom-persistence-layer","text":"Never build your own persistence layer on top of localStorage to route around calling state.save() and state.load().","verify":"manual"},{"id":"req-account.save-load-on-start","text":"If capabilities.saveState is declared, call state.load() on every session start.","verify":"validator:save.load-on-start"},{"id":"req-account.save-at-checkpoint-start","text":"Snapshot progress, score, rewards and position at level or checkpoint start, never at exit. Only values that can work solely against the player (lives lost, a cooldown, a consumed item) save live.","verify":"manual"},{"id":"req-account.no-savestate-without-checkpoints","text":"Don't declare capabilities.saveState for a game with no level or checkpoint structure and no natural resume point.","verify":"manual"},{"id":"req-account.imported-progress-never-ranks","text":"Never let saved or imported progress confer Tickets, rank, leaderboard position, or tournament eligibility.","verify":"manual"}],"body":"# Account integration\n\nThe portal owns player identity. Your game never builds a login screen, never asks the\nplayer who they are, and never adds a third-party login inside the game — there is nothing\nfor a game-side identity flow to add, and one will read as broken or suspicious to players.\n\n## Where identity comes from\n\n`Miniant.init()` resolves with a `PlayContext` whose `player` field is:\n\n```ts\n{ id, displayName, avatarUrl, isGuest, locale, seat, countryFlag }\n```\n\nEvery entry in `participants` (the other seats in the session) carries the same shape. See\n[Game](/guide/sdk/game) for the full `PlayContext` reference.\n\n## `displayName`\n\nRender it as-is. Never derive a name yourself, and never persist it in your own saved state\n— players can change their display name at any time, so a copy you saved last session can go\nstale. Re-read it from `init()` every session instead.\n\n## `countryFlag`\n\nAn ISO 3166-1 alpha-2 country code, or `null`. `null` is not an error — it means the player\nhasn't shared a country. Render a flag only if your game already supports flag rendering, and\nnever infer a country another way, including IP geolocation. That's a consent decision that\nbelongs to the player, made in their portal profile.\n\n## `isGuest`\n\nTells you the player is unregistered. Use it to know which parts of the platform are\navailable to them — see \"Saved progress\" below — not to gate whether they can play at all.\n\n## Saved progress\n\n`Miniant.state.save(blob, meta?)` / `Miniant.state.load()` persist up to 64KB of JSON, gated on\n`capabilities.saveState` in your manifest — one slot per `(this player, your game, the session's\nmode)`, resolved automatically from the session; you never pass a mode yourself. For a signed-in\nplayer it's stored server-side and follows them to any device. For a guest it's stored locally, in\nthat one browser — see below, and [Data](/guide/sdk/data) for the full API, including the portal's\nown save-request push and exactly how loading behaves for each case.\n\n### Save granularity: checkpoint-start, not exit-time\n\n**This is a publishing requirement, checked at manual review — not a recommendation.** Games save\nat the **start** of a level or checkpoint, never at the moment the player happens to leave. A\nconformant `save()` call persists a snapshot taken when the level/checkpoint began: score, lives,\nand any other run-state in the blob must reflect that starting point, not the player's live\nmid-level progress. A player who quits partway through a level returns, next session, to the\n**beginning** of that level — anything gained since it started (points, resources, position) does\nnot carry over. This is deliberate anti-abuse, not an accident: it closes the exploit where a\nplayer grinds half a level for score, quits before anything can go wrong, and banks the gain\nanyway. See [Data](/guide/sdk/data) for exactly how to build this snapshot and what to pass as\n`meta.label`.\n\n**The one exception: values that can only work against the player.** Progress, score, rewards and\nposition snapshot at level START. Values that can only work against the player — lives lost, a\nlockout or cooldown timestamp, a consumed paid item — may be saved live, on every change. The test:\nif a player could gain from quitting and resuming at that moment, it must snapshot.\n\nA game with no level or checkpoint structure — a single continuous match with no natural resume\npoint, such as an offline board-game match — should not declare `saveState` at all. Ending the\nmatch when the player leaves is correct for that shape of game, not a shortcoming to work around.\n\nOnline multiplayer is unaffected: leaving a room-based match ends only the leaver's own session;\nwhat happens to their seat is governed by the mode's availability policy (`modes[].unavailable` —\nsee [Multiplayer requirements](/guide/requirements/multiplayer#when-a-player-becomes-unavailable)'s\n\"When a player becomes unavailable\" section), regardless of whether your game declares `saveState`.\n\nThe platform cannot enforce this in code — the saved blob is opaque JSON your game controls — so a\nreviewer checks it by hand during manual review: quitting mid-level and resuming should visibly\nreturn to the level's start, not the player's exact spot, with equal-or-fewer lives (or other\nagainst-the-player values) than the player had when they quit — never more.\n\n## The wallet is registered-only; saved progress is same-browser for a guest\n\nThe wallet has no guest fallback. Guests do not get a wallet at all — that's part of what converts\na guest into a registered player, and it's an intentional product decision, not a gap to work\naround.\n\nSaved progress used to work the same way. It no longer does: a guest's `state.save()` now persists\nto that browser, transparently, so a guest who closes the tab and comes back can resume — and the\nmoment they sign in, that browser's saved progress uploads onto the new account automatically. This\nis best-effort and entirely outside your game's control: a network hiccup just leaves the local copy\nin place to retry on the player's next sign-in, and if the account already holds a newer save for\nthat slot, the account's copy wins and the local one is cleared without overwriting it. What's still\nregistered-only is *carrying progress to another device before signing in* — a guest playing across\ntwo browsers has two independent local saves until one of them signs in.\n\nYour game must still degrade gracefully for a guest — but it doesn't need to know or care which\nbackend `state.save()`/`state.load()` are using underneath it:\n\n- Never block a guest from playing because they don't have a wallet.\n- **Never build your own persistence layer on top of `localStorage` (or anything else) to route\n around calling `state.save()`.** The portal's own slot — server-side for a signed-in player,\n same-browser for a guest — is the source of truth either way; a competing save system undermines\n the guest-conversion pitch and the rule below about what saved progress can and can't buy a\n player. Game-side `localStorage` is fine only as a short-lived cache (for example, keeping the\n last frame around so a mid-session reload feels instant) — never as where your actual saved\n progress lives.\n\n`Miniant.state.save()` resolves successfully for both a guest and a signed-in player — your game\nnever needs to branch on `isGuest` before calling it. `load()` returns whatever was last saved for\nthis player/game/mode in whichever backend is currently in effect, or `null` if nothing was.\n\n## Imported progress never buys rank\n\nWhatever your game persists through `state.save()` — level, checkpoints, unlocks — is progress, and\nonly ever progress. It never confers Tickets, rank, a leaderboard position, or tournament\neligibility, no matter what the save data claims or what state the player says they reached on any\nother platform. Those only ever come from a portal-witnessed play session reported through\n`reportResult()`/`reportProgress()` (see [Scores and results](/guide/sdk/scores)) — never from a\nsave blob, imported or otherwise.\n"},{"slug":"requirements/multiplayer","title":"Multiplayer","section":"Requirements","agentSummary":"Requirements for multiplayer and spectating games: modes, sync, and relay in miniant.json, the message-rate budget, the unavailable-player policy, and chat moderation.","checklist":[{"id":"req-mp.declare-modes-and-players-range","text":"Declare every supported mode in miniant.json's modes array with a players min-max range — an undeclared mode is never offered.","verify":"validator:manifest.mode-rules"},{"id":"req-mp.ai-declared-for-ai-backed-modes","text":"Declare AI — capabilities.ai true, or a vs_ai mode — if your manifest declares online_random or an unavailable policy of auto.","verify":"validator:manifest.mode-rules"},{"id":"req-mp.online-ladder-complete","text":"Consider declaring online_friends, online_random, and vs_ai together if your manifest declares any online mode — suggested, never required.","verify":"validator:modes.online-ladder"},{"id":"req-mp.turn-clock-and-auto-toggle","text":"Give every turn a deadline that auto-plays on expiry, plus an Auto toggle on the local player's own seat — theirs alone, cancelled by any tap. The unavailable policy bounds absence, not idleness.","verify":"manual"},{"id":"req-mp.hidden-match-real-pace","text":"Keep a hidden page's match at its real pace — every move still takes its think time plus its full animation. Never skip or shorten animation time off screen; spectators watch.","verify":"manual"},{"id":"req-mp.hidden-page-silent","text":"Make no sound while the page is hidden or the portal has paused it, and never resume a suspended AudioContext from inside a sound call.","verify":"manual"},{"id":"req-mp.teamup-declared-and-implemented","text":"Declare teamUp per mode only where your game actually seats two teams of two, and read mode.teamUp plus each participant's team before declaring it.","verify":"manual"},{"id":"req-mp.sync-requires-relay","text":"Declare capabilities.relay true, with a network block, for any online mode whose sync is turn_based or realtime.","verify":"validator:manifest.mode-rules"},{"id":"req-mp.parallel-requires-base-mode","text":"Declare a solo or vs_ai mode in the same manifest for any mode using sync parallel.","verify":"validator:manifest.mode-rules"},{"id":"req-mp.network-block-with-relay","text":"Declare a network block with expectedMessagesPerMinute and maxMessageBytes whenever capabilities.relay is true.","verify":"validator:manifest.mode-rules"},{"id":"req-mp.unavailable-requires-network-headroom","text":"Raise network.expectedMessagesPerMinute to at least 30 before declaring unavailable on any mode.","verify":"validator:manifest.mode-rules"},{"id":"req-mp.forfeit-win-flag","text":"Report a forfeit win with Miniant.reportResult({ outcome: \"win\", forfeit: true, durationMs }).","verify":"manual"},{"id":"req-mp.never-reveal-simulated-seat","text":"Never reveal a simulated, bot-filled online_random seat as AI to the player — no label or different visual treatment.","verify":"manual"},{"id":"req-mp.pass-and-play-no-relay","text":"Never use Miniant.net in local_multiplayer — context.room is null and there is no relay for Pass and Play.","verify":"manual"},{"id":"req-mp.broadcast-action-not-just-outcome","text":"Broadcast the acting player's move the moment the input lands, so opponents and spectators watch it happen — never only the committed outcome or end-of-turn snapshot.","verify":"manual"},{"id":"req-mp.declare-spectate-online","text":"Declare capabilities.spectate: true on every manifest — spectating is a core portal feature; the validator fails a build without it.","verify":"validator:manifest.spectate-required"},{"id":"req-mp.standin-for-roomless-spectate","text":"Declare capabilities.standin: true (and ship the standin/ Worker) when the manifest has capabilities.spectate and a vs_ai or online_random mode — the validator fails a build without it.","verify":"validator:standin.required"},{"id":"req-mp.tag-snapshot-true-for-spectators","text":"Tag every complete-state relay broadcast with {snapshot: true} — a joining spectator is served this cached copy directly, never by reaching your game code or via syncRequest.","verify":"manual"},{"id":"req-mp.pass-and-play-real-names","text":"Render each Pass and Play participant's real displayName in seat order, never a placeholder name you invent yourself.","verify":"manual"},{"id":"req-mp.player-is-the-account-not-a-seat-label","text":"Never overwrite a seat's name with context.player.displayName — player identifies the signed-in account, participants[].displayName labels the seats.","verify":"manual"},{"id":"req-mp.chat-responsibility","text":"If your game exposes chat, emotes, or player-to-player text, your game is responsible for moderating it — the platform only moderates reactively via reports.","verify":"manual"}],"body":"# Multiplayer\n\nThis page covers what's required of your game if it supports multiplayer or spectating — declaring\nthe right things in `miniant.json`, staying within the relay's message-rate budget, and being\nhonest with yourself about what the platform does (and doesn't) moderate. For the actual API\nsurface — `Miniant.net.*`, `Miniant.spectate.*`, the full `PlayContext` shape — see\n[SDK](/guide/sdk); this page stays at the requirements level and cross-links there rather than\nrepeating it.\n\n## Declare your modes and lobby sizes\n\nEvery mode your game supports must be declared in `miniant.json`'s `modes` array, each with a\n`players: [min, max]` range — this is your lobby-size declaration. A mode you don't declare is\nnever offered to a player, full stop. Valid mode ids are `solo`, `vs_ai`, `local_multiplayer`,\n`online_friends`, and `online_random`. The validator requires `min <= max` and both `>= 1` for\nevery mode. See [Technical](/guide/requirements/technical) for the full manifest schema.\n\n## `online_random` and `auto` need your game to have AI\n\nTwo declarations hand your game a seat that only your own AI can play, so both require you to say\nthe AI exists:\n\n- **`online_random`.** Its matchmaking queue doesn't fail when it finds nobody — it expires, and the\n portal seats an AI opponent so the player gets a game instead of a dead end. That fallback always\n happens; your game has to be able to play it.\n- **`unavailable: { \"policy\": \"auto\" }`.** \"Auto\" means your AI takes over a seat that has gone\n silent mid-match. Nothing on our side can play those turns for you.\n\nSay it either way — they mean the same thing to the validator:\n\n```jsonc\n// Either: declare a vs_ai mode, and players get a \"Play vs AI\" card in the picker.\n\"modes\": [\n { \"id\": \"online_friends\", \"players\": [2, 4] },\n { \"id\": \"online_random\", \"players\": [2, 4] },\n { \"id\": \"vs_ai\", \"players\": [2, 4] }\n]\n\n// Or: declare the capability alone, and no Vs AI card is ever shown. Use this when your game\n// has AI internally but you don't want to sell it as its own mode.\n\"modes\": [\n { \"id\": \"online_friends\", \"players\": [2, 4] },\n { \"id\": \"online_random\", \"players\": [2, 4] }\n],\n\"capabilities\": { \"relay\": true, \"wallet\": false, \"saveState\": false, \"ai\": true }\n```\n\nA `vs_ai` mode counts whether or not it's `enabled` — `enabled` controls what players are offered,\nand this is about whether the code exists. If you declare neither, the validator fails the build and\nnames which of the two declarations tripped it.\n\nNothing else needs AI. `online_friends` on its own is a room your invitees fill; declare it without\nAI freely.\n\n## The full online ladder is suggested, not required\n\nIf your `modes` array declares any online mode, we'll *suggest* declaring all three of\n`online_friends`, `online_random`, and `vs_ai` — a player who finds friend-play can then find\nmatchmaking and an AI game on the same title, rather than a menu that changes shape from game to\ngame. That's a suggestion and nothing more: an incomplete ladder shows up as a **warning** on your\nbuild and publishes anyway.\n\nThis used to be a hard rule, and it isn't any more. It was really enforcing the AI requirement above\n— `vs_ai` was the only way to prove a game had AI — and it charged a Vs AI card to every developer\nwho just wanted online play. `capabilities.ai` proves that directly now, so declare the modes you\nactually want:\n\n```jsonc\n// Fine — friend rooms only, no matchmaking, no AI needed. Warns about the ladder; publishes.\n\"modes\": [{ \"id\": \"online_friends\", \"players\": [2, 4] }]\n\n// Fine — online play with no Vs AI card, the AI declared where it's actually needed.\n\"modes\": [\n { \"id\": \"online_friends\", \"players\": [2, 4] },\n { \"id\": \"online_random\", \"players\": [2, 4] }\n],\n\"capabilities\": { \"relay\": true, \"wallet\": false, \"saveState\": false, \"ai\": true }\n```\n\n`local_multiplayer` (Pass & Play) was never part of the ladder and is unaffected. The suggestion\ntriggers on **declaring an online mode**, not on \"having any mode that seats 2+ players\": a\ncouch-only game that declares only `local_multiplayer` is never nagged about netcode just because\nmore than one person can sit at it. A solo-only game, or a game with only `solo`/`vs_ai`, is\nuntouched too.\n\nA relay-backed online mode (sync `\"turn_based\"`/`\"realtime\"`, the default) still needs\n`\"capabilities\": { \"relay\": true }` and a `\"network\"` block — see \"capabilities.relay and the\nnetwork block\" below.\n\n## Declare Team Up if your game has it\n\nA mode played as two teams of two must say so: `{\"id\": \"online_friends\", \"players\": [2, 4],\n\"teamUp\": true}`. Optional, defaults to `false`, and declared **per mode** — a game may run a\nfour-seat online match as teams while its local mode stays free-for-all.\n\nThe portal offers the Team Up toggle for exactly the modes that declare it. It used to infer teams\nfrom the seat count instead, which meant any mode reaching four seats grew a toggle whether or not\nthe game understood `mode.teamUp` — a four-player free-for-all game was offered teams it had no\nhandling for. Seat count says how many can play; it never says how they are grouped, and only your\ngame knows the difference.\n\nDeclaring it commits you to implementing it: read `mode.teamUp`, read each participant's `team`,\nand seat partners accordingly (see [Game](/guide/sdk/game#modeteamup--two-teams-of-two)). A declaration\nwithout that handling is worse than not declaring it, because players will be offered a mode your\ngame then ignores.\n\n## Choose a sync mode for an online mode\n\nEvery `online_friends`/`online_random` mode picks one of three ways to keep its seats in sync,\ndeclared per mode as `sync` ([manifest reference](/guide/requirements/technical)):\n\n| `sync` | What your game ships | What the portal provides |\n|---|---|---|\n| `\"parallel\"` | Zero netcode — your ordinary `solo`/`vs_ai` build, unmodified. | Runs each seat's session with the *same seed*, provides the lobby/invites/chat, and shows a scoreboard built from each player's own `reportResult` score once they report. |\n| `\"turn_based\"` | [`Miniant.createTurnKit`](/guide/sdk/turn-kit) — seat rotation, full-snapshot broadcast, and reconnect recovery, so your game only writes rules and rendering. | The relay (`capabilities.relay: true` plus a `network` block, same as any relay game — see below). |\n| `\"realtime\"` | Full custom netcode over [`Miniant.net`](/guide/sdk/multiplayer) — you own message shape, pacing, and reconciliation yourself. | The relay, same as `turn_based`. **This is the default** when a mode omits `sync` — every online mode published before this field existed already behaves this way. |\n\n`sync` is optional and per mode — declare it only where the default (`\"realtime\"`) isn't what you\nwant. Rules the validator checks:\n\n- **`turn_based`/`realtime` require `capabilities.relay: true`.** Declare the relay, or declare\n `sync: \"parallel\"` on that mode explicitly instead.\n- **`parallel` requires a `solo` or `vs_ai` mode in the same manifest** — that's the base mode a\n parallel room actually launches (`solo` is preferred when both exist, so a friend match doesn't\n share the table with your `vs_ai` bots for no reason).\n- **`sync` is meaningless outside `online_friends`/`online_random`** — declaring it on any other\n mode is a validation error.\n- **`teamUp` and `sync: \"parallel\"` don't mix.** A parallel room runs each seat's own independent\n `solo`/`vs_ai` session — there's nothing for teams to mean there. Declaring both is rejected when\n the room is created, not at publish time.\n\n**Parallel saves don't collide with your solo campaign.** The portal stores a `sync: \"parallel\"`\nmatch's save in its own slot, separate from the player's ordinary `solo` progression, automatically\n— your game does nothing differently for it.\n\n## Broadcast the action, not just the outcome\n\nWhatever sync mode you chose, the other seats must be able to *watch a move happen* — not deduce\nit from the board changing. The failure pattern is always the same: the acting player's client\nanimates the throw, flick, or kick locally, and the only thing that ever crosses the relay is the\nresolved outcome — a committed snapshot, a final velocity, a new score. On every other screen the\nmatch is a slideshow: nothing moves, then the result appears.\n\nTwo rules prevent it:\n\n- **Send the action the moment the input lands** — the flick vector as the finger lifts, the kick\n as the run-up starts, the piece as it's picked up — and have every other client animate or\n replay from that message. Never wait for your own animation or physics to finish before the\n first send: whatever time your local presentation takes, every watcher inherits it on top of\n network latency, and until then they see nothing at all.\n- **The authoritative outcome is a second, separate message** — in `turn_based`, the Turn Kit's\n own snapshot. Peers reconcile to it after their replay settles; it is the correctness backstop,\n never the first sign of motion.\n\nThis is fully compatible with the Turn Kit — the kit only claims its own frame kinds, and\n[Show the action live](/guide/sdk/turn-kit) documents the mechanics. It is also what\n`capabilities.spectate` quietly depends on: a spectator receives exactly the frames the seats\nexchange, so a game that only ever transmits outcomes gives its audience a scoreboard, not a\nmatch — see [Spectating](/guide/sdk/spectating).\n\n## `capabilities.relay` and the `network` block\n\nIf your game exchanges gameplay messages between seats over `Miniant.net` (a `turn_based` or\n`realtime` mode — see \"Choose a sync mode\" above), set\n`\"capabilities\": { \"relay\": true }` in your manifest — and when you do, you must also declare a\n`network` block:\n\n```jsonc\n{\n \"capabilities\": { \"relay\": true },\n \"network\": { \"expectedMessagesPerMinute\": 60, \"maxMessageBytes\": 2048 }\n}\n```\n\nThe validator rejects `relay: true` without a matching `network` block. `expectedMessagesPerMinute`\nisn't just documentation — it sets your game's actual rate ceiling. The relay enforces a **hard\ncap of 3× your declared rate**: sustained traffic above that ceiling gets you a `throttled` event\nfirst, then a disconnect on continued abuse. A room whose aggregate traffic sustains at or above\nthat same ceiling also feeds the platform's anti-cheat engine as a `RELAY_RATE` signal — declare a\nrealistic rate for your game rather than a padded one, since padding it just raises your own\ngame's ceiling for what the platform treats as suspicious.\n\nDeclare `expectedMessagesPerMinute` honestly and keep your traffic well under it in normal play.\nThe relay's cost envelope is why fast-action, high-frequency games are a poor fit for\n`Miniant.net` today — turn-based and low-rate games (chess, cards, quiz-style) are the target\nuntil platform capacity for higher-frequency traffic is available.\n\nYour own `{snapshot: true}`-tagged broadcasts (turn-change broadcasts, `checkpoint()` calls, and\nthe ones you send answering a reconnecting peer's `syncRequest` — see \"Spectating\" below) are real\nsends from your own seat, so budget them into your declared `expectedMessagesPerMinute` the same as\nany other message you send. Fan-out to spectators themselves is free and never counts against your\nbudget or feeds the `RELAY_RATE` signal.\n\n## When a player becomes unavailable\n\nA seat going quiet mid-match — screen off, the tab backgrounded, a network drop, or the player\nswitching apps — is normal, not an error state your game needs to treat specially. For a\nrelay-backed online mode (`turn_based`/`realtime` — see \"Choose a sync mode\" above), the portal\nresolves a per-mode **availability policy** and hands it to your game as `context.mode.unavailable`;\nyour game reacts to it (directly, or for free via [Turn Kit](/guide/sdk/turn-kit)). All four of\nthose triggers are the same event to the platform — there is no liveness signal from a seat for\n`timeoutSeconds`, full stop, whatever the underlying cause.\n\n**The portal resolves and sends this policy on every `turn_based`/`realtime` seat, whether or not\nyour manifest declares `unavailable`** — an undeclared mode still gets `context.mode.unavailable`\npopulated with the implicit default (`{ policy: \"forfeit\", timeoutSeconds: 30 }`, see below).\nImplementing the detection/reporting recipe (directly, or via Turn Kit) is **required** the moment\nyour manifest declares `unavailable` on the mode, and **recommended** otherwise: a game that never\nimplements it and never declares the field simply leaves an absent player's seat stalled until the\nother players give up and leave — the platform's own room sweep eventually closes an abandoned room\nregardless, so nothing hangs forever, but nobody sees the grace/auto-pilot/forfeit experience this\nsection describes.\n\n### The policy contract\n\nDeclared per online mode in `miniant.json`:\n\n```jsonc\n{ \"id\": \"online_friends\", \"players\": [2, 4], \"unavailable\": { \"policy\": \"auto\", \"timeoutSeconds\": 20 } }\n```\n\n- **`policy`** — `\"auto\"` or `\"forfeit\"`.\n- **`timeoutSeconds`** — how long a seat can go silent before the policy acts. Bounded `[10, 120]`\n at the schema level: short enough that a real match doesn't hang forever on a dropped peer, long\n enough to ride out a brief reconnect (a phone call, a tab backgrounding on mobile) without\n punishing it as an abandonment.\n\n**Omit it and a relay-backed online mode defaults to `{ policy: \"auto\", timeoutSeconds: 30 }`.**\n\nBeing unavailable is not the same as quitting. One is a condition — a dropped signal, a locked\nphone, a backgrounded tab — and the other is a decision, made by pressing Exit. Defaulting to\nforfeit collapsed the two, turning \"your connection dropped\" into \"you quit and lost\". **Nobody\nloses a match by being absent.**\n\nThis is why **auto is mandatory for online play**: a mode with no way to drive an absent seat has no\nanswer for one that isn't forfeiting a match the player never chose to leave. The validator enforces\nit — every relay-backed online mode resolves to `auto`, and `auto` needs something able to play the\nseat. Declare it with **`capabilities.auto: true`**.\n\n**The bar is lower than \"AI\".** What the platform needs is something that keeps a match moving when\na player goes quiet — for most turn-based games a legal-move picker is entirely enough. If your game\ncan take a turn on a player's behalf, you support auto mode, and that is all this is asking.\n\n`capabilities.ai: true` is the same claim under an older name; declare either. And **you never have\nto offer Vs AI as a mode** — Vs AI isn't relevant to every game, and nothing here asks you to add a\nmode you didn't want. (Declaring one does also prove it, since a game offering Vs AI plainly has\nwhat's needed, but it is never the requirement.)\n\n`forfeit` remains declarable for a game that genuinely wants it, but it is no longer what silence\nmeans. Declaring\n`unavailable` is meaningless — and a validation error — on a mode outside\n`online_friends`/`online_random`, and on any online mode whose resolved `sync` (see \"Choose a sync\nmode\" above) is `\"parallel\"`: a parallel seat runs its own independent session with no peers to\nmonitor in the first place, so there is nothing here to react to.\n\n### What each policy means for players\n\n- **`\"auto\"`** — the game's own AI takes the missing seat over once `timeoutSeconds` elapses with no\n activity, and the match keeps moving. If the player returns, handback is instant: their next\n move is theirs again, no re-join step. A seat under `\"auto\"` is never removed from the match —\n it can go quiet and come back any number of times.\n- **`\"forfeit\"`** — opt-in only, and rarely what you want. Once `timeoutSeconds` elapses, what\n happens depends on how many seats are still occupied: with exactly **2** seats, the remaining\n player wins outright and the match ends immediately. With **3 or 4** seats, only the silent seat\n retires — permanently removed from turn rotation, the match continuing among whoever's left.\n Reach for this only if losing by absence is genuinely correct for your game; for everything else\n `auto` is the default precisely because it isn't.\n\n**A stand-in's committed turn under `\"auto\"` is authoritative for every client, including the seat\nit stood in for.** Never gate turn-state application on local uncommitted state; cancel local play\nin [Turn Kit](/guide/sdk/turn-kit)'s `onTurnOverridden` instead (see its \"One committed history per\nturn\").\n\n### Every turn needs a clock, and every player needs an Auto button\n\nThe `unavailable` policy above bounds a player's **absence** — it fires when a seat stops sending\nanything at all. It does not bound a **turn**. A player who is connected, on the screen, and simply\nnot moving keeps their client beaconing, so the policy never fires and everyone else waits as long\nas that player likes. **Nothing in the platform stops this. Your game must.**\n\nTwo pieces, and you need both:\n\n**1. A per-turn deadline.** Carry the deadline in the state you broadcast and show the remaining\ntime on every client. When it expires, the turn holder's own client plays for itself and moves on;\nno other client ever decides that a turn expired. Thirty seconds is the number Ludo and Snake Ludo\nboth use.\n\nTwo ways to carry it, both fine:\n\n- **An absolute time** (`deadlineAt`, epoch ms), which every client reads against its own clock.\n Simple, but phone clocks are often seconds apart, so the countdown each player sees is off by that\n much.\n- **The time remaining** (`turnMsLeft`) when the state was sent, which each receiver turns into a\n local deadline on arrival. It ignores clock differences, at the cost of the network delay (a few\n hundred ms). A snapshot that is served later from a cache (to a spectator or a reconnecting\n player) shows too much time left; since only the holder decides expiry, that only affects the\n ring, never the match.\n\nEither way, the holder's own clock is the one that ends the turn.\n\n**2. An Auto toggle on the local player's own seat.** A small checkable control next to that\nplayer's avatar or their own controls — Snake Ludo puts it beside the die. It gives the player a way\nto say \"play for me\" before they step away, instead of discovering after the fact that the game did\nit for them.\n\nThe toggle's contract, as both Ludo titles implement it:\n\n- **Local player only.** Never rendered for another seat and never for a spectator — it is a control\n over your own play, not a status readout about someone else's.\n- **Two ways in:** the player taps it, or the turn deadline expires and the game turns it on itself.\n- **Two ways out:** the player taps it again, **or touches anything else on the screen**. Presence\n is the handback signal — someone who is interacting is plainly back, and making them find the\n chip again to resume control is friction with no purpose.\n- **Purely local state.** Auto is not broadcast and other clients are not told. They see the seat's\n moves arrive exactly as they always would; whether a person or your AI chose them is nobody\n else's business, and a game that labels an auto-driven seat differently on other screens is\n leaking something it shouldn't.\n\nHandback lands on a **turn boundary**, never mid-turn: a tap during an auto-driven turn takes effect\nfor the next one, and is swallowed rather than also being applied as a game input. Cancelling\nhalfway through leaves the turn half-played or, worse, played twice.\n\n### A hidden page plays the same match: same pace, no sound\n\nWhen the player switches tabs or minimises the browser, Auto takes their seat and the match goes on.\nOther players and spectators are still watching it. **They must not be able to tell.** Every move\ntakes the same think time and the same animation time it takes on screen, and the hidden page makes\nno sound.\n\nThe trap is the browser itself. A hidden page renders no frames and wakes its timers about once a\nsecond, so a turn that awaits many short animation steps (a die tumble, each square of a walk, a\nfade between turns) would crawl. The tempting fix is to resolve those waits at once while hidden.\n**Don't.** The match then runs at fast-forward for every spectator, and a watcher who sees a match\nspeed up stops trusting it.\n\nDo this instead:\n\n- **Animation time is match time.** While hidden, skip the drawing but keep the duration. Don't\n await `requestAnimationFrame`, `transitionend` or an animation's `finished` promise: a hidden page\n never fires them. Wait the animation's length on a timer instead.\n- **Batch the short waits.** Add the owed animation time up and pay it with one timer once a\n second's worth has built up. The turn then takes its real length, within about a second, however\n hard the browser throttles.\n- **Publish when the move is done.** Send each move to spectators after its (paced) animation, so\n moves reach them at the pace they would see on screen.\n- **Physics steps by wall time.** If your physics runs in the frame loop, step it from a timer by\n real elapsed time while hidden, never faster.\n- **No sound.** Stop effects and music when the page is hidden or the portal sends `pause`. A sound\n helper must never resume a suspended `AudioContext`; only a visible, unpaused page may.\n- **After the phone sleeps, adopt, don't replay.** A suspended page runs nothing. Your stand-in (for\n `vs_ai` and AI-filled `online_random`) or the other players (in a room) played that time. On wake,\n adopt the board you are handed and carry on at the normal pace; never replay the missed time in a\n burst.\n\n**Check it in a real minimised window**, not by faking `document.hidden`, which doesn't throttle\ntimers. With Auto on, time the moves your game publishes while visible and then while minimised. The\nhidden gaps should match the visible ones, and the page should play no sound.\n\n### The network-headroom rule\n\nDeclaring `unavailable` on a mode requires `network.expectedMessagesPerMinute >= 30`. This isn't an\narbitrary floor — it's the validator's own reasoning: the disconnect-liveness heartbeat that lets a\nrelay-backed match detect \"a peer went unavailable\" in the first place rides on top of whatever\ntraffic budget the mode already declared, and needs headroom above it — the platform's relay limits\nare 3× the declared `expectedMessagesPerMinute`, and the heartbeat's own beacons cost roughly 12/min\nper seat, so a mode with no headroom to spare starves either the heartbeat or the game's own traffic\nthe moment a peer actually goes quiet. Raising `expectedMessagesPerMinute` to clear this floor is\noften the only manifest change a game needs to adopt `unavailable` at all — see the republishing\nappendix below for three worked examples.\n\n### Reporting the result\n\n- **A forfeit win** is reported by the surviving client as any other win, with one extra flag:\n `Miniant.reportResult({ outcome: \"win\", forfeit: true, durationMs })`. The flag carries no\n XP/rank consequence of its own — the platform's ordinary win-outcome rules already apply, and the\n match is **fully ranked**. It exists purely as an audit trail, so integrity review can query\n forfeit-win rates separately from an ordinary contested win.\n- **An `\"auto\"`-completed match** reports normally: every seat that's still connected at the end\n calls `reportResult()` from its own perspective, exactly as it would for a match nobody ever went\n quiet in (see [Scores and results](/guide/sdk/scores)'s \"Reporting results in an online session\").\n It, too, is fully ranked.\n- **The quitter's own result is a forfeit loss, not `\"abandoned\"`.** A player who explicitly Leaves\n or Exits reports `Miniant.reportResult({ outcome: \"loss\", forfeit: true, durationMs })` for their\n own seat (see [Scores and results](/guide/sdk/scores#reporting-results-is-mandatory)) — the match\n continues for the remaining seats, and the room only closes once no players are left. Quitting a\n match you're losing must not erase the loss: a forfeit win needs a matching forfeit loss, or the\n ranked data carries a win nobody lost. If the game reports nothing, the portal records the forfeit\n loss itself on the confirmed exit, so this always lands correctly either way.\n- **A player who goes unavailable *silently* — never Leaves, never Exits — and never returns**\n simply never reports; there is no client left to call `reportResult()` on their behalf. Their\n session sits `\"abandoned\"`, the one case that outcome still applies to a player's own session.\n This is a natural consequence of the existing per-seat reporting model, not special-cased\n handling for the availability policy.\n\n### Explicit Leave is the same policy event\n\nThe portal's own Leave control (mid-match) doesn't bypass this machinery — it drives it. Leaving\nends only the leaver's own session; if at least one other seat is still active, the room stays live\nand the remaining seats are notified the same way a disconnect notifies them (`peerLeft` on\n`Miniant.net`, or the Turn Kit's own availability callbacks). The one difference from a plain\ndisconnect: a plain network drop is only detected once `timeoutSeconds` of silence has passed, while\nLeave is unambiguous the instant it happens, so it skips that detection wait and puts the seat into\nthe grace state immediately. Survivors still see the same full `timeoutSeconds` grace countdown\nbefore the policy actually acts — Leave does not shorten *that* window, only the wait before it\nstarts. Only when a leave empties the room of every other active seat does the room close outright,\nsame as it always has.\n\nSee [Turn Kit](/guide/sdk/turn-kit) for the machinery a `turn_based` mode gets automatically, and\n[Multiplayer](/guide/sdk/multiplayer) for the tier-3 recipe a hand-rolled `realtime` game implements\nitself — both react to `context.mode.unavailable`, and both call\n`Miniant.reportPeerAvailability()` so the portal's own grace/auto-pilot/forfeit banner chrome can\nrender without your game building any of it.\n\n## Spectating\n\nDeclare `\"capabilities\": { \"spectate\": true }` to let a player's friend watch one of their in-play\nmatches, read-only, without joining — in any mode, not just room-based ones.\n\n**Spectate is required, not optional — every manifest must declare it.** The validator fails a\nbuild whose manifest omits `capabilities.spectate: true` or sets it `false`, because the portal's\ncompetitive surfaces are built on it: friends watch live matches, tournament matches are broadcast\nfree to the public, and the Live hub can only feature games that spectate — the spectate endpoint\nrefuses any game without the declaration. Declaring is the enforceable half; *implementing* the\nwatchable stream is yours and is checked at review, not by the validator. The cost is small where\nit matters most: a `turn_based` mode on the Turn Kit already tags its snapshots `{snapshot: true}`,\nwhich the relay caches and serves a joining spectator directly — no `syncRequest` round-trip, and no\ncode the game has to write for that case — so what's left is the read-only rendering rules in\n[Spectating](/guide/sdk/spectating) — and the same action frames you broadcast for\n[the live-view rule above](#broadcast-the-action-not-just-the-outcome) are what make the stream\nworth watching. A `solo`/`vs_ai`-only game complies by calling\n`Miniant.spectate.publishState(snapshot)` once as soon as the first board is on screen and again on\nevery meaningful change (`Miniant.spectate.publishAction()` for live motion in between) — that\npublish path is part of the requirement, implemented in the same change that ships the game, never\ndeferred. The Watch button appears as soon as the portal has observed a publish, independent of\nwhether the manifest also declares `capabilities.relay` for an unrelated online mode.\n\n**A spectatable game with a roomless mode must also ship a stand-in.** If the manifest declares\n`capabilities.spectate: true` and a `vs_ai` or `online_random` mode, it must declare\n`capabilities.standin: true` (`standin.required`, a validator error). Those matches live in the\nplayer's page, so a sleeping phone freezes the board in front of every watcher; the stand-in is the\nsmall server Worker that keeps the match playing. See\n[Stand-in](/guide/sdk/spectating#stand-in-keep-a-roomless-match-playing-while-the-phone-sleeps).\nA game with only `online_friends` (rooms ride the relay) or without `spectate` is exempt. The portal\nalso lists a game in its live sections only when it declares both `spectate` and `standin`.\n\n- **A spectator's connection is receive-only by construction, not by convention your code has to\n enforce.** Any `send()`/`sendTo()` call from a spectator's connection is silently dropped\n platform-side. You don't have to build read-only enforcement yourself. Live action frames must\n always be sent as broadcasts, never `sendTo` — a spectator is never the target of a targeted send.\n- **A spectator joining a room never touches the match.** It is served the relay's cached\n `{snapshot: true}`-tagged broadcast directly, with no frame reaching any seat's game code — see\n [Spectating](/guide/sdk/spectating#snapshottrue-and-catching-up-a-spectator-mid-match). A GAME seat\n itself reconnecting into a room with another live peer is a separate case and still triggers\n `syncRequest`, unaffected by this.\n- **Every spectate visit gets an anchor seat.** `context.spectateAnchorSeat` (present only\n alongside `context.spectator === true`) names which participant's perspective to render from —\n the same seat-rotation/orientation logic you already apply for \"this seat is mine,\" substituting\n the anchor's seat for your own.\n- **`capabilities.spectate` mirrors your manifest declaration in `context.capabilities`.** If you\n didn't declare it, it's `undefined` there too — the same additive, back-compat pattern used\n elsewhere in the play context.\n- **A spectate visit never starts a local match, never runs your AI, and is never offered\n `local_multiplayer`** — a watcher only ever renders what it's handed.\n- Full mechanics — the two delivery paths (room-based via `{snapshot: true}`-tagged relay\n broadcasts and `checkpoint()`, vs. roomless via `Miniant.spectate.publishState()`/`publishAction()`\n and `createSpectatorKit`), the `syncRequest` resync flow (reconnect only), and the 16KB payload\n cap — are documented in [Spectating](/guide/sdk/spectating).\n\n## Online modes and simulated players\n\nThe available online modes are `online_friends` and `online_random`. In `online_random`,\nmatchmaking may fill a seat with a portal-owned simulated identity if it can't find a human\nopponent in time — that participant carries `simulated: true` in `context.participants`, and your\ngame must drive that seat with its own local AI logic while presenting it to the player exactly\nlike any other remote participant (same `id`/`displayName`/`avatarUrl`/`countryFlag` fields you\nwere given). **Never reveal that seat as AI** — no \"playing vs. bot\" label, no different visual\ntreatment. `simulated` is omitted for every real participant; treat its absence as `false`.\n\n## Pass & Play (`local_multiplayer`)\n\nEveryone plays on one device, taking turns. Structurally this is closer to `vs_ai` than to the\nonline modes: `context.room` is `null`, there is no relay, and you must not use `Miniant.net`. If\nyou already support a variable-size `vs_ai` game, this is that minus the AI.\n\nFive things differ from every other mode:\n\n**Every seat is a human.** `mode.playerCount` is the total number of *people*, not \"you plus some\nopponents to fill in\" — at `4` you set up four human-controlled seats and fill none of them\nyourself. Every entry in `context.participants` carries `isLocal: true`, because they all are.\n\n**The names are real, and you are expected to show them.** Before the game starts, the portal asks\nthe players what they're called and hands you the result as each participant's `displayName`, in\nseat order. These are not placeholders: somebody typed them. Render them wherever you'd normally\nshow a player's name, so a turn prompt reads \"Rafi's turn\" rather than \"Player 2's turn\". A player\nwho leaves their field blank arrives as `Player 1`/`Player 2`/… so the field is always populated —\nyou never need a fallback of your own.\n\nOne trap worth naming explicitly: `context.player` is still present in this mode, still carries\nthe signed-in account's own profile `displayName`, and its `seat` number collides with one of\nthese participants. It identifies the *account* running the session — who pays the session fee,\nwhose save this is, whose purchases work — not a seat label, and the person holding the account\nisn't necessarily one of the people playing. For anything drawn on the board — rosters, turn\nprompts, results — `participants[].displayName` is the only source. Never merge or overlay\n`player.displayName` onto the seat whose number matches `player.seat`: doing so silently replaces\na name somebody just typed into the portal's \"Who's playing?\" form with the account's profile\nname.\n\n**Teams come from `team`, never from seat numbers.** When `mode.teamUp` is set, each participant\ncarries `team: 0 | 1`. Read `mode.teamUp` rather than `room.teamUp` — `room` is `null` here, so a\ngame checking only the room would see nothing. Who partners whom is the portal's to decide; where\nthose partners *sit* is yours, so keep whatever arrangement your board uses.\n\n**Nobody is signed in as anyone else.** These are labels for people sharing a couch, not accounts:\nthere is no friend graph, no profile, no avatar, and `avatarUrl`/`countryFlag` are `null` for every\nseat. Don't build anything that assumes otherwise.\n\n**A session is never recorded (2026-09-17).** No XP, no leaderboard or stats entry, no win/loss\nstreak, no share or challenge card — there's no real per-seat identity on a shared device to record\none against, so a `local_multiplayer` session touches none of a player's record. It still costs\nTickets — 3, the same discounted rate as a roomless `vs_ai` start, priced below the normal session\nfee because it's friends playing together for fun and never enters a record (Kais, 2026-09-25) —\nand your game should still call `Miniant.reportResult()` as usual (see [Scores and results](/guide/sdk/scores)) — the portal just never publishes what it says.\n\nDeclare it like any other mode — `{\"id\": \"local_multiplayer\", \"players\": [2, 4]}` — and remember an\nundeclared mode is never offered, so nothing appears for players until you ship support for it.\n\n## Chat and player-generated content\n\nBe honest with yourself about this one: **relay chat and emotes carry no automated profanity\nfiltering and no proactive moderation today.** Moderation is reactive only, through player\nreports. If your game exposes any form of chat, emote, or other player-to-player text, your game\nis responsible for what it lets players send — there is no platform-side filter standing between\na player and what they type. Design accordingly (rate-limit it yourself, restrict it to preset\nmessages, or accept the reactive-only moderation model) rather than assuming the platform screens\ncontent for you.\n\n## Appendix: republishing an existing relay game for the availability policy\n\nIf your game already shipped a relay-backed online mode before \"When a player becomes unavailable\"\nabove existed, it's already running the implicit default (`{ policy: \"forfeit\", timeoutSeconds: 30\n}`) — nothing breaks. Adopting an explicit policy is a small, mechanical change. Three worked\nexamples, from games actually on the platform, cover the two shapes you'll run into:\n\n**A `realtime` (hand-rolled `Miniant.net`) game with its own auto-move already built.** Add\n`unavailable` to both online modes:\n\n```jsonc\n{ \"id\": \"online_friends\", \"players\": [2, 4], \"unavailable\": { \"policy\": \"auto\", \"timeoutSeconds\": 20 } },\n{ \"id\": \"online_random\", \"players\": [2, 4], \"unavailable\": { \"policy\": \"auto\", \"timeoutSeconds\": 20 } }\n```\n\nthen implement the tier-3 recipe in [Multiplayer](/guide/sdk/multiplayer#absent-players) — the 5s\nliveness beacon, away/back on visibility transitions, per-seat last-activity clocks, and calling\n`Miniant.reportPeerAvailability()` at every transition — wiring it to the auto-move logic the game\nalready has rather than building new fallback-move code. `expectedMessagesPerMinute: 60` already\nclears the network-headroom floor (`>= 30`), so no `network` change is needed here. (If your game\nalso renders its own in-room \"Play again\" UI on top of the platform's game-over-screen contract,\nreplace it with `Miniant.requestRematch()` — that's a separate, already-required change, not part of\nthis policy.)\n\n**A `realtime` game with a slower declared rate.** Same shape as above, but check the headroom rule\nfirst: at `expectedMessagesPerMinute: 40` a game already clears the `>= 30` floor with margin, so\n`unavailable: { \"policy\": \"auto\", \"timeoutSeconds\": 30 }` on both online modes is a manifest-only\nchange alongside the same beacon/`reportPeerAvailability` wiring — no `network` edit required.\n\n**A [Turn Kit](/guide/sdk/turn-kit) game with its own AI already built (e.g. a minimax player).**\n`\"forfeit\"` is available, but `\"auto\"` is the better fit whenever the game already has an AI that\ncan stand in instantly — a fast, roughly-instant game makes a forfeit ending feel abrupt for what\nmight have been one missed beat. Add:\n\n```jsonc\n{ \"id\": \"online_friends\", \"players\": [2, 2], \"sync\": \"turn_based\", \"unavailable\": { \"policy\": \"auto\", \"timeoutSeconds\": 30 } },\n{ \"id\": \"online_random\", \"players\": [2, 2], \"sync\": \"turn_based\", \"unavailable\": { \"policy\": \"auto\", \"timeoutSeconds\": 30 } }\n```\n\npass `unavailable: context.mode.unavailable` into `createTurnKit()`, and implement `onAutoTurn(seat)`\nby computing that seat's move with the existing AI and calling `endTurnAs(seat, patch?)`; implement\n`onTurnOverridden` to cancel any local in-flight play when a stand-in's turn takes over or is itself\nreplaced (see \"What each policy means for players\" above), and `onSeatAutoDriven(seat, bySeat)` to\nclose any stale \"your turn\" UI once you learn you were driven while away. At\n`expectedMessagesPerMinute: 30` this game satisfies the headroom floor exactly (the\nrule is `>= 30`, not `> 30`) — nothing to raise today, but there's no margin left, so a future\nincrease in the game's own traffic should raise `expectedMessagesPerMinute` to `40` or so at the\nsame time, before the validator has reason to reject it.\n\nIn every case: republish through the normal pipeline once the manifest and code changes are made —\ndeclaring `unavailable` alone, with no supporting code, still validates and publishes (the platform\ncan't verify game logic), but leaves players staring at a seat that never actually auto-plays or\nretires.\n"},{"slug":"requirements/game-covers","title":"Game covers","section":"Requirements","agentSummary":"Cover art for a game's listing: the five image slots plus preview video, which carry a logo versus stay clean, focal points, the live device preview, and upload limits.","checklist":[{"id":"req-covers.square-and-wide-required","text":"Upload all five image slots — 1:1 square (800×800 or larger), 16:9 without logo and 16:9 with logo (1920×1080), 9:16 without logo and 9:16 with logo (1080×1920). None of them is optional.","verify":"manual"},{"id":"req-covers.no-borders-or-promo-text","text":"Don't add borders or promotional text such as \"New\" or \"Play Now\" to any slot — the game's title/logo is the only text allowed, and only on 1:1 square, 16:9 with logo and 9:16 with logo.","verify":"manual"},{"id":"req-covers.no-copyrighted-visuals","text":"Don't use copyrighted visuals you don't have the right to use in cover art.","verify":"manual"},{"id":"req-covers.not-blurry","text":"Don't submit blurry, pixelated, or stretched cover art — each slot is cropped, never stretched, so supply the native aspect ratio.","verify":"manual"},{"id":"req-covers.set-focal-points","text":"Set a focal point on every uploaded slot in the Store Listing tab so crops keep the subject — the default is dead centre, which is usually wrong for an off-centre subject.","verify":"manual"},{"id":"req-covers.preview-video-limits","text":"Keep the preview video to at most 10 seconds and 8MB, no sound, no fast-forwarding, opening on your 1:1 square cover image as the first frame.","verify":"manual"},{"id":"req-covers.uploader-limits","text":"Stay within the enforced upload limits — 1:1 square 3MB; both 16:9 slots and both 9:16 slots 4MB each; video 8MB and 10 seconds — or the upload is rejected before review.","verify":"manual"},{"id":"req-covers.hero-slots-clean","text":"16:9 without logo and 9:16 without logo uploads carry no baked logo, title, or text — the site draws the title on top of them.","verify":"manual"},{"id":"req-covers.wide-logo-safe-zone","text":"16:9 with logo art carries the game's logo, kept clear of the bottom 28% (where Trending writes the title and Play button) and the top corners (badges).","verify":"manual"},{"id":"req-covers.device-preview-checked","text":"All three device tabs — Desktop, Tablet, Mobile — in the art panel's \"What players see\" preview were checked before submitting.","verify":"manual"}],"body":"# Game covers\n\n## The rule in one sentence\n\n**Surfaces where the site writes the game's title get art without a logo. Card surfaces that identify\nthemselves get art with a logo.** Everything below is that one rule applied to five slots: three of\nthem (1:1 square, 16:9 with logo, 9:16 with logo) are yours to brand, two of them (16:9 without\nlogo, 9:16 without logo) the site brands for you, and putting a logo on the wrong one is the single\nmost common mistake in review. Each orientation has a pair — 16:9 without logo and 16:9 with logo,\n9:16 without logo and 9:16 with logo — the same picture without and with the logo.\n\nThe without-logo pair feeds exactly three places: the home hero carousel, the game page's hero, and\nthe play screen's banner. Everything else is a card.\n\n## The five slots\n\nYour game's art is stored in named **slots** — named for the shape they hold and whether they carry\nyour logo, not for where they appear. All five are required. Slots that share a surface should still\nshare consistent visuals — same subject, palette, and treatment — so your game reads as one\nrecognizable thing everywhere it's shown.\n\n| Slot | Aspect ratio | Recommended size | Required | Logo or text baked in? | Feeds |\n|---|---|---|---|---|---|\n| 1:1 square | 1:1 | 800×800 (1024×1024 is fine) | **Yes** | Logo OK — centred, inside the inscribed circle; keep the bottom 30% and the top corners clear, the site draws a title gradient and badges there. | Every game tile and circle mark. |\n| 16:9 without logo | 16:9 | 1920×1080 | **Yes** | **No** — no logo, no text. The home hero writes the title dead-centre; the game page and play screen write it over the left and the bottom. | The home hero on laptops, landscape tablets, and ultrawides; the game-page hero on tablets and desktops; the play screen's banner on every device. The play banner is a 4:1 strip that keeps only the middle 44% of the height, so keep the subject clear of the top and bottom 28%. Leave it empty and those heroes show no art. |\n| 16:9 with logo | 16:9 | 1920×1080 | **Yes** | **Yes — this is the logo card.** Keep the logo out of the bottom 28% (Trending writes the title and Play button there) and the top corners (badges). | Trending cards, 4:3 and 2:1 cards, the share card. |\n| 9:16 without logo | 9:16 | 1080×1920 | **Yes** | **No** — no logo, no text. The hero title lands between 33% and 65% of the height. | The home hero on phones and upright tablets, and the game-page hero on phones. The phone game page keeps only the middle ~60% of the height, so keep the subject below the top 20% and set the focal point on it; keep the bottom half calm, the CTA and dots float there. |\n| 9:16 with logo | 9:16 | 1080×1920 | **Yes** | **Yes — the logo card for tall frames.** The tile draws no title, scrim, or badge over it; keep the logo inside the middle 89% of the width (the 1:2 tile trims ~5.6% per side). | The tall tiles in All Games. Leave it empty and those tiles use 9:16 without logo with the site's title on top. |\n| Preview video | any | 1080p, ≤10s, ≤8MB, no sound | No | — | Plays in place of the square image on hover. Details under [Preview video](#preview-video). |\n\nThere is no 8:3 slot. The wide heroes on the game page, the play screen, and ultrawide screens\nare all cut from 16:9 without logo.\n\n## Frame templates\n\nOne template per slot, measured on the live site. The zones are cumulative across every surface\nthat consumes the slot — art composed inside its template looks right everywhere without\nper-surface edits.\n\nHow to read them:\n\n- **Green — focus core.** Visible on every surface; put the subject here.\n- **Red — site UI writes here.** Nothing that must be read or seen whole.\n- **Amber dashes — crop lines.** Everything outside is lost on the labeled surface.\n- **Purple dashes — logo zone.** The only place baked text or a logo is allowed (only 16:9 with\n logo and 1:1 square have one).\n\n### 16:9 without logo\n\n\n\nThe home hero writes the title dead-centre on this image — keep the exact middle calm and let\nthe subject live around it. The game page and play screen crop it much wider and shorter, so\nkeep the subject inside the middle band of the height and set the focal point on it. Leave it\nempty and the laptop hero, the desktop game page, and the play banner show no art for your game.\n\n### 16:9 with logo\n\n\n\nThe logo belongs here, big and central if you like — clear of the bottom 28% and the top corners.\n\n### 9:16 without logo\n\n\n\nSubject in the top third; the title lands across the middle; the lower half is scenery.\n\n### 1:1 square\n\n\n\nA centred logo works — the tile is the game's icon. Keep the bottom 30% and the top corners\nclean, and stay inside the inscribed circle.\n\n## Where each image is shown\n\nThe portal renders art in many shapes across desktop, tablet, and mobile, and every one of\nthem draws from **exactly one slot** — the slot it was designed to be cut from. If that slot\nhas no upload, the surface shows **no art** for your game (its own gradients and title still\nrender, but the frame is empty). A square is never stretched into a hero, and a hero image is\nnever squeezed into a tile — a wrong-ratio crop looks broken, so the portal doesn't do it.\n\nThere is exactly one stand-in, the same shape as the slot it covers: an empty **9:16 with logo**\nborrows **9:16 without logo** (and the tile then writes your title on top, since 9:16 without\nlogo has none).\n\nThe allocation: tiles and circle marks cut from **1:1 square**; Trending and the other cards (4:3,\n2:1, the share card) cut from **16:9 with logo**; the tall tiles cut from **9:16 with logo**; the\nphone home hero and phone game page cut from **9:16 without logo**; the laptop and ultrawide home\nhero, the tablet and desktop game page, and the play banner cut from **16:9 without logo**. Every\nslot is required, so with all five uploaded no frame is ever empty.\n\n## Checking your art before you submit\n\nThe Store Listing tab's art panel has one preview surface: **\"What players see.\"** It renders\neach upload inside realistic mockups of the actual site — nav bar, title overlays, gradients —\nat the true proportions those surfaces use, switchable across three device tabs: **Desktop,\nTablet, Mobile.** A tablet held upright takes a different image than the same tablet on its\nside, and a mockup is the only way to catch a logo sitting under where the home hero's title\nwill land before a reviewer catches it for you.\n\nCheck all three device tabs before submitting. The preview also shows exactly which frames\nwill be **empty** for players — each frame renders only its allocated slot, so a missing\nupload shows as a labelled hole here rather than a surprise on the live site.\n\n## Focal points\n\nEvery uploaded slot carries a **focal point** — the spot in that image the crop is anchored\nto, as a percentage from the top-left. Set it in the Store Listing tab by clicking the subject\nof each image; the live preview updates immediately so you can confirm the crop before you\nsubmit. A slot with no focal point crops from the centre, which is usually wrong for a subject\nthat isn't already centred — 16:9 without logo and 9:16 without logo are the two that most often\nneed a nudge, since their crops are the tallest and widest relative to a typical square source.\n\n## Restrictions\n\n- No borders.\n- No promotional text — no \"New\", \"Updated\", \"Play Now\", or similar copy on any slot.\n- No logo or title art on 16:9 without logo or 9:16 without logo. The site draws the title on\n these two; baking your own in produces two overlapping titles in production.\n- The game's logo belongs on 16:9 with logo and 9:16 with logo, and optionally on 1:1 square.\n Nowhere else.\n- No icons or store logos.\n- No copyrighted visuals you don't have the right to use.\n- Nothing blurry, pixelated, or stretched. Supply each slot at its native aspect ratio; the\n uploader does not resize or pad.\n\n## Craft guidance\n\n- Don't just screenshot the game — players can already preview gameplay elsewhere; the\n cover's job is to sell the game at a glance, not to document it.\n- Keep it simple and readable at small sizes — covers get shown small far more often than\n large, and the square slot is shown smallest of all.\n- Put the game's logo on the 16:9 with logo image, and keep it clear of the bottom 28% and top\n corners so it survives every surface that slot feeds.\n- Use a font that suits the game's tone for the logo art.\n- For 16:9 without logo and 9:16 without logo, compose for a title the site adds afterward, not a\n finished poster: leave genuine negative space where each surface's guide above says the copy\n lands, and treat the rest of the frame as where your subject can live.\n- Keep all five slots visually consistent — same subject,\n palette, and treatment — even though only the with-logo slots carry your logo.\n\n## Preview video\n\n- **10 seconds maximum, 8MB maximum** — both enforced by the uploader.\n- 1080p, 16:9; MP4 or WebM.\n- No sound.\n- No fast-forwarding.\n- Avoid black-screen or logo transitions, black bars, a visible mouse cursor, \"Play Now\" or\n other promotional text, and app/social icons.\n- Open on your 1:1 square cover image as the first frame, so the tile transitions seamlessly into\n the preview instead of cutting.\n\n## Uploading and review\n\nThe uploader is the **Store Listing** tab on your game's page in the developer portal,\nalongside the search tags players find the game by. It's independent of the build pipeline —\nyou can change cover art without requesting a new publish.\n\nLimits the uploader enforces, per slot:\n\n| Slot | Types | Max size | Max length |\n|---|---|---|---|\n| 1:1 square | PNG, JPEG, WebP, AVIF | 3MB | — |\n| 16:9 without logo | PNG, JPEG, WebP, AVIF | 4MB | — |\n| 16:9 with logo | PNG, JPEG, WebP, AVIF | 4MB | — |\n| 9:16 without logo | PNG, JPEG, WebP, AVIF | 4MB | — |\n| 9:16 with logo | PNG, JPEG, WebP, AVIF | 4MB | — |\n| Preview video | MP4, WebM | 8MB | 10 seconds |\n\nA file over its limit is rejected client-side before anything is sent; the video's length is\nread from the file's own metadata the same way. Each upload is a submission: it goes into the\nreview queue, and the live listing keeps showing the previous art until a reviewer approves it.\nYou can withdraw a pending submission from the same tab. Aspect ratios are not machine-checked\n— they are what the reviewer holds you to by eye, which is exactly what the live \"What players\nsee\" preview in the tab lets you confirm for yourself first.\n"},{"slug":"requirements/quality-guidelines","title":"Quality guidelines","section":"Requirements","agentSummary":"Optional best-practice guidance for onboarding, fun, aesthetic consistency, and naming. Nothing here blocks a review on its own.","checklist":[],"body":"# Quality guidelines\n\n**Everything on this page is optional best practice, not a requirement.** Nothing here will\nfail a review on its own — this is a collection of things that tend to make games better, not\na checklist a reviewer scores you against.\n\n## Onboarding\n\n- Land the player in gameplay, not a menu maze.\n- Keep any tutorial skippable.\n- Teach only the core mechanic — don't front-load every rule before the player has touched\n anything.\n- Prefer visuals over text where you can.\n- Label buttons clearly.\n- Never use deceptive delays or oversized buttons to nudge a player into something they\n didn't choose.\n\n## General principles\n\n- Clear, reachable goals.\n- Easy to learn.\n- Consistent and intuitive controls throughout.\n\n## What makes a game fun\n\n- Responds quickly to input.\n- Well-paced difficulty — not a flat wall, not a flat cakewalk.\n- Comfortable layout and audio.\n- No long repetitive stretches.\n- If you offer both solo and multiplayer, make solo play as prominent as playing with\n friends — or clearly say when solo isn't available, rather than leaving it ambiguous.\n\n## Uniqueness and naming\n\nA distinctive name helps players find you and avoids confusion with other games on the\nportal. A generic name like \"Chess\" doesn't identify your game; something like \"Super Chess\"\ndoes.\n\nNever use a name or asset whose IP you don't hold — \"Scrabble,\" for example, belongs to its\nrights holder, and using it invites a takedown regardless of how you built the game.\n\n## Aesthetic consistency\n\n- Consistent resolution and art style throughout — don't mix realistic and cartoon styles,\n or high- and low-resolution assets, in the same game.\n- Consistent audio levels across effects and music.\n- Your game's name and imagery should honestly reflect what the game actually is.\n\n## Restricted keys\n\nAdapt key bindings to the player's keyboard layout rather than making them rebind manually —\non AZERTY keyboards (France and others), the WASD movement cluster sits at ZQSD, and a game\nthat hardcodes WASD is unplayable there without a workaround.\n\nAvoid keys the browser owns outright: `Escape` exits fullscreen, and `Ctrl`/`Cmd`+`W` closes\nthe tab. Binding gameplay to either one means the browser will win the keypress before your\ngame ever sees it.\n"},{"slug":"publishing","title":"Publishing your game","section":"Publishing","agentSummary":"How to prepare a repo, connect it, and pass automated build validation plus human review to publish a game.","checklist":[{"id":"pub.validator-passes-locally","text":"Run the local miniant-validate.mjs validator against your actual build output before every publish request and fix every error it reports, treating warnings as review-visible.","verify":"manual"},{"id":"pub.manifest-present","text":"Ensure miniant.json exists in your build output next to index.html, or at your repo root.","verify":"validator:manifest.schema"},{"id":"pub.entry-and-references-resolve","text":"Confirm your entry file and everything it references (script, link, img, audio, video paths) exists in the build output with matching case.","verify":"validator:html.references-resolve"},{"id":"pub.no-secrets-in-output","text":"Keep secrets out of the build output — no .env files, private key or cert files, and no .git or .ssh directories.","verify":"validator:files.no-secrets"},{"id":"pub.lfs-objects-pushed","text":"Push your real Git LFS objects before publishing — an unresolved pointer file fails validation.","verify":"validator:files.no-lfs-pointers"},{"id":"pub.no-service-worker","text":"Don't register a service worker anywhere in the build — it fights the platform's instant-rollback model.","verify":"validator:sw.none"},{"id":"pub.lockfile-committed","text":"Commit a lockfile (package-lock.json, pnpm-lock.yaml, or yarn.lock) so your build is reproducible.","verify":"manual"},{"id":"pub.godot-web-export-preset","text":"For Godot, commit export_presets.cfg with an HTML5 export preset named exactly Web.","verify":"manual"},{"id":"pub.questionnaire-every-publish","text":"Complete the anti-cheat questionnaire honestly before every publish request — a false declaration is grounds for a ban.","verify":"manual"},{"id":"pub.bounds-match-game","text":"Declared score/duration bounds in the questionnaire must match what the game can actually produce — out-of-bounds results are rejected.","verify":"manual"},{"id":"pub.clean-console-playthrough","text":"No console errors during a full playthrough; the reviewer sees your console and a smoke-test screenshot that waits for Miniant.ready().","verify":"manual"}],"body":"# Publishing your game\n\nThis is what you need before you connect your repository, plus what happens after you request a\npublish.\n\n## Sign in\n\nGo to **[publish.miniant.games](https://publish.miniant.games)**. Enter your email, then enter the\ncode sent to your inbox (check spam if it doesn't arrive within a minute or two). You'll only see\ngames the Miniant team has invited you to publish — if \"My Games\" is empty, ask them to send the\ninvite to the email you signed in with.\n\n## Prepare your repository\n\nYour repo must build to a static web bundle. Which convention applies depends on your engine:\n\n**Phaser / plain JavaScript**\n- A `package.json` with a `build` script that outputs static files to `./dist` (or a different\n directory you name when you connect your repo — see \"Custom build output directory\" below).\n- npm, pnpm, and yarn are all auto-detected from your lockfile.\n- Commit your lockfile (`package-lock.json`, `pnpm-lock.yaml`, or `yarn.lock`). A build without one\n still runs, but every build card shows a reproducibility warning — your dependency versions\n aren't pinned, so a build today can silently differ from a build next month.\n\n**Godot**\n- An HTML5 export preset named exactly **`Web`** (Project → Export in the Godot editor). This\n writes to `export_presets.cfg` at your repo root — **commit that file**. If it's missing, or has\n no preset named exactly `Web`, the build fails before it ever runs.\n- Built against Godot `4.2.2`.\n\n**Prebuilt web export (any engine)** — Construct, GameMaker, Cocos, and similar tools with an\n\"export to HTML5\" step.\n- Export your project to a static web build from your engine's own export feature, and commit\n the exported directory to your repo.\n- Point the **build output directory** at that committed folder when you connect your repo (see\n \"Custom build output directory\" below) — it defaults to `./dist`, which is unlikely to match\n where your engine's exporter writes.\n- SDK integration is still the plain-JavaScript script tag from your exported HTML shell (see\n [`/guide/sdk/engines`](/guide/sdk/engines)).\n\n**Unity (WebGL export)**\n- Same prebuilt path as above: there's no Unity toolchain in this pipeline, so you build the\n WebGL export yourself in your own Unity Editor and commit the export directory.\n- Download the Unity wrapper from [`/tools/miniant-unity-sdk.zip`](/tools/miniant-unity-sdk.zip)\n and follow its integration steps — full details in [`/guide/sdk/engines`](/guide/sdk/engines).\n- The wrapper is new and hasn't been compiled inside Unity by us; smoke-test everything you use\n against the real portal before depending on it in production, and report issues you hit.\n\nPrebuilt and Unity builds skip compilation entirely — nothing is installed or run from source.\nThe pipeline only verifies your committed output directory exists and is non-empty, ensures\n`miniant.json` is present, runs a blocking malware scan, runs a non-blocking headless smoke\ntest, then zips and uploads it. Preflight hard-fails the publish request if the resolved output\ndirectory (subdirectory joined with output directory) doesn't exist at your target commit, or is\nempty — so for these two engines especially, make sure the real exported files are committed,\nnot an empty placeholder folder.\n\nYour build must run cleanly and non-interactively in a fresh checkout — the pipeline runs it in an\nempty GitHub Actions runner with no manual steps.\n\n**Monorepo? Your game lives in a subdirectory.**\nIf your repo isn't just the game — a monorepo, or the game sits under something like `apps/game`\nalongside other packages — set the **project subdirectory** when you connect your repo (see\n\"Connect your repo and request a publish\" below). The build runs rooted there instead of at the\nrepo root.\n\n**Build output goes somewhere other than `./dist`?**\nSet the **build output directory** when you connect your repo (see \"Connect your repo and request\na publish\" below). It defaults to `./dist`; point it at wherever your build script actually writes\n(`build/`, `public/`, `www/`, etc.).\n\n**Large assets tracked with Git LFS**\nThe builder checks out your repo with Git LFS enabled, so textures, audio, and other large binaries\ntracked in LFS resolve to real files during the build — no extra configuration needed on your end.\nWhat it can't fix: an LFS *pointer* file that never resolves to real content, usually because the\nactual objects were never pushed. That fails validation (see \"Why your build might fail\" below)\nrather than silently shipping a tiny text stub where your asset should be. Before requesting a\npublish, confirm your LFS objects are actually pushed — `git lfs push --all`, or just check that\nthe files in a fresh clone look like real assets rather than ~130-byte text pointers.\n\n## Generate a GitHub token\n\nThe connect-repo form needs a token that can read your repository. Create a **fine-grained\npersonal access token**:\n\n1. GitHub → Settings → Developer settings → Personal access tokens → Fine-grained tokens →\n Generate new token.\n2. Repository access: **Only select repositories** → your game's repo.\n3. Permissions: **Contents → Read-only**. Nothing else is needed.\n\nThis token is stored encrypted server-side and used only to check your repo's latest commit and\ncheck it out inside the build sandbox — it's never shown again after you save it, and Miniant\nstaff can't read it back out either.\n\n## Before you request a publish\n\nMost first-publish failures are avoidable. A local validator runs the same manifest and structural\nchecks the platform runs, in seconds — it skips a handful of server-only scans (the sandboxed\nmalware scan, the payment-SDK signal scan) that only run\nonce your build actually reaches the pipeline. Download it once from the developer portal and run\nit with Node (18+; no install, no dependencies — it's a single self-contained file):\n\n```sh\ncurl -fsSLO https://publish.miniant.games/tools/miniant-validate.mjs\nnode miniant-validate.mjs ./dist # or your custom build output directory\nnode miniant-validate.mjs ./dist --json # machine-readable output\n```\n\nIf your AI agent has the `miniantgames-mcp` server installed, its `validate_build` tool runs this\nsame validator; see the [introduction](/guide#test-before-you-submit).\n\n**Run it before every publish request.** Then check:\n\n- [ ] The validator passes locally, against your actual build output directory.\n- [ ] `miniant.json` exists — either inside your build output next to `index.html`, or at your repo\n root. (If it's only at the repo root, the builder copies it into the output for you and notes\n this on the build card. If it's in neither place, the build fails immediately — see the\n [manifest reference](/guide/requirements/technical).)\n- [ ] Your `entry` file, and everything it references (`<script src>`, `<link href>`, `<img src>`,\n any other relative path), actually exists in the build output, with matching case.\n- [ ] No secrets in the build output: no `.env*` files, no private key or certificate files, no\n `.git`/`.ssh` directories. Anything in your build output gets published to a public URL.\n- [ ] A lockfile (`package-lock.json`, `pnpm-lock.yaml`, or `yarn.lock`) is committed.\n- [ ] Godot only: `export_presets.cfg` is committed, with a preset named exactly `Web`.\n- [ ] If any assets are in Git LFS: the real objects are pushed, not just pointer files — a\n half-committed LFS asset fails validation.\n- [ ] No service worker gets registered anywhere in the build (see \"Common gotchas: it worked\n locally\" below) — it's not fatal, but it works against you.\n\n## Connect your repo and request a publish\n\nYour game's page in the developer portal is split into four tabs — **Build & Publish** (the one it\nopens on), **Store Listing**, **Settings**, and **Activity**. A first publish starts in Settings,\nbecause there is nothing to build until a repository is connected:\n\n1. **Settings → Connect your repository** — paste the repo URL, pick your branch and engine, paste\n the token from step 3. If your game lives in a subdirectory (monorepo), set the **project\n subdirectory**; if your build writes somewhere other than `./dist`, set the **build output\n directory** (see \"Prepare your repository\" above). Both fields stay editable from the same tab\n afterwards, so a build script that moves later doesn't mean reconnecting.\n2. **Build & Publish → Request publish** — the button only appears once a repository is connected,\n which is why step 1 comes first. This builds whatever commit is at the head of your connected\n branch — so if that commit is already the live version, or already has a build queued or waiting\n for review, the request is refused instead of building the same code twice. Push your changes\n first, then request. This queues a build. Status moves through:\n `pending → building → validating → awaiting_approval`\n - *building*: your `build` script (or Godot export) is running.\n - *validating*: the build output is scanned and checked before anyone reviews it (see the\n [manifest reference](/guide/requirements/technical) and \"Why your build might fail\" below).\n - *awaiting_approval*: a Miniant team member reviews and approves it — this is a manual step,\n so it may take a little time. See \"What the reviewer sees\" below for what they see.\n3. Once approved, your game goes live at `<your-game-slug>.miniant.games`.\n\nIf a build fails, the reason shows on the build card at the top of **Build & Publish** — run the\nchecklist in \"Before you request a publish\" above, fix your repo, and request publish again. A\nbuild the pipeline never actually starts fails on its own within about fifteen minutes rather than\nsitting in *building* indefinitely, so you always end up with a reason to act on.\n\n## Where everything else lives\n\n- **Build & Publish** — the current build card, the Request publish button, past builds, and the\n deployed versions of your game.\n- **Store Listing** — your game page copy (see \"Your game page copy\" below), your cover art and\n preview video, plus the search tags players find the game by (see\n [Game covers](/guide/requirements/game-covers) for the spec and today's upload limits).\n- **Settings** — the repository connection. Once connected, \"Edit build configuration\" changes the\n project subdirectory and build output directory in place; changing the repo, branch, engine, or\n access token means disconnecting and reconnecting (your existing builds and deployments survive\n that).\n- **Activity** — the full audit trail for the game, badged with its entry count.\n- **Promote** — request a paid placement for the game; see \"Promote your game\" below.\n- **Analytics** — traffic and player-behavior numbers for the game; see \"Analytics\" below.\n\n## Your game page copy\n\nYour game's page on Miniant is built from four sections you write in the **Store Listing** tab:\n\n- **About** — what the game is and what a round of it feels like. At least two paragraphs.\n- **How to play** — the rules in order, as a player meets them. At least four short steps.\n- **Tips and strategy** — advice that changes how someone plays, not the rules again. At least three.\n- **Frequently asked questions** — real questions, answered honestly, including when the honest\n answer is \"not yet\". At least three.\n\nThis is not marketing copy and it is not optional decoration. A game page carrying a title, a\nthumbnail and an iframe reads as an empty page to a search engine and to the ad network that\nreviews this site — which means it does not rank, and it puts every other game's page at risk too.\nNobody at Miniant can write it for you: we would be guessing at your game's rules, and a wrong\ndescription is worse for a player than none.\n\n**If it is incomplete when you request a publish, the build carries a warning and the reviewing\nadmin may reject it on that ground.** It will not block the build from running, so a crash fix is\nnever held up by a missing FAQ — but a build that arrives with an empty listing is a build somebody\nhas a reason to send back.\n\nThe editor shows you exactly what is still missing while you type, using the same check the\nreviewer sees. You can save an unfinished draft at any point; nothing is lost between sittings.\n\n### Write it somewhere else and paste it in\n\nYou do not have to type into the individual boxes. Write the whole listing wherever you normally\nwrite — a text file, a doc, a chat message — and paste it into the **Paste it all at once** box at\nthe top of the Store Listing tab. Press **Fill the fields from this** and it reads your text into\nthe fields below, where you can check it before saving. Nothing is saved until you press Save.\n\nThe format is ordinary Markdown headings. Two rules carry the whole thing:\n\n- **`##` starts a section.** Use it for About, How to play, Tips and FAQ.\n- **`###` starts one tip or one question** inside those sections. The line after it is the body or\n the answer.\n\nEverything else is plain text. Leave a blank line between paragraphs in About, and write your steps\nas a numbered or bulleted list.\n\n```\n## About\nWhat the game is, and what a round of it feels like to play.\n\nA second paragraph: what makes it different from other games like it.\n\n## How to play\n1. The first thing a player does.\n2. The next thing.\n3. And so on, in the order they meet it.\n4. How the game ends or how you win.\n\n## Tips\n### The takeaway, in a few words\nWhy it works, in a sentence or two.\n\n### Another tip\nWhy that one works.\n\n### A third tip\nWhy that one works.\n\n## FAQ\n### A question a player would actually ask?\nA complete answer — it is shown on its own in search results.\n\n### Another question?\nAnother complete answer.\n\n### A third question?\nA third complete answer.\n```\n\nThe **Copy template** button puts exactly that skeleton on your clipboard, so you can fill it in\noffline and paste the finished thing back.\n\nThe reader is deliberately forgiving, because nobody should have to think about syntax to write a\nparagraph about their own game:\n\n- `## About`, `About:`, `**About**` and a bare `About` on its own line are all the same heading.\n- `# About` works too — one hash or two, it makes no difference.\n- \"How to play\", \"HowToPlay\", \"Instructions\" and \"Controls\" all mean the same section. So do \"FAQ\",\n \"FAQs\", \"Questions\" and \"Frequently asked questions\".\n- Steps can be numbered `1.`, bulleted with `-` or `*`, or just one per line.\n- In the FAQ, a line ending in a question mark is treated as a question even without the `###`.\n\nWhat it will not do is guess. Text before the first heading is ignored and it tells you so; a\n`##` heading it does not recognise ends the section above it rather than being swallowed\ninto it; and a\npaste with no headings at all is refused with an explanation rather than dumped into About as one\nenormous paragraph. After every paste it reports what it read — \"Read 3 paragraphs, 5 steps, 4 tips\nand 4 questions\" — along with anything it skipped. Read that line before you save.\n\nBoth routes end in the same place. The paste box only fills the fields; the fields are what get\nsaved, and what a player sees on your game's page is the same either way.\n\nA few things that will not get you past it, because the check looks for them:\n\n- One good paragraph pasted into all four sections. Every field needs its own writing.\n- Steps like \"Play the game.\" A step has to say something a player can act on.\n- \"Yes.\" as an FAQ answer. Answers are shown on their own in search results, so they have to stand\n alone.\n\nIf you want a sense of the target, open any of the games Miniant built itself and read the sections\nunder the hero — those are the reference, and they run 450–700 words.\n\n## Promote your game\n\nThe **Promote** tab on your game's page is how you ask Miniant to feature it — there is no\nself-serve checkout for this yet, every placement is booked by the Miniant team after a request.\nThree kinds of slot exist:\n\n- **Sponsored hero** — a featured slot in the home page's hero rotation.\n- **Promoted trending** — a \"Promoted\" pill on a tile at the head of the trending rail (one of at\n most two slots).\n- **Launch package** — a bundled 7-day hero + trending placement plus a \"New\" pill on your game's\n detail page, for a new release.\n\nSubmit a request from the Promote tab (pick the surface, add a note) and Miniant reviews it. Your\nrequest history on the same tab shows each request's status — `pending`, `accepted`, or `declined`\n— and any review note. **Acceptance is not the same as booking**: an accepted request means Miniant\nagreed to place your game, but the placement itself (dates, surface) is booked separately once\ndetails are worked out. Every live and past placement for your game is shown on the same tab.\n\nEvery placement the portal shows a player is labelled — \"Sponsored\" on the hero, \"Promoted\" on a\ntrending tile, \"New\" on a launch — the same way a real ad would be; nothing about promotion is\nhidden from players.\n\n**No price is shown to developers in the product.** Placement is currently priced and negotiated\noff-platform — if you're interested, reach out to the Miniant team directly rather than looking for\na rate card here.\n\n## Analytics\n\nThe **Analytics** tab on your game's page has two tiers:\n\n- **Free (everyone)** — a rolling overview: sessions per day, unique players per day, and median\n session duration, for up to the last 30 days.\n- **Pro (granted by the Miniant team)** — adds a player-retention curve (D1/D3/D7, over the last\n 14 daily cohorts), a session funnel (started → reported a result → won), and category benchmarks\n (how your game's median duration, sessions-per-player, and D1 compare against other published\n games in the same category — aggregate numbers only, never a named competitor).\n\nFree-tier developers see a locked \"Ask us about Pro analytics\" panel where the Pro content would\nbe — Pro is not self-serve; it's a tier the Miniant team switches on for a developer account. Reach\nout if you'd like it enabled.\n\n## Earnings\n\nEvery game earns from the portal's own ad placements around it, plus any `Miniant.ads` calls of its\nown (see [Video ads](/guide/sdk/video-ads)). The top-level **Earnings** page (in the portal's main\nnavigation, not on a per-game tab) is where you see what that's worth:\n\n- A monthly statement per game, showing billable impressions, your 70% ad share, your 70% share of\n the Tickets spent on your [store items](/guide/sdk/store-items), and the total.\n- A lifetime total and an unpaid balance across every game you publish.\n- A **$50 payout threshold** — an unpaid balance below $50 stays accruing; once it crosses the\n threshold it's eligible for payout.\n- A payout-details form (payout method + details) you fill in once, so Miniant knows where to send\n a payout.\n\nPayouts are **manual and out-of-band** — the Miniant team pays you outside the platform (bank\ntransfer, PayPal, or similar) and then marks the statement paid on their side, at which point your\nEarnings page reflects it with a payout reference. There is no in-app \"cash out\" button, for you or\nfor players — this mirrors the platform-wide rule that players never cash out either, just applied\nto developer earnings instead of player Tickets. See [Monetization](/guide/publishing/monetization) for the\nfull rev-share terms.\n\n## What you can't do from here\n\nOnly Miniant owners can approve, reject, or roll back a build — the developer portal is\nread-only for that part of the pipeline. You can always see build and deployment history\n(**Build & Publish**) and the full activity log (**Activity**) for games you're invited to.\n\n## Why your build might fail\n\n**Hard failures** — the build cannot proceed until these are fixed:\n\n| What you'll see | What it means | How to fix |\n|---|---|---|\n| Missing `miniant.json` in build output or repo root | Neither location has a manifest | Add `miniant.json` to your build output or repo root (see the [manifest reference](/guide/requirements/technical)) |\n| `\"miniant.json\" is invalid: ...` | The manifest doesn't match the required shape | Fix the named field per the [manifest reference](/guide/requirements/technical) — e.g. a mode's `players` range, or a missing `network` block when `relay: true` |\n| `declares entry \"X\", which does not exist in the archive` | Your `entry` filename doesn't match a real file, or the case differs | Fix the filename/case in `miniant.json`, or make sure the build actually emits that file |\n| A static reference (`<script src>`, `<link href>`, `<img src>`, etc.) doesn't resolve in the build output | Your entry HTML points at a file, by literal relative path, that isn't actually in the output | Fix the path or case, or make sure the asset is actually copied into your build output |\n| Sensitive files detected (`.env*`, key/cert files, `.git`/`.ssh` directories, or private-key content found in a small text file) | Something that looks like a secret is in your build output | Remove it from the output (check your build script and `.gitignore`), and rotate any credential that may have leaked — these would be published to a public URL |\n| Unresolved Git LFS pointer file | An asset tracked in LFS never had its real content pushed | `git lfs push --all`, then request publish again |\n| Native-executable file extension present | A file like `.exe`, `.dll`, `.so`, `.dylib`, etc. is in your build output | Remove it — only web-runtime files belong in a build output |\n| Zip-bomb characteristics detected | Your build output is anomalously compressible or deeply nested | Check for an accidentally-bundled archive or duplicated data from your build script |\n| Archive over the size limit | Your build output exceeds the artifact size cap | Trim assets, or check you're not shipping unused source or debug files |\n| Malware scan flagged a file | The scan found something it treats as malicious | Remove or replace the flagged file; contact Miniant if you believe it's a false positive |\n| Archive is empty / build did not produce output | Your build script didn't write anything to the configured output directory | Confirm the build output directory (see \"Prepare your repository\" above) matches where your build script actually writes |\n\n**Warnings** — shown to the reviewer at approval, but they don't block the build:\n\n| Warning | What it means |\n|---|---|\n| No lockfile found | Your build isn't pinned to exact dependency versions — see \"Prepare your repository\" above |\n| Service worker registration detected | Discouraged — see \"Common gotchas: it worked locally\" below |\n| External payment-SDK signal detected (e.g. a Stripe/Razorpay/PayPal/Paddle script, or an inline `stripe.confirmCardPayment`/`razorpay.open`/`data-checkout` marker) | Your build references a known external payment integration point. External payment flows are banned by platform policy — all commerce must go through Miniant's own tickets/SKUs (see [what you must not build](/guide/requirements/technical) and [In-game purchases](/guide/sdk/purchases)). This never auto-rejects your build (a tier-2 game legitimately talks to its own server), but the reviewer sees exactly what matched and where, and will ask about it |\n\n## What the reviewer sees\n\nAfter validation passes, a headless browser loads your game with a minimal SDK-handshake harness\nand waits up to 30 seconds for `Miniant.ready()`. Whatever happens during that window — console\noutput, plus a screenshot of the running game — is shown to the human reviewer alongside your build\nbefore they approve or reject it. This never fails the build by itself; it exists so the reviewer\n(and you — the same information appears on your build card) can see what a player is about to see,\nnot just that validation passed.\n\nPractically: make sure your game reaches a visible first frame and calls `Miniant.ready()` (see the\n[SDK reference](/guide/sdk)) promptly, and keep an eye on your own console output — a wall of\nerrors in the reviewer's view doesn't fail your build automatically, but it doesn't help your case\neither.\n\nThe reviewer also confirms the **handoff** during that same window: the portal's own splash should\ncover the game right up until `Miniant.ready()` fires, and then hand off directly to a screen the\nplayer can actually use — a menu, a start screen, or gameplay itself. If the screenshot instead\nshows a loading bar, a percentage, a spinner, or a boot cover your game drew itself, that fails\nreview even though validation passed — see [No game-owned loading\nUI](/guide/requirements/technical#no-game-owned-loading-ui). If your game has a large asset\ndownload, the reviewer expects to see it calling `Miniant.setLoadProgress()`\n([reference](/guide/sdk/game#miniantsetloadprogressfraction)) rather than just sitting on a blank\nor frozen frame for most of that 30-second window.\n\n## Common gotchas: it worked locally\n\nYour own machine is not `<your-slug>.miniant.games`. The usual gap between \"plays fine locally\" and\n\"broke once hosted\":\n\n| Works locally, breaks hosted | Why | Fix |\n|---|---|---|\n| API calls or asset URLs hardcoded to `http://localhost:PORT` | That address only exists on your machine | Use relative paths, or a real production endpoint |\n| Assets 404 only after publish | A GitHub Pages–style base path (e.g. a bundler `base: '/repo-name/'` config) got baked into the build | Miniant doesn't serve your game from a subpath — set the base path to `/` (or drop the GitHub Pages–specific config) for the Miniant build |\n| An asset loads for you but 404s once hosted | Case mismatch between the reference and the actual filename — macOS's default filesystem is case-insensitive, so `Player.png` vs. `player.png` never shows up locally | Match case exactly between code/HTML references and actual filenames |\n| Players see an old version after you publish an update | A service worker cached the previous build, or HTML still points at unhashed asset filenames that browsers cached as immutable | Don't register a service worker in your Miniant build (warned at validation). Content-hash JS/CSS filenames in your build so a fresh HTML entry can point at new URLs — the host revalidates HTML on every load. The publish pipeline now warns (non-blocking) when your HTML references a JS/CSS/WASM filename that doesn't look content-hashed |\n\n## Anti-cheat questionnaire — required before every publish request\n\nRequesting a publish opens a short questionnaire on the **Build & Publish** tab — it's prefilled\nfrom your last submission, so you only have to change what's changed. Miniant is **rules-blind**:\nit can enforce the bounds you declare (score range, session length, scoring rate) but it can never\njudge whether a specific move was legal. Your answers do two things: **score direction** (higher\nis better, lower is better, or no score) picks the kind of leaderboard your game gets, and the\n**Limits** below flag a result that falls outside them as suspicious.\n\nThe questionnaire asks two things: **how a result is verified** (the client reports its own\nresult, deterministic replay, or your own server validates it) and **score direction**, plus one\nattestation tick. How a result is verified does not change what your game is eligible for: every\ngame's results count toward leaderboards, tournaments and rewards the same way. A game whose\nclient reports its own result shows a \"self-reported\" note on its leaderboard, and only a game\nwhose own server validates results may import progress. Your\nattestation is recorded under the email you are signed in with.\n\nAn optional, collapsed **Limits** section lets you declare a bounded score (min and max), a session\nlength range in seconds (fill in both ends or neither), and a maximum score per minute. Anything\nyou leave empty is simply not checked. Everything else the platform needs comes from\n`miniant.json`: the multiplayer message rate is `network.expectedMessagesPerMinute`, and whether\nthe game sells anything is read from `store.skus`. A Miniant reviewer sees your answers — and any\nchanges from your last approved submission — on the approve screen. Answer honestly: a false\ndeclaration is grounds for the game being banned.\n\n"},{"slug":"publishing/monetization","title":"Monetization","section":"Publishing","agentSummary":"Developer-facing monetization: ad and store-item revenue share (70/30, monthly statements), paid promotion, analytics tiers. Business terms only.","checklist":[],"body":"# Monetization\n\nThe developer-facing summary of how money flows between your game and Miniant — terms and process,\nnot integration. For the SDK surfaces that feed this, see [Video ads](/guide/sdk/video-ads) and\n[Cosmetics](/guide/sdk/cosmetics). For the day-to-day tabs, see\n[Publishing your game](/guide/publishing)'s Promote, Analytics, and Earnings sections. Developer\ncosmetics aren't a separate system worth its own section here: a cosmetic you sell is just an\n`entitlement`-kind SKU like any other durable unlock — see [Cosmetics](/guide/sdk/cosmetics) for\nthe full contract.\n\n## Terms\n\n**Ad revenue share.** Every ad served around your game is recorded — the portal's own placements\n(rematch, XP doubler, spend waiver, matchmaking wait) and any `Miniant.ads` call of\nyour own (see [Video ads](/guide/sdk/video-ads)). Only `\"shown\"`/`\"rewarded\"` outcomes from a real\nad network are billable — house cross-promo earns nothing, since no advertiser paid for it.\n\n- **Split: 70/30 in your favour** of billable revenue.\n- **Statements are monthly**, drafted on the 1st for the previous month, lifecycle\n draft → final → paid; only a `final` statement can be marked paid.\n- **Payout threshold: $50**, accrued across every game you publish, on ad share and item share together.\n- **Payouts are manual, out-of-band** (bank/PayPal) — no in-app cash-out, same as players. See\n [Earnings](/guide/publishing#earnings) for where you view statements and enter payout details.\n\n**Store item revenue share.** Items you declare for your game (see [Store items](/guide/sdk/store-items))\nearn the same **70/30 in your favour**, paid from a monthly pool:\n\n- **Ticket value** = net Ticket-pack revenue for the month ÷ all Tickets spent that month.\n- **Your share** = 70% × the Tickets spent on your items × Ticket value.\n- **Purchases by your game's own publishers don't count.**\n- It appears on the same monthly statement as the ad share, as a separate column, and a draft month\n is labelled *estimate* until the month's pack revenue is final.\n\n**Paid promotion** — a sponsored hero slot, a \"Promoted\" trending tile, or a launch package — has\nno self-serve checkout: you request from your game's Promote tab, Miniant reviews\n(accepted/declined, with a note), and an accepted request is booked separately. Every placement a\nplayer sees is labelled — \"Sponsored\", \"Promoted\", or \"New\". Pricing is negotiated off-platform.\nSee [Publishing your game](/guide/publishing#promote-your-game) for the request flow.\n\n**Analytics** has two tiers. Free: sessions/day, unique players/day, median session duration, last\n30 days. Pro (granted by the Miniant team, not self-serve): adds retention, a session funnel, and\ncategory benchmarks. See [Publishing your game](/guide/publishing#analytics) for what each tier\nshows.\n"},{"slug":"sdk","title":"SDK reference","section":"SDK","agentSummary":"Overview and full call reference of @mag/game-sdk, the only channel a game has to talk to the portal: identity, results, saves, relay, wallet, ads, and spectating.","checklist":[{"id":"sdk.script-tag-only","text":"Load the SDK only via the official script tag from www.miniant.games; never vendor it or npm-install it.","verify":"validator:sdk.script-tag"},{"id":"sdk.init-mandatory","text":"Call Miniant.init({ sdkVersion: 1 }) as the first SDK call and use the PlayContext it resolves with.","verify":"validator:sdk.init-called"},{"id":"sdk.ready-mandatory","text":"Call Miniant.ready() once your first frame is drawn, not before.","verify":"validator:sdk.ready-called"},{"id":"sdk.report-result-once","text":"Call Miniant.reportResult() exactly once per session, at game end, on every end path.","verify":"validator:sdk.report-result-called"},{"id":"sdk.terminate-stop","text":"Stop simulating and stop reporting the moment the terminate event fires.","verify":"validator:sdk.terminate-handled"},{"id":"sdk.iframe-css","text":"Set html, body { height: 100%; margin: 0; } so your background fills the iframe viewport.","verify":"manual"},{"id":"sdk.report-progress-60s","text":"Send Miniant.reportProgress() heartbeats whenever a session can run past 60 seconds.","verify":"manual"}],"body":"# SDK reference\n\n## The SDK — how your game talks to the portal\n\nYour build never runs standalone. It is loaded inside a portal-owned iframe at\n`<your-slug>.miniant.games`, and the **only** sanctioned way to talk back to the portal — find out\nwho's playing, report a result, send a progress heartbeat — is `@mag/game-sdk`. There is no other\nintegration surface: a game that never calls `Miniant.init()` never receives a play session, and a\ngame that never calls `Miniant.reportResult()` never has its result counted for anything.\n\n**Common pitfall — your iframe is your game's entire viewport.** Set `html, body { height: 100%; margin: 0; }`\nin your own stylesheet. Without it, `body` is only as tall as its visible content, and any background\ncolor or image you apply there only paints that short strip instead of filling the frame — leaving\nblank space or the portal's own background showing through around your game. This isn't a Miniant\nAPI, just plain CSS every game embedded this way needs.\n\nHow to get it:\n- **UMD script tag** — the way to integrate today, for any build (bundler or not, including a\n Godot HTML5 export): `<script src=\"https://www.miniant.games/sdk/v1.js\"></script>`, which sets a\n global `window.Miniant`. Commit to loading it from that URL; don't vendor a copy into your\n repo, so SDK fixes reach your game without a republish. `MiniAnt` remains a permanently\n supported alias for `Miniant`, so builds already published under the old spelling keep working.\n On `miniant.games` the script only starts inside the portal: the game must be framed by\n miniant.games and `Miniant.init()` must complete the portal handshake. For development it also\n runs, unframed, on `http(s)://localhost:<port>` and `*.localhost:<port>` (the port is required),\n loopback and private LAN addresses (`192.168.x.x`, `10.x.x.x`). Anywhere else (another host, an\n app WebView, a `file://` page) it shows a \"Play this game on Miniant\" screen and `Miniant.init()`\n never settles, so test on one of those hosts rather than a public preview URL. Inside the portal,\n a handshake that fails shows a \"Couldn't connect to Miniant\" screen instead of rejecting.\n- **npm / ESM** (`import { Miniant } from \"@mag/game-sdk\"`) — **not yet available**: the package\n isn't published to a public registry yet. Use the UMD build until it is; this guide will be\n updated when that changes.\n\nAll communication is `postMessage` between your iframe and the portal shell that frames it, and\nevery message is origin-checked on both sides. Your iframe's origin is a real, checkable string\n(never `null`) — the portal's sandboxing model guarantees this by construction.\n\n### Quick start\n\n```html\n<script src=\"https://www.miniant.games/sdk/v1.js\"></script>\n<script>\n (async () => {\n const context = await Miniant.init({ sdkVersion: 1 });\n\n // context.mode, context.participants, context.player tell you everything about this play\n // session. Do not build your own mode picker, friend list, or lobby — the portal already\n // decided all of it (see what you must not build).\n\n await Miniant.ready(); // first frame is drawn; portal hides its loading state, session clock starts\n\n // ... gameplay ...\n\n await Miniant.reportProgress({ checkpoint: \"level_2\", score: 340, tick: 1024 });\n\n // ... game ends ...\n\n const { accepted, verdict } = await Miniant.reportResult({\n outcome: \"win\",\n score: 1200,\n durationMs: 45_000,\n });\n })();\n</script>\n```\n\n(With a bundler, the same calls work identically off the `window.Miniant` global — the ESM\n`import` form arrives when the package is published to npm, see \"How to get it\" above.)\n\n### Calls you can make\n\n| Call | Required? | What it does |\n|---|---|---|\n| `await Miniant.init({ sdkVersion: 1 })` | **Mandatory** | Resolves with the `PlayContext` (see [Game](/guide/sdk/game)). Rejects after 10s if your game wasn't opened through the portal. |\n| `Miniant.ready()` | **Mandatory** | Call once your first frame is drawn. The portal hides its loading state and the play session's clock starts here. See [Game](/guide/sdk/game). |\n| `Miniant.reportResult({ outcome, score?, durationMs, perParticipant?, detail? })` | **Mandatory at game end** | `outcome` is one of `\"win\" \\| \"loss\" \\| \"draw\" \\| \"abandoned\" \\| \"completed\"`. The portal checks it against your declared bounds and returns `{ accepted, verdict }`. Call it exactly once per session — a duplicate call is rejected with `SESSION_ALREADY_SUBMITTED`. See [Scores and results](/guide/sdk/scores). |\n| `Miniant.reportProgress({ checkpoint, score?, tick? })` | **Required if a session can run past 60s** | A periodic heartbeat. Feeds the monotonicity and score-rate checks — a long session with zero progress reports is itself a signal, so don't skip this even though nothing \"requires\" a specific interval. See [Scores and results](/guide/sdk/scores). |\n| `Miniant.on(\"pause\" \\| \"resume\" \\| \"terminate\", cb)` | **Mandatory to handle `\"terminate\"`** | The portal can terminate your session — maintenance, a ban, or expiry — at any time, including mid-play. Your game must stop simulating and stop reporting the moment `\"terminate\"` fires. See [Game](/guide/sdk/game). |\n| `Miniant.exit()` | Optional | Asks the portal to leave. The portal owns the exit UI regardless of whether you call this. See [Game](/guide/sdk/game). |\n| `Miniant.requestRematch(options?)` | Mandatory **iff** your game has a definite ending | The game-over screen's \"Rematch\" button, in **every** mode — not only room-backed. The portal, never your game, decides what the rematch is (a successor room, or a fresh normally-charged session) and never restarts locally. Valid only once `reportResult()` has resolved. **Exception:** `progression: \"levels\"` — the next level is free for the rest of the sitting and starts without a reload or splash; plain `requestRematch()` is enough, `{ inPlace: true }` is optional (2026-09-25). See [Game](/guide/sdk/game#the-game-over-screen)'s \"The game-over screen\" and [Scores and results](/guide/sdk/scores#reporting-results-is-mandatory) for the levels contract. |\n| `Miniant.state.save(blob, meta?)` / `Miniant.state.load()` / `Miniant.state.onSaveRequest(cb)` | Optional | `save`/`load` persist up to 64KB of JSON, scoped to `(your game, this player, this session's mode)`; `meta?` is an optional `{ label?, progressPct? }` second argument to `save()` for the portal's own \"Resume — {label}\" card. `onSaveRequest(cb)` registers a callback the portal calls when it wants your current state persisted right now (returns an unsubscribe fn, same convention as `on()`). See [Data](/guide/sdk/data). |\n| `Miniant.net.*` | Mandatory **iff** `capabilities.relay` is `true` in your manifest | `connect()`, `send(payload, {reliable})`, `sendTo(peerId, payload)`, `on(\"message\" \\| \"peerJoined\" \\| \"peerLeft\" \\| \"roomClosed\" \\| \"throttled\" \\| \"spectatorCount\" \\| \"syncRequest\" \\| \"spectatorMessage\" \\| \"connected\" \\| \"disconnected\")`, `isConnected()`, `close()`. The payload is opaque to Miniant — it's never inspected. `on(\"syncRequest\", cb)` is mandatory for a hand-rolled `realtime` game (a `turn_based` game using `createTurnKit` gets it answered automatically). **Live** — see [Multiplayer relay](/guide/sdk/multiplayer) for the full contract. |\n| `Miniant.createTurnKit({ seats, mySeat, teams?, getSnapshot, applySnapshot, onTurnStart?, onPeerAbandoned?, unavailable? })` | Mandatory **iff** your mode declares `sync: \"turn_based\"` | Layers seat rotation, full-snapshot broadcast, and reconnect recovery on top of `Miniant.net` — gated on `capabilities.relay` the same way `net.*` is. Pass `unavailable: context.mode.unavailable` and the kit also handles auto-takeover/forfeit for you — omit it and none of that machinery runs. See [Turn Kit](/guide/sdk/turn-kit) for the full contract. |\n| `Miniant.net.deck(name, { kit?, onViolation? })` | Optional; use it whenever an online room game has hidden cards | A sealed deck held by the relay: server shuffle, each seat receives only its own ids, a play of a card the seat does not hold is refused. Gated on `capabilities.relay`. See [Sealed deck](/guide/sdk/sealed-deck). |\n| `Miniant.reportPeerAvailability({ seat, state, deadlineMs? })` | Required **iff** your manifest declares `unavailable` on the mode **and** you're not using `createTurnKit` (which calls this for you automatically); recommended for any relay-backed online mode even when `unavailable` isn't declared | Reports one peer-availability transition (`\"grace\"` \\| `\"returned\"` \\| `\"auto\"` \\| `\"retired\"` \\| `\"forfeit\"`) so the portal's own grace/auto-pilot/forfeit banner chrome can render. Gated on `capabilities.relay`. See [Multiplayer](/guide/sdk/multiplayer#absent-players) for the full recipe a hand-rolled game implements. |\n| `Miniant.wallet.getBalance()` | Mandatory **iff** `capabilities.wallet` is `true` | Read-only current ticket balance. See [Purchases](/guide/sdk/purchases). |\n| `Miniant.wallet.purchase({ skuId, quantity? })` / `.getEntitlements()` / `.consume({ skuId, units, idempotencyKey })` | Mandatory **iff** you sell anything | Buy, read, and burn manifest-declared SKUs — see [Purchases](/guide/sdk/purchases) for the full contract. **You can never charge a player an amount your own code makes up**: `purchase()` only ever buys a SKU the portal itself has reviewed and priced, and the portal renders its own confirm dialog from that registry data, never from anything your game passes at runtime. |\n| — | — | `getEntitlements()` only ever answers for the **local player**. To learn what an OPPONENT owns (e.g. to render their golden board), read `context.participants[].entitlements` instead — a static array of owned entitlement SKU ids per seat, present from `init()` onward with no relay round-trip. See [Cosmetics](/guide/sdk/cosmetics). |\n| `Miniant.wallet.on(\"entitlements_changed\", cb)` | Optional | Fires after any successful purchase (yours or, in principle, a refund) — re-call `getEntitlements()` to refresh. `\"balanceChanged\"` is not implemented yet. See [Purchases](/guide/sdk/purchases). |\n| `Miniant.wallet.openStore(sku?)` | Not implemented yet | Reserved for Phase 6 (buying tickets with real money) — throws `NOT_IMPLEMENTED` even with `capabilities.wallet` declared. See [Purchases](/guide/sdk/purchases). |\n| `Miniant.ads.requestInterstitial(reason?)` / `Miniant.ads.requestRewarded()` | Optional | Ask the portal for an ad; the portal decides. Interstitials are spaced at least 3 minutes apart and never shown in a player's first session, so handle `\"skipped\"` / `\"unavailable\"` without stalling — the portal pauses and resumes your game around a shown ad. `requestRewarded()` resolving `\"rewarded\"` means \"double the XP / quest progress you award on the results screen\"; it never grants Tickets. No capability gate. See [Ads](/guide/sdk/video-ads). |\n| `Miniant.spectate.publishState(snapshot)` | Mandatory **iff** `capabilities.spectate` is `true` **and** your session is roomless (solo/vs-AI) | Pushes a JSON-serializable state snapshot (≤16KB serialized) for any watchers to render. Throws `CapabilityNotGrantedError` immediately if `capabilities.spectate` isn't declared — checked before the room/roomless branch, so this holds even in a room session, where a *granted* call is instead a safe, documented no-op. See [Spectating](/guide/sdk/spectating) for the full contract, including why you can call it unconditionally from one shared code path once the capability is declared. |\n\nAny of the capability-gated calls (`net.*`, `wallet.*`) throws `CapabilityNotGrantedError` immediately, before even reaching the \"not implemented yet\" state, if your manifest didn't declare that capability — check `capabilities` in `miniant.json` ([manifest reference](/guide/requirements/technical)) first.\n\n## In this section\n\n- [Game](/guide/sdk/game) — the session lifecycle: `init()`, `ready()`, pause/resume/terminate, `exit()`, and the full `PlayContext` shape.\n- [User](/guide/sdk/user) — player identity as carried inside `PlayContext`, and what the SDK does not offer.\n- [Data](/guide/sdk/data) — saving and loading a player's progress.\n- [Scores and results](/guide/sdk/scores) — reporting results and progress to the portal.\n- [Purchases](/guide/sdk/purchases) — selling SKUs through the portal's wallet.\n- [Cosmetics](/guide/sdk/cosmetics) — the two cosmetic systems: entitlement SKUs you sell, and the portal's own catalog.\n- [Receipts](/guide/sdk/receipts) — verifying purchases from your own backend.\n- [Multiplayer](/guide/sdk/multiplayer) — the relay for room-based online play.\n- [Turn Kit](/guide/sdk/turn-kit) — a seat-rotation helper for a `sync: \"turn_based\"` online mode, layered on `Miniant.net`.\n- [Spectating](/guide/sdk/spectating) — letting a friend watch a match, read-only.\n- [Video ads](/guide/sdk/video-ads) — showing video ads in your game.\n- [Banners](/guide/sdk/banners) — banner ad placement in your game.\n- [Leaderboards](/guide/sdk/leaderboards) — how scores surface on the portal's own leaderboards.\n- [Engines](/guide/sdk/engines) — using the SDK from popular game engines.\n"},{"slug":"sdk/game","title":"Game","section":"SDK","agentSummary":"Session lifecycle: init() resolves PlayContext, ready() at first interactive screen starts the clock, setLoadProgress() reports load, pause/resume/terminate, exit()/requestRematch(), game-over screen.","checklist":[{"id":"game.init-first-call","text":"Call Miniant.init() as your game's first SDK call and drive setup entirely from the PlayContext it resolves with.","verify":"validator:sdk.init-called"},{"id":"game.seats-match-playercount","text":"Set your board up for exactly context.mode.playerCount seats; never assume the maximum seat count.","verify":"manual"},{"id":"game.ready-after-first-frame","text":"Call Miniant.ready() once your game is fully loaded and its first interactive screen is painted — not merely once a first frame is drawn — starting the session clock.","verify":"validator:sdk.ready-called"},{"id":"game.no-own-loading-ui","text":"Never show your own loading bar, spinner, percentage, or boot cover — the portal's splash covers the entire load, and your game must hand off directly to a playable screen.","verify":"manual"},{"id":"game.load-progress-reported","text":"If your game has a large asset download, report loading progress with Miniant.setLoadProgress() so the portal's splash can wait for you instead of giving up.","verify":"validator:sdk.load-progress-declared"},{"id":"game.terminate-stop-immediately","text":"Stop simulating and stop reporting the instant the terminate event fires, especially for platform-initiated reasons.","verify":"validator:sdk.terminate-handled"},{"id":"game.rematch-only-after-report","text":"Call Miniant.requestRematch() from your Rematch CTA in every mode, only after reportResult() resolved, and never restart the match locally.","verify":"manual"},{"id":"game.gameover-two-ctas","text":"For a game with a definite ending, render your own game-over screen with exactly two CTAs labelled Rematch and Exit.","verify":"manual"},{"id":"game.no-local-restart-on-rematch","text":"Never restart a match locally when the player taps Rematch — each round is its own portal session, and a game that loops rounds inside one session fails review.","verify":"manual"},{"id":"game.respect-settings","text":"Read context.settings.sound (effects), music (background music; a game with music stops it live when it turns off) and vibration at init, and apply live updates from the settings_changed event.","verify":"manual"},{"id":"game.safe-area-insets","text":"Keep interactive controls out of context.ui.safeAreaInsets.","verify":"manual"},{"id":"game.avatar-anchors","text":"Required with an online mode: call Miniant.ui.setAvatarAnchors() with each seat's avatar centre in percentages (0-100) of your frame, and again on every layout change.","verify":"validator:sdk.avatar-anchors"},{"id":"game.no-own-banner-chrome","text":"Show away/auto-pilot state in your own seat UI if your game needs it — the portal no longer shows availability banners over the game; it only announces a final forfeit or departure.","verify":"manual"},{"id":"game.sdk-script-tag-only","text":"Load the SDK only via the official script tag https://www.miniant.games/sdk/v1.js; never vendor it or npm-install it.","verify":"validator:sdk.script-tag"},{"id":"game.iframe-css","text":"Set html, body { height: 100%; margin: 0 } so your background fills the iframe viewport.","verify":"manual"}],"body":"# Game\n\nYour game's play session has a lifecycle the portal drives from end to end: it opens your game,\nhands you a `PlayContext` describing the session, waits for you to call `Miniant.ready()`, and — eventually —\nends the session, whether your game called `exit()` or the portal terminated it. This page covers\nthat lifecycle and the full shape of the `PlayContext` object. For player identity carried inside\n`PlayContext`, see [User](/guide/sdk/user). For reporting results and progress during and after a\nsession, see [Scores and results](/guide/sdk/scores).\n\n## `Miniant.init({ sdkVersion: 1 })`\n\n**Mandatory.** This is the first call every game makes. It resolves with the `PlayContext` below —\nmode, participants, player identity, capabilities, everything the portal decided about this\nsession. It rejects after 10s if your game wasn't opened through the portal (for example, opened\ndirectly by URL instead of inside the portal-owned iframe).\n\n## `PlayContext` — what `init()` hands you\n\nThis is the whole of \"the portal decided everything, now just play\":\n\n```ts\n{\n protocolVersion: 1,\n game: { slug, version },\n mode: { id: \"online_friends\", variant: \"blitz\" | null,\n playerCount: 4 | null, // how many seats to set up — see below\n teamUp: true | undefined, // two teams of two — see below\n params: {} },\n spectator: true | undefined, // see \"Spectating\" below — undefined for a real seat\n spectateAnchorSeat: number | undefined, // see \"Spectating\" below — present only alongside spectator: true\n player: { id, displayName, avatarUrl, isGuest, locale, seat, countryFlag },\n participants: [{ id, displayName, avatarUrl, seat, isLocal, isFriend, countryFlag,\n simulated: true | undefined, // `simulated` — see `online_random` note below\n team: 0 | 1 | undefined }], // only when mode.teamUp — see below\n room: { roomId, hostSeat, seatCount, teamUp: true | undefined,\n relayUrl, relayToken, maxMessagesPerMinute } | null,\n session: { sessionId, seed, startedAt, expiresAt },\n capabilities: { relay, wallet, saveState, spectate: boolean | undefined }, // mirrors your manifest\n entry: { paid: false, entryFee: null }, // portal charged it, if any — you never charge\n ui: { showExitButton, safeAreaInsets }, // see \"Leave room for the portal's own chrome\" below\n settings: { sound, vibration, music } | undefined // see \"Respect the player's sound, music and vibration\" below\n}\n```\n\nThis block is exhaustive — every field either side of the SDK actually sends is listed above.\n`capabilities.spectate` mirrors what you declared in `miniant.json` ([manifest reference](/guide/requirements/technical)):\nif your manifest didn't declare it, it's `undefined` here too — the same additive, back-compat\npattern `spectator` and `simulated` already use elsewhere in this shape.\n\n`displayName` (on `player` and every entry in `participants`) is the player's portal profile\nidentity — their chosen display name, falling back to their unique portal username. Render it\nas-is: never derive a name yourself, never ask the player who they are, and never persist it in\nsaved state (players can change it; re-read it from `init()` each session). See\n[User](/guide/sdk/user) for the full identity rules.\n\n`countryFlag` (on `player` and every entry in `participants`) is optional — an ISO 3166-1 alpha-2\ncountry code, or `null`. `null` means the player either chose not to share their country flag, or\nhasn't set a country at all — never treat it as an error. Render a flag only if your game already\nsupports flag rendering; do nothing otherwise. Never guess a player's country any other way\n(IP geolocation included) if `countryFlag` is `null` — that consent decision is the player's, made\nin their portal profile, and the portal will never hand you a value it hasn't been told to share.\n\n`room` is populated for `online_friends`/`online_random` sessions (`relayUrl`/`relayToken` are what\n`Miniant.net.connect()` uses under the hood — see [Multiplayer](/guide/sdk/multiplayer)) and `null`\nfor every other mode. Solo, vs-AI, and local-multiplayer sessions always hand you a single local\nparticipant.\n\nIn `online_random`, an entry in `participants` may carry `simulated: true`. This means the portal\ncould not find a human opponent and filled the seat with a portal-owned AI identity — your game\nmust drive that seat with its own local AI logic while presenting it to the player exactly like\nany other remote participant, using the `id`/`displayName`/`avatarUrl`/`countryFlag` you were\ngiven. **Never reveal that seat as AI** (no \"playing vs. bot\" label, no different visual treatment)\n— from the player's perspective it is a normal online opponent. `simulated` is omitted for every\nreal participant, which is every participant you've seen before this field existed — treat its\nabsence as `false`.\n\n### `mode.playerCount` — how many seats to set up\n\nThe player chooses this in the mode picker before your game loads, from the counts your manifest\ndeclares (`players` / `playerCounts` — see the [manifest reference](/guide/requirements/technical)).\nSet your board up for exactly this many seats.\n\n- **`vs_ai`** — this is **total** seats and you fill the ones the human doesn't. `4` means one\n player against three AI opponents.\n- **`online_friends` / `online_random`** — it agrees with `room.seatCount`. Either is fine to read.\n- **`null`** — either the mode declares a single possible count (nothing to choose), or the session\n predates this field. Fall back to your own default, exactly as you did before it existed.\n\nNever assume the maximum. A game that always seats four will seat four for a player who asked for\ntwo, and the portal has already told the other players how many to expect.\n\n### `participants[].team` — who is on whose side\n\nPresent on every participant when `mode.teamUp` is set: `0` or `1`, two per side. Absent for a\nfree-for-all session.\n\n**This tells you WHO partners whom, never WHERE they sit.** The portal owns the membership half\nbecause only it knows who was invited — a player who teamed up with a friend must actually get that\nfriend as their partner, and in Online Random a pre-formed pair must land on the same side. Seating\nstays yours, because the arrangement is game-specific: Ludo seats partners diagonally, Carrom pairs\nacross the ends.\n\nSo read `team` to learn the sides, then place them however your board works. **Do not infer teams\nfrom seat numbers** — the portal makes no promise about seat order. (Today the portal happens to\ncompute `team` by seat parity — seats 0/2 on team 0, seats 1/3 on team 1 — but that's an\nimplementation detail behind the field, not a contract: read `team`, never derive it yourself.) A\n`sync: \"turn_based\"` game using [Turn Kit](/guide/sdk/turn-kit) gets a `kit.teamOf(seat)` helper\nbuilt from exactly this field, rather than reading `team` off `participants` directly.\n\n### `mode.teamUp` — two teams of two\n\n`true` when this session is played as two teams of two rather than free-for-all. Only ever set at\nfour seats. Absent or `false` is free-for-all, which is every session before this existed.\n\n**You only ever receive this if your manifest declares it.** Team Up is opt-in per mode\n(`\"teamUp\": true`, see [Multiplayer](/guide/requirements/multiplayer)) — the portal offers the\ntoggle for declaring modes only, so a game that never declares it will never see `mode.teamUp` set\nand needs none of the handling below.\n\n**Read `mode.teamUp`, not `room.teamUp`.** `room` is `null` for `vs_ai` and for an `online_random`\nsession the portal filled with AI, and both of those can be team games — so a game reading only the\nroom field sees nothing for exactly the modes teams were added to support. `room.teamUp` still\nmirrors this for room sessions and is safe to keep reading, but it can never be the whole answer.\n\nTeam Up appears in four places, and your game handles all four the same way — read `mode.teamUp`,\nread each participant's `team`, seat them your way:\n\n- **Play with Friends** — the host picks Team Up for a private four-seat lobby.\n- **Vs AI** — you and an AI partner against two AI opponents. `room` is `null`; you create every\n seat the human doesn't hold, and `team` tells you which of them is your partner.\n- **Online Random** — matched into a four-seat team game. You may have queued alone or as a\n pre-formed pair with a friend; either way the portal guarantees a friend you queued with is on\n your side. Seats it could not fill with humans carry `simulated: true` and are AI — and as\n always, never reveal that to the player.\n- **Pass & Play** — four people on one device, two per side. `room` is `null` here too, so\n `mode.teamUp` is again the only field that can answer.\n\nReport the result as your game normally does. The portal doesn't model teams in scoring — if a\nteam win means both partners won, say so through `reportResult`'s `perParticipant`.\n\n## `Miniant.ready()`\n\n**Mandatory.** Call this once your game is fully loaded and its first **interactive** screen is\npainted — not once your first frame is drawn. Those are different moments, and the gap between\nthem is exactly what this contract exists to close. A menu, a start screen, or gameplay itself all\ncount as interactive and are fine to call `ready()` from. A loading bar, a percentage, a spinner,\nan \"assets downloading\" message, or a boot/splash cover of your own does **not** — see [Technical\nrequirements](/guide/requirements/technical#no-game-owned-loading-ui) for the full rule against\nbuilding any of those.\n\nFrom the moment the play shell mounts until you call `ready()`, the portal's own branded splash\nfully covers your game. The portal's splash is the *only* loading screen a player is allowed to\nsee. Calling `ready()` at your first frame instead of your first playable one hands the player\nstraight from that splash onto your own boot cover or loading bar — the player still sees a\nloading screen, it's just yours instead of the portal's, and the whole point of the portal owning\nthis moment is that every game's load looks like the same platform.\n\n**Get the order right:** hide your own cover, let a frame paint, *then* call `ready()`. Calling it\nbefore your cover is actually gone flashes your own loading UI in the gap between the portal's\nsplash retiring and your game's real screen appearing — which looks exactly like the bug this rule\nexists to prevent, even though you technically called `ready()` at the right moment.\n\nThe play session's clock starts the instant you call this, so don't call it before the player\nactually has something to do — a menu they can tap into counts, a spinner they're staring at\ndoesn't.\n\nIf your game has a large asset download and might take a while to reach that first interactive\nscreen, see `Miniant.setLoadProgress()` next — it keeps the portal's splash honest about how much\nlonger that's going to take, instead of the portal giving up on you.\n\n## `Miniant.setLoadProgress(fraction)`\n\nOptional. Reports how far your own asset loading has gotten, at any point before you call\n`ready()` — `fraction` is a number from `0` to `1`. Fire-and-forget: it returns nothing, never\nthrows, and is safe to call at high frequency straight from your asset loader's own progress\ncallback.\n\nCalling it does two things:\n\n- Turns the portal's splash from an indeterminate spinner into a determinate progress bar\n showing your real download progress. **The portal owns the wording on the bar** — there's no\n game-supplied label. That's deliberate: it's what keeps every game's load looking like the same\n platform, exactly like the \"no game-owned loading UI\" rule above.\n- Pushes back the portal's give-up failsafe. Without any calls, the portal gives up waiting for\n `ready()` after 12 seconds and retires its splash anyway — a heavy title on a cold CDN can lose\n its splash mid-download. Each **new** progress value re-arms that 12-second wait, proving your\n game is still actively downloading rather than stuck, so the portal keeps waiting for you — up to\n a hard ceiling of 30 seconds after which the splash retires regardless, matching the 30-second\n window the reviewer's own smoke test already gives `ready()` (see\n [Publishing](/guide/publishing#what-the-reviewer-sees)). Calling `setLoadProgress()` again with a\n value you've already reported doesn't buy any extra time — a loader pinned at one percentage\n isn't making progress, so repeating it is treated the same as not calling at all.\n\nSkip it entirely and your game behaves exactly as it does today: an indeterminate bar and the\n12-second failsafe — fine for a game with little to load. **Strongly recommended if your game has\na large asset download:** without it, a legitimately slow but healthy load is indistinguishable\nfrom a hung one, and the portal abandons it at 12 seconds regardless of whether you're still\nmaking progress.\n\nA `Promise.all` over your asset list, counting completions as they land, is the most portable way\nto wire this up — it works the same regardless of engine:\n\n```js\nlet loaded = 0;\nconst urls = [/* your list of asset URLs */];\n\nawait Promise.all(\n urls.map((url) =>\n fetch(url).then((response) => {\n loaded += 1;\n Miniant.setLoadProgress(loaded / urls.length);\n return response;\n }),\n ),\n);\n```\n\nIf you're loading through three.js's own loaders, wire it straight to a shared\n`LoadingManager.onProgress` instead of tracking completions yourself:\n\n```js\nconst manager = new THREE.LoadingManager();\nmanager.onProgress = (_url, loaded, total) => {\n Miniant.setLoadProgress(loaded / total);\n};\nmanager.onLoad = () => {\n hideOwnCover(); // if you have one — see \"Get the order right\" above\n requestAnimationFrame(() => Miniant.ready());\n};\n\nconst loader = new THREE.GLTFLoader(manager);\n```\n\n## Pause, resume, and terminate — `Miniant.on(cb)`\n\n`Miniant.on(\"pause\" | \"resume\" | \"terminate\" | \"settings_changed\", cb)` — **mandatory to handle\n`\"terminate\"`.** The\nportal can pause or resume your game (for example, the player switching tabs), and can terminate\nyour session outright at any time, including mid-play. Your game must stop simulating **and** stop\nreporting the moment `\"terminate\"` fires; a game that keeps ticking or keeps calling\n`reportProgress()`/`reportResult()` after termination is not handling this event correctly.\n\nYour `\"terminate\"` callback receives `{ reason?: string }`. The reason tells you *why*:\n\n- **`\"player_exit\"`** — the player deliberately ended the session through the portal's own Exit\n control (or your own game called `Miniant.exit()`). **This ends the game**, and it is recorded as\n a **forfeit loss** (`outcome: \"loss\", forfeit: true`) for that player, never `\"abandoned\"`\n (2026-09-17) — report it yourself before you exit if you can (see [Scores and\n results](/guide/sdk/scores#reporting-results-is-mandatory)); the portal records it centrally on\n the confirmed exit even if your game reports nothing. For a **match-style** session (`vs_ai`,\n `local_multiplayer`, any `online_*` mode, or any room-backed session) the portal clears whatever it\n had saved for this player/game/mode the moment this fires — a conformant match game must not expect\n its run snapshot to survive a deliberate exit. A **solo / levels** progression save is different:\n it survives a deliberate exit, and the player resumes it next session. Stop simulating\n immediately: a match-based game (turn-based board/card games, most `Miniant.net` titles) should\n treat it like any other seat leaving — broadcast a resignation message to the remaining seats (see\n \"Absent players\" in [Multiplayer](/guide/sdk/multiplayer)) — while a progress-based game just\n stops the current run. You don't need to call `save()` here; the portal isn't asking for one.\n- **Any other or unknown reason** — always platform-initiated (a ban, scheduled maintenance, session\n expiry) and never something the player chose. Stop immediately, and if you declare\n `capabilities.saveState`, **flush your latest snapshot first** — the player didn't ask for this,\n so don't let it cost them progress they'd otherwise keep. Treat every reason you don't specifically\n recognize this way, not only the ones named here; new platform-initiated reasons can be added\n without a guide update.\n\n### Leaving is not one thing: exit, step away, and going quiet\n\nThree different things can take a player off your screen, and only one of them ends the game. Your\ngame sees each of them differently, so it is worth being precise about which is which.\n\n| | What the player did | What your game sees | What it means |\n|---|---|---|---|\n| **Exit** | Pressed the portal's Exit control, or your game called `Miniant.exit()` | `terminate` with `\"player_exit\"` | **Ends the session, recorded as a forfeit loss.** A match-style session's save is cleared; a solo/levels save survives. A match game should broadcast a resignation. |\n| **Step away** | Navigated back, closed the tab, switched app | **Nothing directly** — the frames simply stop | The seat is still theirs. The portal keeps the session alive and offers them a way back. |\n| **Went quiet** | Lost signal, battery died, tab froze | Nothing directly | Indistinguishable from stepping away, and treated identically. |\n\nA step-away deliberately looks the same to your game as a crash, and that is the point: your game\nshould not behave differently based on whether someone *meant* to walk off. What handles both is the\n[`unavailable` policy](/guide/requirements/multiplayer#when-a-player-becomes-unavailable) — auto-pilot\nor forfeit, on the clock the mode declares. **How long a player can be gone is entirely that\npolicy's `timeoutSeconds`**, so a `\"forfeit\"` mode with the default 30 seconds gives them thirty\nseconds, and an `\"auto\"` mode lets them come back whenever.\n\nOnly `player_exit` is a decision. The other two are conditions.\n\n### Knowing when a player is coming back: `context.resumed`\n\n`PlayContext.resumed` is `true` when this player is **returning to a session that was already\nrunning** — a mid-match refresh, a return after stepping away, a reconnect after a dead battery —\nrather than starting a new one.\n\nThis has always been possible; it was simply never announced. A reload has always put the player\nback into the same live session, and your game had no way to tell that apart from a first start, so\nit would re-run its opening: reset the score, re-deal, replay the countdown, over a match already in\nprogress.\n\n**On `resumed: true`, do not re-run start-of-match logic.** No resetting scores, no re-dealing, no\ncountdown. Restore from the state you are handed — your own save via `state.load()`, or for a\nrelay-backed match the snapshot that arrives from your peers — and carry on from there.\n\nTwo things it is *not*:\n\n- It is not \"the player pressed Resume in the picker\". That is a fact about how the session was\n **created**; this is a fact about the current **load**. A plain Start that the player reloads once\n arrives with `resumed: true`.\n- It is not a reconnect signal for your relay socket. `Miniant.net`'s own\n [`connected`/`disconnected` events](/guide/sdk/multiplayer) tell you about the socket; this tells\n you about the session.\n\nAbsent on a portal build that predates this field, which reads as `false` — a first start, exactly\nas before. A game that ignores it behaves as it always has.\n\nBefore `\"terminate\"` fires for a **platform-initiated** reason, the portal also calls\n`Miniant.state.onSaveRequest` with `reason: \"terminate\"` and waits a brief grace window for your\nreturned blob to land through the normal `save()` path before it pushes this event — see\n[Data](/guide/sdk/data)'s `onSaveRequest` section for the full API. **The portal no longer sends\n`onSaveRequest` with `reason: \"exit\"`** — a deliberate exit never asks for a flush: a match-style\nsession's save is cleared and a solo/levels save is simply kept as it already stands, so there is\nnothing to save for it. A game that has registered `onSaveRequest` gets a real chance\nto persist its precise final state ahead of a platform-initiated termination, rather than whatever\nwas last explicitly saved. It's still only a brief window, not a guarantee — the same honest limits\napply as any other `onSaveRequest` push (a mobile freeze or a near-cap save can still miss it) — so\nkeeping your saved state reasonably current on your own cadence remains your fallback, and it's\nstill worth keeping an independent periodic autosave regardless of whether you've registered\n`onSaveRequest`.\n\nThis is also your hook for a clean online shutdown regardless of reason: broadcasting a resignation\nmessage to the remaining seats before the session actually ends, rather than just going silent — see\n[Multiplayer](/guide/sdk/multiplayer)'s \"Absent players\" for the room-side half of this.\n\n## `Miniant.exit()`\n\nOptional. Asks the portal to leave the game. The portal owns the exit UI regardless of whether you\ncall this — calling it doesn't grant your game any control over how the exit is presented; it's\npurely a request to leave.\n\n## `Miniant.requestRematch()`\n\nMandatory **iff** your game has a definite ending (see \"The game-over screen\" below) — valid in\n**every** mode, not only a room-backed one. This is how your own game-over screen's \"Rematch\"\nbutton (see below) reaches the portal: the portal, never your game, decides what the rematch\nactually is — a successor room for a room-backed session (`online_friends` / `online_random` with\n`context.room` non-null), or a brand-new, normally-charged session for every other mode (solo,\n`vs_ai`, local pass-and-play, or a `sync: \"parallel\"` seat, whose `PlayContext.room` is always\n`null`). One Ticket fee buys exactly one round — a rematch is never free and never a local restart.\n\n**Exception, `progression: \"levels\"` (2026-09-17, updated 2026-09-25, Kais):** \"Next level\" and\n\"Retry\" are free for as long as the player stays in the same sitting, and plain\n`requestRematch()` needs nothing else — the portal starts the next level without a reload or\nsplash screen. `{ inPlace: true }` is optional polish. See\n[Scores and results](/guide/sdk/scores#reporting-results-is-mandatory) for the full levels\ncontract, including how the free sitting ends and what anti-cheat bound it changes.\n\n**Valid once:**\n\n- `reportResult()` has already resolved for this session. There's nothing to rematch before your\n game has reported an outcome.\n\nCalling it before that rejects the returned promise with an error — that's your integration's bug\nto fix, not a state your game needs to handle gracefully:\n\n```js\nawait Miniant.reportResult({ outcome: \"win\", durationMs });\n\n// later, when the player taps your own game-over screen's \"Rematch\" button:\nawait Miniant.requestRematch();\n```\n\n## The game-over screen\n\nProduct rule, updated 2026-08-23 (\"rematch = one fee, one round, every mode\"): **a game with a\ndefinite end state — a win/loss, or a final score — owns its own game-over screen.** The portal\nused to auto-open its own \"Rematch?\" modal on top of whatever your game rendered the moment\n`reportResult()` landed; outside a `sync: \"parallel\"` room, it no longer does. Rendering nothing of\nyour own there used to work by accident (the portal's modal covered for you); it doesn't anymore —\na game with a definite ending that skips this now leaves the player looking at a dead screen with\nno way to continue. (A `sync: \"parallel\"` room is the one exception — see the note at the end of\nthis section.)\n\n**If your game has a definite ending, your game-over screen must show exactly two uniform primary\nCTAs, labelled exactly:**\n\n- **\"Rematch\"**\n- **\"Exit\"**\n\nSame two labels, every game — that consistency is the whole point: a player should never have to\nrelearn what ends a match depending on which game they're in.\n\n**\"Rematch\" must call `await Miniant.requestRematch()` in EVERY mode** — online room, online\nrandom, `vs_ai`, solo, and local pass-and-play alike. **\"Exit\" always calls `Miniant.exit()`.**\nThere is no mode where \"Rematch\" restarts the match inside your own game: each round is its own\nportal session and costs the player's Ticket fee (or shows them the watch-to-play gate if they\ncan't pay it) exactly like starting any other session. **A game that loops rounds inside one\nsession on \"Rematch\" — a local restart instead of calling `requestRematch()` — fails review.**\n\n```js\nrematchButton.onclick = async () => {\n await Miniant.requestRematch(); // same call, every mode — the portal decides what happens next\n};\nexitButton.onclick = () => {\n Miniant.exit();\n};\n```\n\nThe portal always reloads your game fresh for the new round — whether that's a successor room (any\nroom-backed session, including a `sync: \"parallel\"` room — see the note at the end of this section)\nor a brand-new charged session (solo, `vs_ai`, local pass-and-play) — so `reportResult()` staying\ncall-once per session is automatic: a rematch never shares a session with the run that preceded it,\nand never needs a second report crammed into the same one.\n\n**Games with no definite end state at all — endless/high-score modes with no final screen — are\noutside this rule entirely.** Keep reporting exactly as you already do\n(`reportProgress()`/`reportResult()` per [Scores and results](/guide/sdk/scores)); there is no\ngame-over screen to build and nothing here changes for you.\n\n**`progression: \"levels\"` is a different case (2026-09-17, Kais), not the same exemption.** Every\nlevel still ends definitely — cleared or failed — so it still gets a \"Next level\"/\"Retry\" screen\nthat calls `requestRematch()`. What's different is the fee and the transition: the next level is\nfree and starts without a reload or splash screen, for as long as the player's sitting continues\n(a new sitting after closing pays the normal fee again, same as any other session). Plain\n`requestRematch()` is enough; `{ inPlace: true }` is optional. See [Scores and results](/guide/sdk/scores#reporting-results-is-mandatory) for the\nfull contract, the code example, and the anti-cheat bounds that shift for levels games.\n\nA `sync: \"parallel\"` seat's own `PlayContext.room` is always `null` (see [Choose a sync\nmode](/guide/requirements/multiplayer#choose-a-sync-mode-for-an-online-mode)), so call\n`requestRematch()` exactly as you would in a solo session — the missing `context.room` changes\nnothing about when or how you call it. The portal still routes that call to the room's\n**successor**, the same rematch its own scoreboard panel offers there, not to a fresh session: a\nparallel room is the one case that keeps the portal's own scoreboard-and-Rematch panel, so you'll\nsee it alongside (or instead of) your own game-over screen there.\n\n## Respect the player's sound, music and vibration\n\n`context.settings` carries `{ sound, vibration, music }` — the player's own choices, made in the\nportal's in-game settings sheet. **Honouring them is your job, not the portal's.** Your game runs in\na cross-origin iframe: the portal physically cannot mute your audio or intercept your\n`navigator.vibrate()` calls, so a game that ignores these keeps making noise after the player has\nexplicitly asked it to stop.\n\nThere are two audio switches, and the player controls them separately:\n\n- `sound` — sound effects (taps, hits, wins, UI blips).\n- `music` — background music and ambience loops.\n\nA game with no music only needs `sound`. A game that does not read `music` at all should keep\ntreating `sound` as all of its audio, which is exactly how it behaved before `music` existed. A game\nwith music must not start the loop when `music` is `false` at init, and must stop or fade it out\nwhen `music` turns `false` live (the `settings_changed` event), then bring it back when it turns\n`true` again.\n\nRead them at init, and subscribe so a mid-game change takes effect immediately — a player who mutes\nduring a match expects silence now, not next session:\n\n```js\nconst context = await Miniant.init();\napplyEffects(context.settings?.sound ?? true);\napplyMusic(context.settings?.music ?? true);\napplyHaptics(context.settings?.vibration ?? true);\n\nMiniant.on(\"settings_changed\", ({ sound, vibration, music }) => {\n applyEffects(sound);\n applyMusic(music);\n applyHaptics(vibration);\n});\n```\n\n`settings` is optional and absent on portal builds that predate it — treat a missing value as\n**everything enabled**, which is what the fallbacks above do. The SDK always hands you a complete\nobject: a missing `music` (from a portal build that only knew `sound` and `vibration`) arrives as\n`true`.\n\n`miniant-validate` and the upload check report four audio checks, all non-blocking: a miss never\nstops a publish, and an admin reviews them at approval. `audio.sfx-present` passes when the build\nhas an audio file (or a `data:audio/` URI) and code that plays it. `audio.sound-setting-honoured`\npasses when the game listens for `settings_changed` and reads `sound`. `audio.bgm-present` passes\nwhen something loops (`loop = true`, `loop: true` or an `<audio loop>` tag; an ambience loop counts,\nthe admin judges by ear), and `audio.music-setting-honoured` passes when the game also reads `music`.\n\n## The portal only announces a final forfeit or departure\n\nFor a relay-backed online mode that declares `unavailable` ([manifest\nreference](/guide/requirements/technical), [the policy\ncontract](/guide/requirements/multiplayer#when-a-player-becomes-unavailable)), the portal no longer\nshows live away/auto-pilot chrome over your game — it only announces a final state: a \"{name} left —\nyou win\" banner for a forfeit, or a \"{name} left — the game continues\" notice once a departed seat\nis later replaced, both driven entirely by the `Miniant.reportPeerAvailability()` calls your game\n(or [Turn Kit](/guide/sdk/turn-kit), automatically) makes. **Show away/auto-pilot state in your own\nseat UI if your game needs it** — the portal has nothing covering that window any more, so a player\nwatching an away or auto-driven seat only sees what your board itself renders for it.\n\nWhat stays yours: everything happening **on the board itself**. Freezing or highlighting the\nunavailable seat's pieces, playing the auto-driven move with your own animation, and your own\ngame-over/victory screen (including the `forfeit: true` win case — see [Multiplayer\nrequirements](/guide/requirements/multiplayer#when-a-player-becomes-unavailable)'s \"Reporting the\nresult\") are all in-board UX only your game can render. The portal's banners are peripheral chrome\naround your board, not a replacement for what happens on it.\n\n## Leave room for the portal's own chrome\n\n`context.ui.safeAreaInsets` is `{ top, right, bottom, left }`, in CSS pixels, and it reports space\ninside your viewport that the portal itself draws over. **Keep your own interactive controls out of\nit.** It is not only about device notches: the portal floats a small social dock (chat and emotes)\nabove the bottom edge of the game area in any session that has them, so `bottom` is typically around\n56px there and `0` in a solo session with no dock.\n\n```js\nconst { bottom } = context.ui.safeAreaInsets;\nlayoutHud({ bottomPadding: bottom });\n```\n\nWhat is **not** reserved, deliberately, so you get to use the space:\n\n- The portal's top chrome bar sits outside your viewport entirely while docked, and auto-hides in\n fullscreen.\n- Panels the player opens (game info, settings, chat) are transient and dismissed with one tap.\n- Emote bursts drift over the board but never accept input.\n\nThe player can drag the dock elsewhere, so treat these insets as the resting layout rather than a\nguarantee about every pixel for the whole session. Decorative background art under the dock is\nfine — it is buttons and readouts that need to move.\n\n## Tell the portal where your players are — `Miniant.ui.setAvatarAnchors()`\n\nEmotes and chat messages float over your game, and should rise from **that player's avatar**. The\nportal can't see inside your iframe to find them, so without anchors it guesses (local player\nbottom-left, opponents across the top) — which fits some layouts and badly misses others.\n\n```js\nMiniant.ui.setAvatarAnchors([\n { seat: 0, x: 18, y: 82 }, // you, bottom-left\n { seat: 1, x: 82, y: 18 }, // opponent, top-right\n]);\n```\n\n- `x`/`y` are **percentages of your own frame**, `0`–`100`, and mark the avatar's **centre**.\n Not pixels: divide a `getBoundingClientRect()` centre by `innerWidth`/`innerHeight` and multiply\n by 100. Pixel values are clamped to 100, so every emote rises from the bottom-right corner.\n- `x` runs left → right; **`y` runs top → bottom**, like CSS. Upside-down emotes are a flipped `y`.\n- `seat` is the player's real seat from `context.participants`, not a screen slot. Send one\n anchor for every seat in the match.\n- Anchor the **avatar** itself, not its name card or a marker near it.\n\n**Call it again on every layout change** — resize, orientation flip, seat rotation, player joining.\nThe portal keeps the last set you sent and cannot tell that your board moved.\n\n**Required for any game with an online mode.** `miniant-validate` and the upload check fail a build\nthat declares `online_friends` or `online_random` and never calls it. The check only sees that the\ncall exists, so test it: send an emote in an online match and watch where it rises.\n\n## The wire protocol is internal\n\nThe full wire protocol underneath this SDK (envelope shape, message types, the host-side security\nmodel) is internal — you never need it, and there's no public reference for it beyond this guide.\n"},{"slug":"sdk/user","title":"User","section":"SDK","agentSummary":"Player identity as carried in PlayContext (player and participants): displayName, countryFlag, cosmetics, entitlements, isGuest, and what the SDK deliberately does not offer.","checklist":[{"id":"user.render-displayname-as-is","text":"Render displayName as-is: never derive a name yourself, never ask the player who they are, and never persist it in saved state.","verify":"manual"},{"id":"user.no-geolocation-fallback","text":"Never guess a player's country, including via IP geolocation, when countryFlag is null.","verify":"manual"},{"id":"user.cosmetics-unknown-id-fallback","text":"Always render your game's own default appearance for any cosmetics id you don't recognize.","verify":"manual"},{"id":"user.no-frame-avatar-rendering","text":"Never render the frame or avatar cosmetic slots yourself; the portal draws those in its own chrome.","verify":"manual"},{"id":"user.no-signin-ui","text":"Never build a sign-in or login screen, or any third-party login; the portal owns authentication entirely.","verify":"manual"},{"id":"user.no-friends-list","text":"Never build a friends list, invites, chat, or any other social feature inside the game.","verify":"manual"}],"body":"# User\n\nThere is no separate user module in the SDK. Player identity arrives as part of the `PlayContext`\nobject `Miniant.init()` resolves with — there's no live user object, no subscription, and nothing\nto fetch separately. See [Game](/guide/sdk/game) for the full `PlayContext` shape and the rest of\nthe session lifecycle.\n\n## Shape\n\n```ts\nplayer: { id, displayName, avatarUrl, isGuest, locale, seat, countryFlag, cosmetics, entitlements }\n```\n\nEvery entry in `participants` (the other seats in this session) carries the same shape, plus\n`isLocal`, `isFriend`, and an optional `simulated`. `simulated: true` marks a seat the portal\nfilled with a portal-owned AI identity because it couldn't find a human opponent (only possible in\n`online_random`) — see [Game](/guide/sdk/game) for the full rule, including that you must never\nreveal that seat as AI to the player.\n\n## `displayName`\n\nThe player's portal profile identity — their chosen display name, falling back to their unique\nportal username. Render it as-is: never derive a name yourself, never ask the player who they are,\nand never persist it in your own saved state. Players can change their display name at any time,\nso a copy you saved last session can go stale — re-read it from `init()` every session instead.\n\n**`player` names the account; `participants` name the seats.** Anything rendered on a seat —\nroster rows, turn prompts, results — reads `participants[].displayName`, in every mode. The\ndistinction matters most in Pass & Play, where the portal collects each seat's name up front and\ndelivers it through `participants`, while `player.displayName` stays the signed-in account's\nprofile name. When both describe the same seat number, the participant entry wins for anything\non-screen — merging `player` over your roster replaces a name somebody just typed with the\naccount name. See [Multiplayer](/guide/requirements/multiplayer) for the full Pass & Play\ncontract.\n\n## `countryFlag`\n\nOptional — an ISO 3166-1 alpha-2 country code, or `null`. `null` means the player either chose not\nto share their country flag, or hasn't set a country at all — never treat it as an error. Render a\nflag only if your game already supports flag rendering; do nothing otherwise. Never guess a\nplayer's country any other way, including IP geolocation, if `countryFlag` is `null` — that\nconsent decision belongs to the player, made in their own portal profile.\n\n## `cosmetics`\n\nAn object of the equipped-cosmetic slots the portal knows about, each optional:\n\n```ts\ncosmetics: { boardTheme?, diceSkin?, tokenSkin?, striker?, frame?, avatar?, emotePack? }\n```\n\nEvery value, when present, is an **id string from the portal's own cosmetics catalog** — never a\nURL, never an asset, just an identifier like `\"obsidian_gold\"` or `\"gilded_dice\"`. A slot with\nnothing equipped is simply absent from the object (never `null` or an empty string), and the whole\nobject itself can be empty (`{}`) for a player who owns no cosmetics, a guest, or a bot-filled seat\n— never assume it's populated.\n\nMapping an id to something on screen is entirely your game's own job: only YOUR game knows what a\n`boardTheme` or `diceSkin` even means for its own board/pieces, so the portal hands you the bare id\nand nothing more. Maintain your own id → asset table for whichever slots your game actually uses,\nand **always render your game's own default appearance for any id you don't recognize** — a new\ncatalog item, a slot your game doesn't support, or simply a typo upstream must never break\nrendering or show a blank/broken asset. Treat an unrecognized id exactly like an absent one.\n\n`frame` and `avatar` are the two exceptions: the portal renders those itself, in its own chrome\n(the player list, spectator panel, leaderboard, profile pages, and so on) — never inside your\ngame's iframe. You'll see them come through on `player.cosmetics`/`participants[].cosmetics` like\nevery other slot, but there's nothing for your game to draw for them; ignore both unless your game\nhas its own in-board seat portraits it wants to keep visually consistent with the portal's chrome.\n\n## `entitlements`\n\nAn optional `string[]` of *entitlement-kind* SKU ids (`store.skus`, `type: \"entitlement\"`) this\nseat owns — your own sellable cosmetics/unlocks, not the portal's catalog above. Present on every\nparticipant, including your own, so you can render an opponent's owned skin without a relay\nround-trip. See [Cosmetics](/guide/sdk/cosmetics) for the full contract, including how it differs\nfrom `wallet.getEntitlements()` (which only ever answers for the local player).\n\n## `isGuest`\n\nMarks the player as unregistered. Guests deliberately have no wallet — that's part of what converts\na guest into a registered player, and it's an intentional product decision, not a gap to route\naround. Saved progress is different: a guest's `state.save()` now persists in that one browser (see\n[Data](/guide/sdk/data)), so guests are not without saved progress the way they're without a\nwallet — what registering actually buys a player is **cross-device** progress (the portal\nauto-uploads a guest's local save the moment they sign in) and the wallet, not saved progress\nitself. See [Account integration](/guide/requirements/account-integration) for the full product\nrules around this split, and [Data](/guide/sdk/data) for exactly how saves behave for a guest.\n\n## What the SDK does not offer\n\n- **No live user object or subscription.** Identity is a snapshot handed to you once, inside\n `PlayContext`, when `init()` resolves. There is no ongoing \"user changed\" event to listen for.\n- **No login/logout API.** The portal owns authentication entirely. Your game never builds a\n sign-in screen, never adds a third-party login, and never asks the player who they are.\n- **No access to the player's friends list from inside a game.** Whatever friend-based matching or\n invites the portal offers happens in the portal's own chrome, before your game is ever loaded.\n"},{"slug":"sdk/data","title":"Data","section":"SDK","agentSummary":"Saving and loading player progress via Miniant.state.save/load/onSaveRequest: one slot per game, player, and mode, save-granularity rules, and guest vs signed-in behavior.","checklist":[{"id":"data.load-on-every-start","text":"Call Miniant.state.load() unconditionally at the start of every session, never gated on which button the player pressed.","verify":"validator:save.load-on-start"},{"id":"data.levels-start-from-best","text":"For a levels game, open at the higher of context.progress.bestLevel and your own save; never restart a player at level 1 because you could not read your own older blob.","verify":"validator:save.levels-read-best-level"},{"id":"data.level-snapshot-at-start","text":"In level or checkpoint games, snapshot progress, score, rewards and position at level start. Only values that work solely against the player (lives lost, a cooldown, a consumed item) save live.","verify":"manual"},{"id":"data.no-entitlements-in-save","text":"Never persist wallet entitlements inside your saved blob; always re-read them live from getEntitlements().","verify":"manual"},{"id":"data.save-never-buys-rank","text":"Treat saved progress as progress only; it must never confer Tickets, rank, or a leaderboard position.","verify":"manual"},{"id":"data.keep-own-autosave","text":"Keep your own periodic autosave cadence regardless of whether you register onSaveRequest.","verify":"manual"},{"id":"data.no-own-signin-prompt","text":"Never build your own sign-in prompt around saves; the portal shows its own the first time a guest saves.","verify":"manual"}],"body":"# Data\n\n`Miniant.state.save(blob, meta?)` / `Miniant.state.load()` persist saved progress — one slot per\n`(your game, this player, the session's mode)`, last-write-wins (a second `save()` overwrites the\nfirst, there is no history). It requires `capabilities.saveState` in your manifest ([manifest\nreference](/guide/requirements/technical)). The `mode` half of the slot key is resolved\nautomatically from the session — you never pass one yourself; a player's \"blitz\" and \"classic\"\nruns land in separate slots without your game doing anything differently.\n\nFor a signed-in player the slot lives server-side and follows them to any device. For a guest it\nlives in that one browser instead — see \"Guests and signed-in players\" below.\n\nThe 64KB cap is enforced server-side, not just by the SDK's own client-side check — an oversize\n`blob` rejects with a size error regardless of what your client code does or doesn't check first,\nso don't rely on the client-side cap alone. The same 64KB cap applies to a guest's local save too.\n\n`meta` is an optional second argument — `{ label?: string, progressPct?: number }` — purely cosmetic\nslot metadata the portal can use to render its own \"Resume — {label}\" card in a mode picker. Omit it\nentirely and `save()` behaves exactly as it always has. **It's only honored for a signed-in\nplayer right now** — passed for a guest, it's accepted (never an error) but not stored, so don't\nrely on it surfacing anywhere until the player signs in.\n\n**`load()` resolves with your blob directly — there is no wrapper.**\n\n```ts\nconst saved = await Miniant.state.load();\n// `saved` IS your blob (whatever you last passed to `save()`), or `null` if nothing is saved yet.\n```\n\n`saved` is never a `{ blob }`-shaped object — that wrapper exists only on the internal wire between\nthe portal shell and the SDK, and is unwrapped for you before `load()` ever resolves. *(This guide\npreviously documented `load()` as returning `{ blob: null }` — that wording was wrong, not the SDK;\nif your game reads `.blob` off the resolved value, drop the `.blob` and use the value directly.)*\n\n## Save granularity depends on your game's shape\n\n**This is a publishing requirement, checked at manual review — not a recommendation.** See\n[Account integration](/guide/requirements/account-integration) for the normative statement; this\nsection is the how-to.\n\n### Level/checkpoint games — snapshot at the start, not live mid-level state\n\nThe `blob` you pass to `save()` must be a snapshot taken at the moment a level or checkpoint\n*begins*, never the player's live in-progress state. Score, lives, and any other run-state in the\nblob must reflect the values they had when that level started, not wherever the player happens to\nbe when they quit or the tab backgrounds. A player who leaves partway through a level resumes,\nnext session, at the **start** of that level or checkpoint — never their exact mid-level position.\nThis is deliberate, not a gap: it is the anti-abuse guard against a player grinding half a level\nfor points, quitting, and banking them anyway. It applies equally to a save you make on your own\ncadence and to a blob you return from `onSaveRequest` below.\n\n**Against-the-player values may be saved live.** Progress, score, rewards and position snapshot at\nlevel start. Values that can only work against the player — lives lost, a lockout or cooldown\ntimestamp, a consumed paid item — may be saved live, on every change. The test: if a player could\ngain from quitting and resuming at that moment, it must snapshot. Resuming mid-level returns to the\nlevel's start with equal-or-fewer lives (or other against-the-player values), never more.\n\nPass `meta.label` to name the resumable unit you just saved — `\"Level 3\"`, `\"Chapter 2 —\nCheckpoint 1\"` — because the portal renders it verbatim in its own \"Resume — {label}\" card. Get the\nlabel right and that's exactly what the player sees.\n\n### Levels games — the portal knows the level, so your save doesn't have to\n\nFor a `progression: \"levels\"` game, the context your game receives at boot carries a floor:\n\n```js\nconst floor = Miniant.context.progress?.bestLevel ?? 0; // highest level WE have accepted for this player\nconst saved = await Miniant.state.load(); // your blob, or null\nconst level = Math.max(floor, saved?.level ?? 0); // open here\n```\n\n`bestLevel` is the highest level this player has ever had *accepted* for your game — resolved from\nthe results you reported to us, not from anything in your blob, and never raised by a result our\nintegrity checks flagged. It is the level **cleared**; which level that makes the next one is your\ngame's business, since only you know whether your levels are a line, a branch or a map.\n\n**Take the higher of the two, always.** Your blob still owns everything else — score, lives,\nunlocks, checkpoint state — and a blob you can read is richer than a bare number, so this is a\nfloor, never a replacement.\n\nThe reason this field exists is worth stating plainly, because it is the single most common way a\ngame loses its players' progress. Your save blob is opaque to us: we store it, we never parse it.\nSo when you ship a build whose loader rejects a blob an older build wrote — the ordinary\n\n```js\nif (blob.v !== BLOB_VERSION) return null; // \"foreign shape, start fresh\"\n```\n\n— a player who reached level 10 restarts at level 1, while their actual progress sits intact in our\ndatabase, unreadable by the only code that knows what it means. The level is the one part of\nprogression we know independently, so it does not have to survive your save format to survive your\ndeploy. Read the floor and that whole class of bug stops mattering.\n\nTwo further habits worth keeping, neither of which the floor replaces: **migrate your old blobs\nrather than discarding them** whenever you can (a version bump is a reason to translate, not to\nthrow away), and **never rename a mode or a variant casually** — the save slot key is\n`\"<modeId>\"`/`\"<modeId>:<variant>\"`, so renaming one in a new manifest orphans every save written\nunder the old name.\n\n`bestLevel` is resolved once, when your game boots, and deliberately not refreshed afterwards for\nthe life of that boot. Mid-run you know your own level better than we do; this exists for the boot\ndecision alone. It is `null` for a player who has never cleared a level, and absent entirely on an\nolder host, both meaning the same thing: no floor, use your blob.\n\nWhat \"the life of that boot\" means depends on how the portal continues you into the next level. An\nin-place \"Next level\" chain (`{ inPlace: true }`) never re-boots your game at all — same instance,\nsame `onInit` call — so the floor it handed you at the start simply never refreshes for as long as\nthat chain runs. A plain `requestRematch()` call on a `\"levels\"` game, by contrast, gets a genuinely\nnew boot: the portal preloads the next level in a fresh, hidden instance and swaps it in, and that\nnew instance's own `onInit` carries the floor as of the last result we accepted before the swap —\nnot whatever was true when the FIRST level in the chain originally booted.\n\n### Solo match games with no level structure — resume the exact position\n\nA solo match with no level/checkpoint structure — an offline Ludo or Chess match against the AI —\nhas no partial-level score to bank, so the anti-farming rationale above doesn't apply. For this\nshape, `save()` should snapshot the **exact position** instead: the match itself is the unit, and\nthere is no earlier checkpoint to roll back to. Resume it exactly where the player left off.\n\nIf your game has no level or checkpoint structure at all *and* no meaningful mid-match position\nworth resuming — a single continuous match with no natural resume point — don't declare\n`saveState` at all. Ending the match when the player leaves is correct for that shape of game.\n\n### Call `state.load()` on every session start\n\nCall `state.load()` at the start of every session — the portal decides what comes back, so your\ngame never needs to branch on which button the player pressed. What you get back depends on your\n`mode`: for a match-style session (`vs_ai`, `local_multiplayer`, or any `online_*` mode — a match\nagainst an opponent, nothing partial to bank) pressing Start deliberately begins a fresh game, and\n`load()` returns `null` even when a save exists for that slot; only the picker's own Resume button\nreturns it. For a solo, level-based session, Start begins a fresh run from your player's last\nrecorded level, and `load()` still returns your saved progress either way — Resume changes nothing\nfor this shape. Either way, call `load()` unconditionally: a level game that only loads on some\n\"this is a resume\" condition will dump a returning player back at level 1 even on a Resume.\n\n## Guests and signed-in players\n\nSaves work for both, but from two different backends. For a signed-in player, `save()`/`load()`\nwork exactly as documented above, server-side. For a guest (`context.player.isGuest === true`),\n`save()` persists to that one browser instead — same 64KB cap, same last-write-wins semantics, but\ntoday a guest's slot is per-game only, not per-mode: switching modes as a guest overwrites the same\nlocal save rather than landing in a separate slot the way a signed-in player's would. `load()`\nreturns whatever that browser last saved for this game, or `null` if nothing was — never an error,\nsame \"no save exists yet\" convention either way.\n\nThe moment a guest signs in, the portal automatically uploads that browser's saved progress onto the\nnew account — this is entirely outside your game's control, best-effort (a failed upload just leaves\nthe local copy to retry on the next sign-in), and newest-wins if the account already holds a save for\nthat slot. Your game never sees this happen; it just means `load()` can start returning a blob a\nsigned-in player never explicitly saved on this device. See [Account\nintegration](/guide/requirements/account-integration) for the product decision behind the guest/\nsigned-in split.\n\nYour game never needs to branch on `isGuest` to call `save()`/`load()` — both resolve the same way\nregardless of which backend is behind them. **Never build your own sign-in prompt around this** —\nthe portal shows its own prompt the first time a guest saves, and yours would either duplicate or\ncontradict it.\n\n## `Miniant.state.onSaveRequest(cb)` — reacting to a portal-initiated save\n\n```ts\nconst unsubscribe = Miniant.state.onSaveRequest(({ reason }) => {\n // Return your current state to have it saved through the normal save path — the same as\n // calling save() yourself. Return (or resolve to) `undefined` to skip — e.g. nothing has\n // changed since your last save.\n return currentSaveBlob();\n});\n```\n\nRegisters a callback the portal invokes whenever it wants your game to persist its state right now,\ninstead of your game having to guess or poll for the right moment. `reason` is `\"hidden\"` (the tab\nwas backgrounded, or the page is being torn down) or `\"terminate\"` — sent right before the portal\nends your session for a platform-initiated reason (a staff ban, the game being pulled), with a brief\ngrace window for your returned blob to land before the `\"terminate\"` lifecycle event itself fires;\nsee [Game](/guide/sdk/game)'s \"Pause, resume, and terminate\" section for that event. Treat any reason\nyou don't specifically branch on the same way you already treat an unrecognized `\"terminate\"` reason\nthere — new ones can ship without a guide update.\n\n**The portal no longer sends `onSaveRequest` with `reason: \"exit\"`.** A deliberate exit — the\nplayer confirming the exit dialog, or your own game calling `Miniant.exit()` — never asks for a\nflush (see [Game](/guide/sdk/game)'s `\"player_exit\"` bullet). A match-style session's save (`vs_ai`,\n`local_multiplayer`, `online_*`, any room-backed session) is cleared instead; a solo/levels save\nsurvives the exit as it already stands. Either way there is nothing to save for it. `\"exit\"` remains a defined value in the protocol's reason type for wire\nback-compat only; the current portal never sends it.\n\nWhatever you return here for `\"hidden\"`/`\"terminate\"` is exactly what `state.load()` hands back\nnext session — held to the same save-granularity rule as every other save: a level/checkpoint game\nsnapshots the start of the current level, never the player's exact position (apart from the\nagainst-the-player values it may keep live); a solo match game with\nno level structure snapshots the exact position. See \"Save granularity depends on your game's\nshape\" above.\n\nReturning anything other than `undefined` sends it through the exact same path `state.save()` itself\nuses — no separate API to learn, and it's subject to the same 64KB cap and guest/signed-in routing\ndescribed above. `onSaveRequest` returns an unsubscribe function, the same convention `Miniant.on()`\nuses. Registering it costs nothing if you never act on it, and a game that never registers it\nbehaves exactly as it did before this existed.\n\n### Honest limits — keep your own autosave too\n\nTwo things this push does not solve, only reduce:\n\n- A mobile OS can freeze or kill a backgrounded tab before an async `onSaveRequest` round trip\n completes. There's no signal faster than `visibilitychange`/`pagehide` for this, and neither\n guarantees your save lands before the tab is gone.\n- The browser's own `fetch(..., { keepalive: true })` body cap sits right around this slot's own\n 64KB limit — a near-cap save flushed during page teardown can be silently dropped by the browser\n before it ever leaves the page.\n\n`visibilitychange` firing earlier, while the page is merely backgrounded rather than being torn\ndown, is what actually carries most of the weight in practice — but neither case is eliminated.\n**Keep your own periodic autosave regardless of whether you register `onSaveRequest`.** A game that\ncalls `save()` on its own cadence — after each turn, every few seconds, on any meaningful state\nchange — is materially better protected against a mobile freeze than one relying on the portal's\npush alone. The push closes the gap between autosaves; it doesn't replace having them.\n\n## Entitlements do not belong in saved state\n\nNever persist anything derived from `wallet.getEntitlements()` (an owned SKU, an unlocked-by-purchase\nmode) inside your saved `blob` — entitlements can change portal-side (e.g. a refund) with no way to\nalso rewrite your saved state, so that data must always be re-read live from `getEntitlements()`,\nnever trusted from `state.load()` alone. See [Purchases](/guide/sdk/purchases) for the full\nrehydration rule.\n\n## Saved progress never buys rank\n\nWhatever `save()` persists — level, checkpoints, unlocks — is progress, and only ever progress. It\nnever confers Tickets, rank, a leaderboard position, or tournament eligibility, regardless of what\nthe blob claims or what state the player reached on any other platform. See [Account\nintegration](/guide/requirements/account-integration) for the full policy.\n"},{"slug":"sdk/scores","title":"Scores and results","section":"SDK","agentSummary":"Reporting session outcomes and progress via reportResult()/reportProgress(), the only channel the portal uses to record results and derive its own leaderboards.","checklist":[{"id":"scores.report-result-once","text":"Call Miniant.reportResult() exactly once per session, at game end, on every end path including the player's own exit (a forfeit loss, never \"abandoned\").","verify":"validator:sdk.report-result-called"},{"id":"scores.report-progress-if-long","text":"Send Miniant.reportProgress() heartbeats whenever a session can run past 60 seconds.","verify":"manual"},{"id":"scores.each-seat-reports-own-outcome","text":"In an online session, report from each seat's own client independently; never infer or report another seat's outcome.","verify":"manual"},{"id":"scores.no-leaderboard-api","text":"Never try to post to or read a named leaderboard directly; only reportResult/reportProgress exist for this.","verify":"manual"},{"id":"scores.level-reported","text":"For a \"levels\" progression game, add detail: { level } to a completed level's reportResult() payload — one session is one level, and the portal can't tell them apart without it.","verify":"validator:sdk.level-reported"},{"id":"scores.level-in-place","text":"Optional polish for a \"levels\" game: requestRematch({ inPlace: true }) and a labeled save. Neither is required — a plain requestRematch() or just reporting the next level is already seamless.","verify":"manual"}],"body":"# Scores and results\n\nReporting is how the portal finds out what happened in a play session — it is entirely SDK-driven,\nthere is no other channel. Results are what the portal records for a play session; scores you\nreport surface on the portal's own leaderboards. **There is no leaderboard SDK API** — your game\ncannot post to a named leaderboard or read standings directly. See\n[Leaderboards](/guide/sdk/leaderboards) for how the portal surfaces scores instead.\n\n## `Miniant.reportResult({ outcome, score?, durationMs, perParticipant?, detail? })`\n\n**Mandatory at game end.** `outcome` is one of `\"win\" | \"loss\" | \"draw\" | \"abandoned\" | \"completed\"`.\nThe portal checks it against your declared bounds and returns `{ accepted, verdict }`.\n\nCall it exactly once per session — a duplicate call is rejected with `SESSION_ALREADY_SUBMITTED`.\nYour declared score bounds and session length come from the anti-cheat questionnaire you fill in\nbefore every publish request (see [Publishing your game](/guide/publishing)); the portal checks\n`reportResult()` against those bounds, which is why an honest, accurate questionnaire matters as\nmuch as the call itself.\n\n**Score direction is declared in that questionnaire, not in `miniant.json`.** Its required\n`scoreDirection` answer is one of `higher_better`, `lower_better` or `no_score`, set once per game\n(not per mode):\n\n- `higher_better`: points; the leaderboard ranks the highest score first.\n- `lower_better`: times, strokes, moves; the leaderboard ranks the lowest score first. Report the\n score in one consistent unit (a race time in milliseconds, say) and declare `scoreBounds` in that\n unit. Because a lower number is better, any `score` you send through `reportProgress()` must never\n go up during a session; a rising one is flagged as `PROGRESS_MONOTONICITY`. If your progress\n heartbeats have no meaningful running score (a race in progress), omit `score` from them.\n- `no_score`: outcome only; the leaderboard ranks by points earned from outcomes.\n\nA mode that reports no `score` (say a multiplayer race that only reports `win`/`loss`) is fine\nunder any direction; the direction only governs the modes that do send one.\n\n`perParticipant` is optional: an array of `{ id, score?, outcome? }` entries, one per participant\nyou want to report a result for beyond your own seat — `id` matches a `context.participants[].id`\nvalue, and `score`/`outcome` follow the same shapes as the top-level `score`/`outcome` fields\n(`outcome` is the same `\"win\" | \"loss\" | \"draw\" | \"abandoned\" | \"completed\"` enum). `detail` is an\nopaque `Record<string, unknown>` for anything else you want attached to the record.\n\n## Reporting results is mandatory\n\n**Every session ends with exactly one `reportResult()` call — no exceptions.** A win, loss, draw, or\ncompleted run reports the matching `outcome`. A player who **LEAVES** mid-session — Exit, a quit\nbutton, back — before a result exists is a **loss with forfeit**, never `\"abandoned\"` (2026-09-17):\nreport `Miniant.reportResult({ outcome: \"loss\", forfeit: true, durationMs, ... })` before you exit.\n`\"abandoned\"` is reserved for ends the player didn't choose — a disconnect, a room closing, session\nexpiry, a load failure, the platform terminating the session — never for the player's own exit. A\nquit earns 0 XP but still counts as played and as a loss (win rate, streaks); the survivor of a\nforfeit reports a normal win.\n\n**Closing the tab counts as a leave too (2026-09-18).** While a match is live the portal asks the\nbrowser to confirm before leaving; if the player closes anyway, the portal pings that they left and\nopens a grace window of about 2 minutes. Returning to that same session inside the window cancels\nit — nothing is recorded. Never returning converts it into the same forfeit loss a confirmed Exit\nrecords. A refresh, a phone backgrounding the tab, or a brief disconnect are all absorbed by the\ngrace and never reach it. No game change is needed for any of this — the portal enforces it the same\nway it enforces the confirmed-Exit forfeit above. Pass & Play (`local_multiplayer`) is never recorded\neither way, win or forfeit.\n\nIf your integration misses this entirely, the SDK itself now backstops it outside online modes:\ncalling `exit()` with no prior `reportResult()` call auto-reports `outcome: \"loss\", forfeit: true`\n(not `\"abandoned\"`, as of 2026-09-17) and logs a `console.error` so the gap shows up in your own dev\nconsole — that exists to catch what your integration missed, not as a substitute for reporting the\nreal outcome yourself. In `online_friends`/`online_random` the SDK still never reports on your\nbehalf (a finished match's result closes the shared room — a quitter's own forfeit loss never does,\nthe match plays on for the seats still in it), so an online game must report its own outcome. Either way, no game change is strictly required for the forfeit-loss rule to take effect —\nthe portal itself records the forfeit loss on a confirmed Exit even when a game reports nothing, and\nconverts a game-reported `\"abandoned\"` landing around the same moment as that confirmed exit into a\nforfeit loss too. Reporting it yourself is still the correct integration, not a workaround.\n\n**For a `\"levels\"` progression game, one session is one level.** Clearing a level reports\n`Miniant.reportResult({ outcome: \"completed\", score, durationMs, detail: { level } })`; a failed\nlevel reports `outcome: \"loss\"` with the same `detail: { level }` (still not `\"abandoned\"` — a\nfailed attempt is a normal loss; a player who *leaves* mid-level is a different case, a forfeit loss\nthat ends the sitting, see below).\n\n**Nothing else is required for a seamless \"Next level\"/\"Retry\" (2026-09-25).** Report the level as\nabove and call the same plain `await Miniant.requestRematch()` that [The game-over\nscreen](/guide/sdk/game#the-game-over-screen) describes for every other mode — the portal recognizes\nthe `\"levels\"` progression and does the rest itself: it starts the next (free) session in a hidden\ncopy of your game and swaps it in once ready, with no Miniant splash and no page reload. Your game\njust boots and opens its own save the way it always does. If your integration never calls\n`requestRematch()` at all and simply reports the next level on the same session instead, the portal\nstarts that next session itself and records the level there — either shape gets the seamless\ntransition. Next level and Retry are free either way — **one Ticket fee per sitting, unlimited\nlevels, until the player closes the game.** Closing and resuming later starts a new sitting and costs\nthe normal fee again, same as any other resume. A guest's chained levels never count against their\ndaily free-session allowance; only the first session of the sitting does.\n\n**Levels XP = progress only (2026-09-18).** A session earns XP only when its completed level is\nhigher than that player's own best recorded level for this game. A retry, a replay of an\nalready-cleared level, a failed level, and a completed level that carries no `detail.level` all earn\n0 XP — every one of them is still recorded, still counts as played, still feeds result-health, and\n(on a milestone level) still gets its Share offer. This exists because levels are now free for the\nwhole sitting (above): a flat per-level XP grant would be unlimited. A `\"levels\"` game that never\nsends `detail.level` at all now earns its players **no XP, ever** — one more reason to send it.\n\n`inPlace: true` and a level-labeled save are optional polish, never required — the code below is one\nway to take them, not the baseline contract above:\n\n```js\nawait Miniant.reportResult({ outcome: \"completed\", score, durationMs, detail: { level } });\n\nconst rematch = await Miniant.requestRematch({ inPlace: true });\nif (rematch.continued) {\n // No reload, no splash — reset your own state and start the next level yourself.\n // The portal doesn't know your level numbering; keep your own counter and use\n // rematch.session.seed if you seed randomness from it.\n startLevel(++currentLevel, rematch.session.seed);\n}\n// `continued: false` means the portal is navigating to a new session instead (an old host, a\n// game not opted in, or an expired session) — do nothing further, the page is reloading.\n```\n\n`{ inPlace: true }` goes a step further than the default seamless swap above — no reload at all,\nrather than none the player sees. Pair it with `Miniant.state.save(blob, { label: \"Level N\" })` — see\n[Data](/guide/sdk/data#levelcheckpoint-games-snapshot-at-the-start-not-live-mid-level-state) for the\nlabel contract — so the picker's Resume button reads \"Resume Level N\" instead of falling back to the\nplayer's highest cleared level + 1. Both are worth doing for the extra polish; neither changes\nwhether the level flow itself is seamless.\n\nThe portal shows no interstitial between levels on its own. Call\n`Miniant.ads.requestInterstitial(\"level_end\")` yourself if you want one between levels (see [Video\nads](/guide/sdk/video-ads)). The post-match Share offer is also gated for levels games: it only\nappears on milestone levels — every 10th (10, 20, 30…) — not on every completed level.\n\nA player who leaves mid-level still reports a forfeit loss (`outcome: \"loss\", forfeit: true`), same\nas leaving any other session (see \"Reporting results is mandatory\" above) — and it ends the sitting.\nIf a `\"levels\"` game's completed result never carries `detail.level`, the SDK logs a\n`console.error` — the result is still sent, but the portal has no way to tell which level it was\nfor, so (2026-09-18) it earns 0 XP as well, the same as a retry.\n\n**Levels get their own anti-cheat bounds too (2026-09-17).** The publish-time questionnaire's\nshortest/longest session (see [Publishing your game](/guide/publishing)) are read as\nshortest/longest single *level* for a `\"levels\"` game: the shortest bound is **not** applied per\nlevel (a level can legitimately clear in a couple of seconds), but the longest still is. The\nscore-rate cap (`maxScorePerMinute`) is inert below a 30-second level — too little play to\nextrapolate a rate from without a false positive. Result submission is also allowed a higher rate\nfor levels games: 60 calls per 5 minutes, against 20 for a continuous game, since a fast human\nclearing levels back-to-back legitimately produces more `reportResult()` calls than any one\ncontinuous session would.\n\n`miniant-validate` enforces both halves of the reporting rule as build-failing **errors**:\n`sdk.report-result-called` (no `.reportResult(...)` call found anywhere in the build) and\n`sdk.level-reported` (a `\"levels\"` game whose `.reportResult(...)` payload never carries a `level`).\nBeyond the build, the portal also watches production traffic — a game whose last 20 sessions end\nwithout a reported result more than half the time is flagged to staff for review.\n\n## `Miniant.reportProgress({ checkpoint, score?, tick? })`\n\n**Required if a session can run past 60s.** A periodic heartbeat. It feeds the portal's\nmonotonicity and score-rate checks — a long session with zero progress reports is itself an\nanti-cheat signal, so don't skip it even though no specific interval is mandated. Call it whenever\nyour game reaches a natural checkpoint (a level, a round, a meaningful score change) rather than on\na fixed timer if your game doesn't have one.\n\n`checkpoint` is a **string label**, not a counter — `\"level_2\"`, `\"boss_intro\"`, `\"round_3\"`. The\nportal stores it verbatim for the record and never orders, compares or sums it, so use whatever\nnames the moments in your own game.\n\n## Reporting results in an online (multi-seat) session\n\nThere's no server-side arbiter deciding who won — **each participant's own client calls\n`reportResult()` independently, from that seat's own perspective.** A game ending in a win for seat\n0 and a loss for seat 1 means seat 0's client reports `outcome: \"win\"` and seat 1's client reports\n`outcome: \"loss\"`, each from its own session; Miniant never infers one seat's outcome from another's.\nThis applies the same way regardless of `sync` ([Choose a sync\nmode](/guide/requirements/multiplayer#choose-a-sync-mode-for-an-online-mode)): a `turn_based`/\n`realtime` match's seats agree on the board over the relay, but still each report their own result\nonce it ends; a `parallel` match's seats never share a board at all, so `score` is the only signal\nthe portal has, and it's what drives that match's own scoreboard (see [Multiplayer\nrequirements](/guide/requirements/multiplayer)).\n\n## How this fits together\n\n- **`reportResult()`** closes out the session and is what the portal actually records as the\n outcome of this play.\n- **`reportProgress()`** keeps the portal informed while the session is still running, primarily so\n a long session that never finishes (abandoned, crashed, disconnected) still has a plausible trail\n behind it instead of going dark from the first frame to nothing.\n- **Neither call is a leaderboard API.** Reporting a score through either call is not the same as\n posting to a board — see [Leaderboards](/guide/sdk/leaderboards) for what the portal does with\n reported scores and what remains outside the SDK's surface.\n"},{"slug":"sdk/purchases","title":"In-game purchases","section":"SDK","agentSummary":"In-game purchases go only through Miniant's own declared SKU/ticket economy via Miniant.wallet; games never set prices, render store UI, or move currency directly.","checklist":[{"id":"buy.declare-wallet-capability","text":"Declare capabilities.wallet: true in the manifest before calling any wallet.* method.","verify":"validator:manifest.schema"},{"id":"buy.no-custom-pricing","text":"Never compute a price in game code; every price lives in the manifest store.skus or pricing.actions block.","verify":"manual"},{"id":"buy.no-store-ui","text":"Never render a custom store/confirm UI. Only an ENTITLEMENT wallet.purchase() gets the portal's dialog; consumables and every requestSpend debit instantly — show the price on your own control.","verify":"manual"},{"id":"buy.rehydrate-entitlements","text":"Re-derive any saved state referencing an owned item from getEntitlements() on load rather than trusting state.load() alone, when both wallet and saveState are declared.","verify":"manual"},{"id":"buy.stable-idempotency-key","text":"Use a stable idempotencyKey per real conversion event when calling wallet.consume(), not a fresh value per call.","verify":"manual"}],"body":"# In-game purchases\n\nIf your game sells anything at all — a gem pack, an undo, a skin, \"remove ads\" — it sells through\nMiniant's own ticket economy, declared in your manifest and enforced by the portal. Your game never\nsets a price, never renders a store UI, and never moves currency directly; it asks the portal to\nbuy/read/burn a SKU it already declared, and the portal does the rest (see\n[what you must not build](/guide/requirements/technical), point 2). For server-side verification of\na completed purchase, see [Receipt verification](/guide/sdk/receipts).\n\n## Other wallet calls\n\n`Miniant.wallet.getBalance()` is mandatory **iff** `capabilities.wallet` is `true`, and returns the\nplayer's read-only current ticket balance — it never lets your game set or adjust it directly.\n\nTwo pieces of the wallet surface are **not implemented yet**, and this guide won't pretend otherwise:\n\n- `Miniant.wallet.openStore(sku?)` is reserved for a later phase (buying tickets with real money) —\n it throws `NOT_IMPLEMENTED` even when `capabilities.wallet` is declared.\n- A `\"balanceChanged\"` event does not exist. `wallet.on(\"entitlements_changed\", cb)` (below) is the\n only wallet event currently implemented.\n\n## The `store.skus` manifest block\n\nAdd a `store` block to `miniant.json`, alongside `capabilities` (requires `capabilities.wallet: true`\n— a `store` block without it fails validation):\n\n```jsonc\n{\n \"capabilities\": { \"wallet\": true, \"relay\": false, \"saveState\": false },\n \"store\": {\n \"skus\": [\n { \"id\": \"undo\", \"type\": \"consumable\", \"title\": \"Undo move\", \"priceTickets\": 1 },\n { \"id\": \"gems_100\", \"type\": \"consumable\", \"title\": \"100 Gems\", \"priceTickets\": 50 },\n { \"id\": \"gold_dice\", \"type\": \"entitlement\", \"title\": \"Golden Dice skin\", \"priceTickets\": 200,\n \"maxPerPlayer\": 1 },\n { \"id\": \"premium\", \"type\": \"entitlement\", \"title\": \"Premium unlock\", \"priceUSD\": 0.99 }\n ]\n }\n}\n```\n\n**The ticket–USD peg, stated plainly: USD 1 = 100 tickets, equivalently 1 ticket = $0.01 (one US\ncent).** Price each SKU with whichever field matches how you think about it — never both:\n- `priceTickets` (integer, `1`–`10000`) — the canonical unit everything is stored/ledgered in.\n- `priceUSD` — a convenience for porting an existing USD price list mechanically (`0.99` → `99`\n tickets). Must be a **whole number of cents**; `0.999` is rejected outright, because sub-cent\n pricing doesn't exist at this peg. Converts to `priceTickets` at parse/materialization time —\n the portal's runtime and ledger only ever see the integer ticket price, never your `priceUSD`.\n\nOther rules the validator enforces:\n- `id` must match `^[a-z0-9_]{1,40}$` and be unique within your manifest.\n- Up to **200 SKUs per game**.\n- `type: \"consumable\"` — bought, then burned by your own game via `consume()` (a gem pack, an\n undo, a boost). The portal tracks a units-purchased/units-consumed balance; it never knows what\n a \"gem\" is, only that this SKU was bought and later burned.\n- `type: \"entitlement\"` — bought once, owned durably, forever, restorable on any device (a skin,\n \"remove ads\", a premium mode). Defaults `maxPerPlayer` to `1` if you don't set it explicitly —\n set a higher `maxPerPlayer` only if you genuinely want a player able to own more than one\n (rare for an entitlement; more common for a consumable pack with a purchase cap).\n\n**SKUs go live only when the build that declares them is approved and deployed** — exactly like\neverything else in `miniant.json` ([manifest reference](/guide/requirements/technical)). A reviewer\nsees a diff of exactly what's new, changed, or removed between the awaiting build and what's\ncurrently live, prices included. A roll-back to an earlier build re-materializes *that* build's\nSKUs — the live SKU catalog can never drift from the live code, in either direction.\n\n## `Miniant.wallet.purchase({ skuId, quantity? })`\n\n```ts\nconst result = await Miniant.wallet.purchase({ skuId: \"gems_100\", quantity: 2 });\n// { status: \"completed\", receipt: { receiptId, skuId, quantity, ticketsPaid, signedReceipt? } }\n// { status: \"declined\", code: \"insufficient_funds\" | \"declined\" | \"guest_not_allowed\" |\n// \"unavailable\" | \"sku_not_found\" | \"already_owned\" | \"sku_not_registered\" }\n```\n\n- `quantity` defaults to `1`, capped at `100`, and is **consumables-only** — an entitlement SKU\n must be bought with quantity 1 (omit the field, or pass `1` explicitly).\n- **An `entitlement` purchase gets the portal's own Confirm/Cancel dialog** — reviewed title and\n registered price × quantity (e.g. \"Golden Dice skin — 200 tickets ($2.00)?\"), pulled from the\n SKU registry your build declared, never from any string your game passes at call time. **A\n `consumable` purchase debits instantly with no dialog** (2026-08-27 ruling: an undo or extra\n life is a fast in-game action, not a considered purchase — your own control must show the price\n before the tap, e.g. \"Undo · [Ticket mark] 2\", see the Ticket mark note below). Either way the pricing invariant holds: **a game can never\n charge a player an amount its own code makes up** — `purchase()` only ever buys a\n manifest-declared, portal-reviewed, portal-priced SKU.\n- Never rejects for a normal decline (insufficient funds, the player cancelling, a guest session,\n already owning a `maxPerPlayer`-capped entitlement) — branch on `result.status`, no try/catch\n needed for \"the player said no\" or \"they already own it.\"\n- `receipt.signedReceipt` is present when the platform has a signing key configured (it usually\n does) and is for your own backend, not your client-side game code — see\n [Receipt verification](/guide/sdk/receipts) if you run a developer server that needs to verify a\n purchase independently of your own client.\n\n## `Miniant.wallet.getEntitlements()`\n\n```ts\nconst { entitlements } = await Miniant.wallet.getEntitlements();\n// [{ skuId: \"gold_dice\", kind: \"entitlement\" }, { skuId: \"gems_100\", kind: \"consumable\", unitsRemaining: 3 }]\n```\n\nCall this on boot to discover what this player owns — on *any* device, since ownership lives\nportal-side, not in your own save data. `unitsRemaining` is only meaningful for `kind:\n\"consumable\"`; an entitlement's ownership is the array entry's mere presence.\n\n**Rehydration rule:** if your game declares both `capabilities.wallet` and `capabilities.saveState`,\nany state that references an owned item (an equipped skin, an unlocked mode) must be re-derived\nfrom `getEntitlements()` on load, never trusted from `state.load()` alone. Saved state can go\nstale (a refund revokes an entitlement portal-side with no way to also rewrite your saved blob);\n`getEntitlements()` is always the live, authoritative answer. This is checked in review for any\ngame declaring both capabilities.\n\n## `Miniant.wallet.consume({ skuId, units, idempotencyKey })`\n\n```ts\nconst result = await Miniant.wallet.consume({ skuId: \"gems_100\", units: 20, idempotencyKey: \"convert-to-gold-1\" });\n// { ok: true, unitsRemaining: 30 }\n// { ok: false, code: \"...\", unitsRemaining: 50 }\n```\n\nBurns already-purchased consumable units — this is how a portal-side \"100 Gems\" purchase becomes\nyour game's own internal gem balance, or is spent one-for-one on in-game actions, whichever model\nyou use. Never touches the wallet's ticket balance; it only decrements the SKU's own\nunits-purchased/units-consumed counter, and only ever downward, never below zero.\n\n**`idempotencyKey` is yours to set, not the SDK's** — you own consume-idempotency, since only your\ngame knows whether a given purchase-to-internal-currency conversion already happened (e.g. after a\nnetwork retry or a reload mid-conversion). A repeated call with the same key burns nothing a second\ntime and just returns the current state. Use a key that's stable for \"this exact conversion event\"\n(e.g. `\"${receiptId}-initial-grant\"`), not a fresh random value per call, or every retry will look\nlike a brand-new burn.\n\n## `wallet.on(\"entitlements_changed\", cb)`\n\nFires with no arguments after any successful purchase. A conformant listener re-calls\n`getEntitlements()` to fetch the new state rather than trying to infer what changed from the event\nitself — treat it purely as an invalidation signal.\n\n## Migrating an existing `requestSpend` call\n\n`Miniant.wallet.requestSpend({ amount, reason })` still works, but only as sugar over a registered\nSKU now — it is honored **only** when your manifest declares a consumable SKU with `id === reason`\nand `priceTickets === amount`. Any other pair now declines with `sku_not_registered` instead of\ndebiting an arbitrary, unreviewed amount. Migrating is a manifest-only change, no game code touched.\n\n**`requestSpend` never opens a confirm dialog, even when it resolves against a `skus` entry.**\nThe portal debits the moment `requestSpend` matches a registered SKU at the right price, exactly\nthe way a declared `pricing.actions` price debits instantly (see below): no Confirm/Cancel step,\nno \"are you sure.\" There is no way to opt a consumable into a dialog — a consumable is instant\nvia `wallet.purchase()` too; only an `entitlement` purchase ever shows the portal's confirm\ndialog. Show the price on your own control instead.\n\n```ts\n// Game code — unchanged either way:\nawait Miniant.wallet.requestSpend({ amount: 1, reason: \"undo\" });\n```\n\n```jsonc\n// miniant.json — add this SKU, nothing else:\n{\n \"capabilities\": { \"wallet\": true, \"relay\": false, \"saveState\": false },\n \"store\": { \"skus\": [\n { \"id\": \"undo\", \"type\": \"consumable\", \"title\": \"Undo move\", \"priceTickets\": 1 }\n ]}\n}\n```\n\nNew integrations should call `wallet.purchase({ skuId: \"undo\", quantity: 1 })` directly instead of\n`requestSpend` — the back-compat shim exists for games that already shipped `requestSpend`, not as\na second way to design a new integration.\n\n## Declared action prices (undo, extra life)\n\nThe SKU registry above models a deliberate, store-UI purchase — a player taps \"Buy,\" picks a price\nthey already know. Some actions don't fit that shape: an undo that gets pricier the more you lean\non it in the same turn, an extra life whose cost escalates the longer a run goes. For those, declare\na `pricing` block instead of (or alongside) `store`:\n\n```jsonc\n{\n \"capabilities\": { \"wallet\": true, \"relay\": false, \"saveState\": false },\n \"pricing\": {\n \"actions\": {\n \"undo\": { \"tiers\": [2, 8, 15], \"maxPerTurn\": 3 }\n }\n }\n}\n```\n\nEach entry in `actions` is keyed by an action id (same `^[a-z0-9_]{1,40}$` shape a SKU `id` uses)\nand declares:\n- `tiers` — 1 to 10 integers, `1`–`10000` tickets each, **non-decreasing**. `[2, 8, 15]` means the\n first undo this turn costs 2, the second costs 8, the third costs 15 — a later use of the same\n action can never be cheaper than an earlier one.\n- `maxPerTurn` (optional) — caps how many priced uses of the action a single turn allows.\n\nLike `store`, `pricing` requires `capabilities.wallet: true` and only takes effect once the build\nthat declares it is approved and deployed.\n\n**Your game calls the same `requestSpend`, nothing new to learn:**\n\n```ts\n// First undo this turn:\nawait Miniant.wallet.requestSpend({ amount: 2, reason: \"undo\" });\n// Second undo, same turn:\nawait Miniant.wallet.requestSpend({ amount: 8, reason: \"undo\" });\n```\n\nAn `amount` is accepted only when it's one of `pricing.actions[\"undo\"].tiers` exactly — the portal\nvalidates this server-side (declining `sku_not_registered` otherwise, the same code an unregistered\nSKU gets), so there's no way to request a price your manifest didn't declare. The legacy SKU-shim\npath (`store.skus` with a matching `id`/`priceTickets`) is checked first and still works unchanged\nif you already have one; `pricing` is only consulted when no SKU matches.\n\n**Escalation order and `maxPerTurn` are entirely your game's own responsibility.** The portal has no\nnotion of \"which use this is\" or \"how long the turn has been going\" — it only ever checks that the\n`amount` you send is *some* declared tier for that action, not that it's the *right* one for this\nparticular use. Your own code decides the first undo this turn should ask for tier 0's price, tracks\nthat it already spent one, and asks for tier 1's price on the second — the portal simply won't debit\nanything outside the declared list.\n\n**This does not open a confirm dialog, same as every `requestSpend` call.** The portal debits a\ndeclared action price **immediately** the moment `requestSpend` resolves the tiers check — no\nConfirm/Cancel step, no \"are you sure.\" That's deliberate: an undo (or any other action-priced\nspend) is a fast, repeated, in-the-moment control, not a considered purchase decision, and a\nconfirm dialog on every use would make the control unusable. The trade-off is that **your own UI\nis now the only place the player sees the price before it's charged** — the portal never shows it\none. Your undo control must display the price itself before the tap (e.g. \"Undo · [Ticket mark] 2\"), reading\nthe next tier from your own escalation tracking (the same tracking `maxPerTurn` already requires\nyou to keep, above). Call `Miniant.wallet.getBalance()` to disable the control when the player\nclearly can't afford the next tier, and handle an `insufficient_funds` decline gracefully either\nway (the portal still shows its own translucent low-balance notice on that decline, but your\ncontrol should not be left in a state that implies the spend went through).\n\n**The Ticket mark.** Show every Ticket price with the Miniant Ticket mark, the same one the portal\nuses, leading the number like a currency sign: the mark, then \"2\". Load it from\n`https://www.miniant.games/brand/ticket-mark.png` (122×96, transparent; it is served\ncross-origin-readable, so it loads inside your game frame) and draw it about 1em wide beside the\nnumber. Never an emoji, a line icon or a ticket of your own.\n\n**The rule, stated once and generally: only an `entitlement` purchase ever opens a confirm\ndialog.** Everything a game spends mid-play is instant — `requestSpend` against a `skus` entry\n(see [Migrating an existing `requestSpend` call](#migrating-an-existing-requestspend-call) above),\n`requestSpend` against a declared `pricing.actions` entry here, and `wallet.purchase()` of a\n`consumable` SKU all debit the moment the portal validates the amount/price, with nothing shown\nin between. Only `wallet.purchase()` of an `entitlement` (a permanent unlock — bought rarely and\ndeliberately) gets the reviewed title/price confirm dialog described earlier in this guide.\n"},{"slug":"sdk/cosmetics","title":"Cosmetics","section":"SDK","agentSummary":"Cosmetics come from two separate systems, developer-sold entitlement SKUs and the portal's own cosmetic catalog; this page covers rendering and ownership rules for both.","checklist":[{"id":"cosmetics.no-gameplay-gate","text":"Never gate gameplay, levels, or an advantage behind a cosmetic on either the SKU or portal-catalog system.","verify":"manual"},{"id":"cosmetics.default-for-unknown","text":"Render your game's own default appearance for any unrecognized or unowned cosmetic/entitlement id, never an error or broken asset.","verify":"manual"},{"id":"cosmetics.frame-avatar-portal-only","text":"Never render the frame or avatar cosmetic slots inside your own game; those are drawn only by the portal's own chrome.","verify":"manual"}],"body":"# Cosmetics — yours and the portal's\n\nMiniant has two entirely separate cosmetic systems, and it's worth keeping them straight before you\nbuild anything: **yours** (SKUs you declare and sell, art you supply) and **the portal's** (a\ncatalog the portal itself owns, prices, and sells — you just render the ids it hands you). Nothing\nstops a game from using both.\n\n## Yours: entitlement SKUs\n\nA cosmetic you sell — a board skin, a dice reskin, an \"unlock the gold set\" bundle — is just an\n`entitlement`-kind SKU in your `store.skus` manifest block, exactly like any other durable unlock.\nSee [In-game purchases](/guide/sdk/purchases) for the full `store.skus` shape, the `priceTickets`/\n`priceUSD` fields, and `Miniant.wallet.purchase()`. The short version for a cosmetic specifically:\n\n```jsonc\n{\n \"capabilities\": { \"wallet\": true, \"relay\": false, \"saveState\": false },\n \"store\": {\n \"skus\": [\n { \"id\": \"gold_dice\", \"type\": \"entitlement\", \"title\": \"Golden Dice skin\", \"priceTickets\": 200,\n \"maxPerPlayer\": 1 }\n ]\n }\n}\n```\n\nTo make an item players equip into a named slot of your game (a cue, a jersey) and earn 70% of\nthe Tickets spent on it, see [Store items](/guide/sdk/store-items).\n\nYou supply the art and the id; the portal prices it, reviews it, and sells it through its own\nConfirm/Cancel wallet dialog — your game never renders a store UI or sets a price for it (see\n[Declared action prices](/guide/sdk/purchases#declared-action-prices-undo-extra-life) for the one\nother place prices can come from, and why a cosmetic is never one of them).\n\n**Reading ownership — two calls, two different seats:**\n\n- `Miniant.wallet.getEntitlements()` — the **local player's own** owned SKUs (consumables and\n entitlements both). Call it on boot to discover what this player owns, on any device — see\n [In-game purchases](/guide/sdk/purchases#miniantwalletgetentitlements) for the full contract and\n the rehydration rule if you also declare `capabilities.saveState`.\n- `context.participants[].entitlements` — every **other seat's** owned *entitlement-kind* SKU ids\n (a bare `string[]`, e.g. `[\"gold_dice\"]`), present on `PlayContext` from `init()` onward with no\n relay round-trip. This is how you render an opponent's golden board without ever asking them or\n the relay — you already have the answer at session start. It's present for every participant,\n including your own entry (the same value `getEntitlements()` would give you, filtered to\n entitlement kind and flattened to bare ids) — so one code path can read any seat's ownership the\n same way, local or remote.\n\n```ts\nconst context = await Miniant.init({ sdkVersion: 1 });\nfor (const p of context.participants) {\n const ownsGoldDice = p.entitlements?.includes(\"gold_dice\") ?? false;\n // render p's board however your game likes, gold_dice or default\n}\n```\n\n**Consumables never appear in `participants[].entitlements`** — a units-metered burnable (a gem\npack, an undo) was never \"just owned\" the way a cosmetic is, so it's filtered out entirely, same\nrule `wallet.getEntitlements()`'s own `kind: \"entitlement\"` vs `kind: \"consumable\"` split follows.\nAn id you don't recognize, or an id absent from the list at all (unowned, a guest, a bot-filled\nseat, or simply an older portal build that predates this field), all mean the same thing: **render\nyour game's own default appearance.** Never treat an absent or unrecognized entitlement as an\nerror, and never block a seat from playing over one — a cosmetic decorates, it never gates.\n\n## The portal's: `cosmetics` on `player`/`participants`\n\nSeparately, every seat also carries `cosmetics` — ids from the **portal's own** cosmetic catalog,\npriced, sold, and equipped entirely through the portal's own wallet dialog and equip UI. Your game\nnever sells these, never prices them, and never even knows they exist until they show up on\n`PlayContext`:\n\n```ts\nplayer: { id, displayName, avatarUrl, isGuest, locale, seat, countryFlag, cosmetics, entitlements }\n```\n\n```ts\ncosmetics: { boardTheme?, diceSkin?, tokenSkin?, striker?, frame?, avatar?, emotePack? }\n```\n\nSee [User](/guide/sdk/user#cosmetics) for the full field-by-field breakdown — the short version:\n`boardTheme`/`diceSkin`/`tokenSkin`/`striker`/`emotePack` are **optional hints your game MAY\nhonour** (maintain your own id → asset table, and always fall back to your default appearance for\nanything you don't recognize); `frame` and `avatar` are rendered by the **portal's own chrome**\n(player list, spectator panel, leaderboard, profile pages) and never inside your iframe at all —\nignore both unless your game draws its own seat portraits it wants to keep visually consistent with\nthe portal's.\n\n## Yours vs. the portal's, side by side\n\n| | Yours (`store.skus`, `type: \"entitlement\"`) | The portal's (`cosmetics`) |\n|---|---|---|\n| Who supplies the art | You | The portal |\n| Who prices it | You (`priceTickets`/`priceUSD`) | The portal |\n| Who sells it | The portal's wallet dialog, from your manifest | The portal's own store UI |\n| Where you see ownership | `wallet.getEntitlements()` (yours), `participants[].entitlements` (everyone's) | `player.cosmetics` / `participants[].cosmetics` |\n| Slots | None — a flat SKU id | `boardTheme`, `diceSkin`, `tokenSkin`, `striker`, `frame`, `avatar`, `emotePack` |\n| Rendered by | Your game, entirely | Your game (most slots) or the portal's own chrome (`frame`, `avatar`) |\n\n## Rules that apply to both\n\n- **Never set a price in game code**, for either system — a SKU's price lives in your manifest\n (reviewed and materialized at build-approval time), and the portal cosmetic catalog's prices are\n entirely the portal's own. Your code only ever reads ids and ownership, never writes a number.\n- **Never gate gameplay behind a cosmetic.** Both systems exist to decorate, not to unlock rules,\n levels, or an advantage — a player who owns nothing must be able to play your game exactly like\n one who owns everything. Reviewers check for this the same way they check any other purchase\n flow (see [what you must not build](/guide/requirements/technical)).\n- **Unknown or unowned always means \"render your default.\"** Never block, error, or show a broken\n asset for an id you don't recognize, on either system — a new catalog item ships far more often\n than your game's own asset table does.\n\nSee [In-game purchases](/guide/sdk/purchases) for the full SKU/pricing contract, and\n[Declared action prices](/guide/sdk/purchases#declared-action-prices-undo-extra-life) for the\nescalating-cost `pricing.actions` mechanism — cosmetics never use it (a cosmetic's price never\nescalates with use), but it's the other place a `store`-less price can legitimately come from.\n"},{"slug":"sdk/store-items","title":"Store items","section":"SDK","agentSummary":"Store items: declare slots and cosmetic SKUs in miniant.json, read the equipped id from player.cosmetics, admin reviews each item, you earn 70% of the Tickets spent from a monthly pool.","checklist":[{"id":"store-items.slot-ids-match-reads","text":"Each store.slots id is exactly the key your game reads from player.cosmetics (self) or participants[].cosmetics (everyone).","verify":"manual"},{"id":"store-items.price-in-band","text":"Every item's priceTickets is 100 to 1500 in steps of 50.","verify":"manual"},{"id":"store-items.preview-in-build","text":"Every item's preview is an image inside the build, 300 KB or less (webp recommended).","verify":"manual"},{"id":"store-items.unknown-id-default","text":"An equipped id your game does not recognise renders your default look, never an error.","verify":"manual"}],"body":"# Store items\n\nA **store item** is a cosmetic you make and the portal sells: a cue, a board theme, a jersey. You\ndeclare the changeable parts of your game (**slots**) and the items that fill them in\n`miniant.json`. They ship with a build, Miniant reviews each item, and the portal sells the ones\nthat pass. This is the same `store` block as [In-game purchases](/guide/sdk/purchases); an item is\nan `entitlement` SKU with a `cosmetic` block. For the portal's own catalog (frames, avatars, board\nthemes you do not sell) see [Cosmetics](/guide/sdk/cosmetics).\n\n## How items reach the store\n\nThere is no separate upload. Items travel inside your build:\n\n1. Put each item's preview image in your build, e.g. `store/gold_cue.webp`.\n2. Declare your slots and items in `miniant.json` (below).\n3. Make the game draw each item when it is equipped (below).\n4. Run `miniant-validate`, then publish a build the usual way ([Publishing your\n game](/guide/publishing)).\n5. Miniant reviews the build and each item with it. Approved items go on sale in the portal store\n when the build goes live; your game's **Store Items** tab shows each one's state.\n\nTo add, change or remove an item, publish a new build. Players buy items in the portal store, never\ninside your game, so a game that sells only store items does not need `capabilities.wallet`.\n\n## Declare slots\n\n```jsonc\n{\n \"store\": {\n \"slots\": [\n { \"id\": \"cue\", \"label\": \"Cue\", \"visibility\": \"everyone\" },\n { \"id\": \"boardTheme\", \"label\": \"Board theme\", \"visibility\": \"self\" }\n ]\n }\n}\n```\n\n- `id` is the key your game reads from the player's cosmetics: camelCase, 2 to 32 characters\n (`^[a-z][a-zA-Z0-9]{1,31}$`). Up to 16 slots.\n- `label` is what the store shows (up to 40 characters).\n- `visibility` is `\"self\"` when only the owner needs to see the item (a board theme), or\n `\"everyone\"` when opponents and spectators see it too (a cue, a jersey).\n\n## Declare items\n\n```jsonc\n{\n \"store\": {\n \"skus\": [\n {\n \"id\": \"gold_cue\",\n \"type\": \"entitlement\",\n \"title\": \"Gold cue\",\n \"priceTickets\": 400,\n \"cosmetic\": { \"slot\": \"cue\", \"preview\": \"store/gold_cue.webp\" }\n }\n ]\n }\n}\n```\n\n- `cosmetic.slot` must be a slot you declared.\n- `title` is at most 20 characters, and no single word in it is longer than 12 characters, so the\n name fits two lines on the smallest store tile.\n- `type` must be `\"entitlement\"` and the price must be `priceTickets` (not `priceUSD`): 100 to 1500\n in steps of 50. The band is fixed by the portal.\n- `cosmetic.preview` is a relative path to a `.webp`, `.png` or `.jpg` **inside your build** (no\n leading `/`, no `..`). Keep it to 300 KB or less; webp is recommended. The portal shows it in the\n store.\n\n## Read the equipped item\n\nThe portal hands your game what each seat has equipped. Read it from the slot id:\n\n```ts\nconst context = await Miniant.init({ sdkVersion: 1 });\n\n// \"self\" slot: read the local player's own item.\nconst boardTheme = context.player.cosmetics.boardTheme;\n\n// \"everyone\" slot: read every seat's item.\nfor (const p of context.participants) {\n const cue = p.cosmetics.cue; // e.g. \"gold_cue\", or undefined\n}\n```\n\nAn id you do not recognise, or no id at all, means **render your default look**. Never error and\nnever gate gameplay behind an item. See [User](/guide/sdk/user#cosmetics) for the full `cosmetics`\nfield.\n\n## Review\n\nEach item is reviewed with the build it ships in. An admin can approve the build and pull\nindividual items; a pulled item is not sold, and a later rollback or recovery never brings it\nback. The **Store Items** tab of your game shows every item you declared and whether it is live,\nrejected or no longer on sale.\n\n## What you earn\n\nYou earn **70%** of the Tickets players spend on your items, same split as ads\n([Monetization](/guide/publishing/monetization)). It is paid from a monthly pool:\n\n```\nTicket value = net Ticket-pack revenue for the month ÷ all Tickets spent that month\nYour share = 70% × (Tickets spent on your items) × Ticket value\n```\n\nPurchases by your game's own publishers do not count. The figure lands on your monthly statement\nas the item share, next to the ad share; both count toward the $50 payout threshold. A draft month\nis marked *estimate* until the month's pack revenue is final. See [Publishing your\ngame](/guide/publishing#earnings) for the Earnings page.\n"},{"slug":"sdk/receipts","title":"Receipt verification","section":"SDK","agentSummary":"Tier-2 developer servers can verify a completed purchase's signedReceipt offline via ECDSA P-256, and check live revocation via the receipts/verify endpoint.","checklist":[{"id":"receipts.check-game-slug","text":"Reject a verified receipt whose gameSlug does not match your own game's slug, both offline and via the verify endpoint.","verify":"manual"},{"id":"receipts.cache-ttl","text":"Put a TTL of at most about an hour on any cached entitlement derived from a receipt, and re-check rather than caching forever.","verify":"manual"},{"id":"receipts.no-shared-secret","text":"Never require or send an API key, account, or shared secret when calling the receipts verify endpoint; the signed token is the authorization.","verify":"manual"}],"body":"# Receipt verification\n\nIf you run your own backend (`authorityModel: \"developer_server_validates\"`, tier 2 — see the\nanti-cheat questionnaire in [Publishing your game](/guide/publishing)), you can verify a\n[purchase](/guide/sdk/purchases) independently of your own game client, two ways: offline, and with\na live revocation check.\n\n## `signedReceipt` — verify offline\n\nEvery completed purchase gets a `signedReceipt` — a compact token over\n`{ receiptId, subjectId, gameSlug, skuId, quantity, ticketsPaid, issuedAt }`, signed **ECDSA P-256**\nwith a single platform keypair. Your game's client receives it in the `purchase()` result and\nforwards it to your own server (however you already get data there); your server verifies it with\nno Miniant account, credential, or shared secret of any kind — just the platform's public key,\nwhich is free to fetch:\n\n```js\n// Node 20+ / any runtime with a global crypto.subtle. Fetch once, cache indefinitely (the key\n// itself changes only on a deliberate platform-side rotation, which would be a breaking change we'd\n// announce).\nconst { jwk } = await fetch(\"https://www.miniant.games/api/publisher/receipts/public-key\").then((r) => r.json());\nconst publicKey = await crypto.subtle.importKey(\"jwk\", jwk, { name: \"ECDSA\", namedCurve: \"P-256\" }, false, [\"verify\"]);\n\nfunction base64UrlDecode(value) {\n const padded = value.replace(/-/g, \"+\").replace(/_/g, \"/\").padEnd(Math.ceil(value.length / 4) * 4, \"=\");\n return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));\n}\n\nasync function verifyReceiptOffline(signedReceipt, yourOwnGameSlug) {\n const [body, signature] = signedReceipt.split(\".\");\n const ok = await crypto.subtle.verify(\n { name: \"ECDSA\", hash: \"SHA-256\" },\n publicKey,\n base64UrlDecode(signature),\n new TextEncoder().encode(body),\n );\n if (!ok) return { valid: false, reason: \"bad_signature\" };\n\n const payload = JSON.parse(new TextDecoder().decode(base64UrlDecode(body)));\n // payload: { receiptId, subjectId, gameSlug, skuId, quantity, ticketsPaid, issuedAt }\n\n // YOUR responsibility, not the platform's: a valid signature only proves the platform signed\n // this receipt for *some* game. Reject anything that isn't yours.\n if (payload.gameSlug !== yourOwnGameSlug) return { valid: false, reason: \"wrong_game\" };\n\n return { valid: true, payload };\n}\n```\n\nA tier-0/1 game can simply ignore `signedReceipt` — nothing about the purchase flow requires you to\nverify it. It exists for the one case a client-trusting backend can't otherwise close: a modified\ngame client claiming a purchase happened that never did.\n\n## `POST /api/publisher/receipts/verify` — the one thing offline verification can't tell you\n\nA valid signature proves a receipt is genuine and unmodified; it cannot tell you whether the\npurchase has since been **refunded**. For that, call:\n\n```js\nconst res = await fetch(\"https://www.miniant.games/api/publisher/receipts/verify\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ signedReceipt }),\n});\nconst body = await res.json();\n// { status: \"valid\", receipt: { receiptId, subjectId, gameSlug, skuId, quantity, ticketsPaid, issuedAt } }\n// { status: \"revoked\", revokedAt: \"2026-...\", receipt: { ... } }\n// { status: \"invalid\" } -- 400: tampered token, bad signature, or malformed body. Same shape\n// regardless of which — don't try to distinguish the reason.\n```\n\n**No API key, account, or credential of any kind is required or accepted.** Possession of a\nvalidly-signed `signedReceipt` token is itself the entire authorization — the token already proves\nthe purchase is real and names its own `gameSlug`, so requiring anything more would be redundant.\nThis also means the endpoint does **not** check that the receipt belongs to *your* game — that's\nthe same check you already do offline (`payload.gameSlug !== yourOwnGameSlug` above); apply it to\nthis endpoint's response too before trusting a `\"valid\"` result.\n\n**Recommended cache TTL: re-check periodically, don't cache \"owns this\" forever.** If your server\ncaches an entitlement (e.g. \"this player owns `gold_dice`\") to avoid a network call on every\nrequest, put a TTL on that cache — somewhere in the range of a few minutes to at most an hour for\nanything gameplay-affecting, shorter if you can afford the traffic. A refund revokes the\nentitlement portal-side immediately; your cache won't know until it re-checks.\n[`getEntitlements()`](/guide/sdk/purchases) is the equivalent live check from your game's own\nclient side, for the same reason.\n"},{"slug":"sdk/progress-import","title":"Progress import","section":"SDK","agentSummary":"Tier-2 games with an importEndpoint receive a one-way, player-initiated, cosmetic-only progress import request signed by Miniant; documents the request/response contract.","checklist":[{"id":"import.https-only-endpoint","text":"Declare importEndpoint as an https:// URL; Miniant refuses to call anything else and never follows redirects from it.","verify":"manual"},{"id":"import.verify-purpose-and-slug","text":"Verify the assertion's signature, then check payload.purpose === 'progress_import' and payload.gameSlug matches your own game before trusting it.","verify":"manual"},{"id":"import.never-grants-rank-value","text":"Never let imported progress grant Tickets, affect rank, leaderboard position, or tournament eligibility; treat it as cosmetic progress only.","verify":"manual"},{"id":"import.respond-within-budget","text":"Respond with a 2xx JSON body containing blob within the 8-second timeout and roughly 65KB response cap.","verify":"manual"},{"id":"import.blob-under-cap","text":"Keep the returned blob within the same 64KB cap enforced on Miniant.state.save().","verify":"manual"}],"body":"# Progress import\n\nIf your game is also distributed outside Miniant (e.g. a Play Store release) and you run your own\nbackend (`authorityModel: \"developer_server_validates\"`, tier 2 — see the anti-cheat questionnaire\nin [Publishing your game](/guide/publishing)), you can declare an `importEndpoint` your own server\nimplements. A signed-in player who owns your externally-distributed build can then pull their\nprogress from it into Miniant, once, on their own request — cosmetically only, forever.\n\nThis is entirely opt-in. A tier-0/tier-1 game, or a tier-2 game that never sets `importEndpoint`,\nsimply never offers this — nothing else about your integration changes.\n\n## Declaring it\n\nAdd `importEndpoint` to your `miniant.json`, alongside your other manifest fields:\n\n```json\n{\n \"sdkVersion\": 1,\n \"importEndpoint\": \"https://your-backend.example.com/miniant/import\"\n}\n```\n\nIt must be `https://` — Miniant's server refuses to call anything else, and refuses to follow a\nredirect from it. This is the one endpoint anywhere in the SDK/manifest surface where Miniant's own\nserver calls a URL you control, so it's treated accordingly: HTTPS-only, no redirects, an 8-second\ntimeout, and a hard cap on how much of your response it will read (about 65KB — see \"What you\nreturn\" below). None of this is configurable from your side; build to it rather than around it.\n\n## What Miniant sends you\n\nA signed-in player who taps \"Import progress\" on your game's page triggers exactly one request,\ninitiated by Miniant's server, never by your game's client code:\n\n```\nPOST https://your-backend.example.com/miniant/import\nContent-Type: application/json\nAccept: application/json\n\n{ \"assertion\": \"<base64url-body>.<base64url-signature>\" }\n```\n\n`assertion` is a compact signed token — same envelope shape as a purchase's `signedReceipt` (see\n[Receipt verification](/guide/sdk/receipts)): base64url `{body}.{signature}`, **ECDSA P-256 +\nSHA-256**, signed with the same platform keypair a receipt is. Verify it exactly the same way,\nagainst the same already-published public key:\n\n```js\n// Node 20+ / any runtime with a global crypto.subtle. Fetch once, cache indefinitely — see\n// Receipt verification's own note on this: the key only changes on a deliberate, announced rotation.\nconst { jwk } = await fetch(\"https://www.miniant.games/api/publisher/receipts/public-key\").then((r) => r.json());\nconst publicKey = await crypto.subtle.importKey(\"jwk\", jwk, { name: \"ECDSA\", namedCurve: \"P-256\" }, false, [\"verify\"]);\n\nfunction base64UrlDecode(value) {\n const padded = value.replace(/-/g, \"+\").replace(/_/g, \"/\").padEnd(Math.ceil(value.length / 4) * 4, \"=\");\n return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));\n}\n\nasync function verifyImportAssertion(assertion, yourOwnGameSlug) {\n const [body, signature] = assertion.split(\".\");\n const ok = await crypto.subtle.verify(\n { name: \"ECDSA\", hash: \"SHA-256\" },\n publicKey,\n base64UrlDecode(signature),\n new TextEncoder().encode(body),\n );\n if (!ok) return { valid: false, reason: \"bad_signature\" };\n\n const payload = JSON.parse(new TextDecoder().decode(base64UrlDecode(body)));\n // payload: { pairwiseSubjectId, gameSlug, purpose, linkCode, issuedAt, nonce }\n\n // Two checks that are YOUR responsibility, same as with a receipt: a valid signature only\n // proves Miniant signed this for *something*.\n if (payload.purpose !== \"progress_import\") return { valid: false, reason: \"wrong_purpose\" };\n if (payload.gameSlug !== yourOwnGameSlug) return { valid: false, reason: \"wrong_game\" };\n\n return { valid: true, payload };\n}\n```\n\n**`purpose: \"progress_import\"`** is what stops this token from ever being confused with a purchase\nreceipt (or any future signed-token type) by a server that verifies the signature correctly but\nskips checking what the token is actually *for*. Reject anything that isn't exactly this value, the\nsame way you already reject a receipt that isn't for your own `gameSlug`.\n\nThe verified payload:\n\n| Field | Meaning |\n| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `pairwiseSubjectId` | See \"The pairwise subject id\" below. **Never the player's real Miniant account id.** |\n| `gameSlug` | Your game's slug — check it equals your own, exactly as above. |\n| `purpose` | Always the literal string `\"progress_import\"` for a valid request to this endpoint. |\n| `linkCode` | The proof-of-ownership code the player typed into Miniant's own import dialog. **Opaque to Miniant** — it never validates or interprets this value, only forwards it. Its format, length, and meaning are entirely yours to define and document to your players (an account ID, a redeem code, whatever your own external build already uses to identify an account). |\n| `issuedAt` / `nonce` | Token freshness bookkeeping, informational — mirrors the same fields on a purchase receipt. |\n\n## The pairwise subject id\n\n`pairwiseSubjectId` is **not** the player's real Miniant account id, and there is no way to derive\none from the other. It's a per-`(player, your game)` pseudonym: the same Miniant player gets a\n*different*, unlinkable `pairwiseSubjectId` for every different tier-2 game they import into — so\nif you publish two tier-2 games with import endpoints, you cannot tell from this id alone that the\nsame person imported into both. Treat it as an opaque key scoped to this one game, suitable for\n\"has this player already imported once\" bookkeeping on your own side, nothing more.\n\n## What you return\n\nA 2xx JSON response, within the 8-second budget and the ~65KB response cap mentioned above:\n\n```json\n{\n \"blob\": { \"...\": \"whatever your game's own save shape is\" },\n \"label\": \"Level 12 — Forest Temple\",\n \"progressPct\": 48\n}\n```\n\n- **`blob`** (required) — your save data, any JSON-serializable value. It's stored through the\n exact same path and the exact same **64KB cap** as `Miniant.state.save()` ([Data](/guide/sdk/data))\n — an oversized blob is rejected the same way an oversized `state.save()` call would be. This is\n the only field that actually becomes the player's new Miniant save for this game; get it into the\n same shape your own game's `state.load()` already expects to read back.\n- **`label`** (optional, 1–200 characters) — cosmetic slot metadata, same meaning as `state.save()`'s\n own `meta.label` ([Data](/guide/sdk/data)) — purely for Miniant's own \"Resume — {label}\" card.\n- **`progressPct`** (optional, integer 0–100) — same idea, for a progress indicator.\n\nMissing the `blob` key at all, a non-2xx status, an oversized or non-JSON body, or a timeout are all\ntreated identically by Miniant as \"the import failed\" — the player sees a generic error, and none of\nyour response body or the specific failure reason is ever echoed back to their browser. Log\nwhatever detail you need on your own side; there is nothing to negotiate with Miniant about the\nfailure reason.\n\n## Repeatable, one-way, cosmetic-only — permanently\n\nA few rules that never change, regardless of what your endpoint returns:\n\n- **One-way.** Miniant calls your endpoint; your endpoint is never called back, polled, or\n webhooked by Miniant afterward. There is no ongoing sync in either direction.\n- **Player-initiated, every time.** Nothing about this endpoint is ever called automatically or in\n the background — only when a signed-in player explicitly opens Miniant's import dialog and\n confirms, once per attempt.\n- **Repeatable, and destructive on Miniant's side.** A player can import again later (e.g. after\n more progress in your externally-distributed build); each successful import **overwrites** their\n existing Miniant save slot for this game. Miniant's own dialog tells the player this before every\n attempt — you don't need to.\n- **Cosmetic-only, with no exception.** Whatever `blob` claims — level, currency-looking fields,\n anything — it is progress and only ever progress once inside Miniant. It never grants Miniant\n Tickets, never affects rank or leaderboard position, and never confers tournament eligibility, no\n matter what state the player reached in your external build. See [Account\n integration](/guide/requirements/account-integration) and [Data](/guide/sdk/data)'s own \"Saved\n progress never buys rank\" section — the same firewall applies here, without exception. This is\n also covered as a binding term in the [Developer Terms](/terms) — read the \"Separate wallets and\n purchases\" clause if your game sells anything outside Miniant as well as inside it.\n"},{"slug":"sdk/multiplayer","title":"Multiplayer","section":"SDK","agentSummary":"Miniant.net is the only sanctioned way to exchange realtime gameplay messages in a room; documents connect/send/sendTo, syncRequest, and the message-rate budget.","checklist":[{"id":"mp.no-own-websocket","text":"Never open a custom WebSocket for gameplay messaging when capabilities.relay is declared; use Miniant.net exclusively.","verify":"validator:sdk.no-own-networking"},{"id":"mp.use-net-surface","text":"Use only Miniant.net connect/send/sendTo/on/close for relay messaging, the entire sanctioned surface.","verify":"validator:relay.net-used"},{"id":"mp.answer-sync-request","text":"Tag every complete-state broadcast {snapshot:true}, and answer net.on('syncRequest') the same way — that event fires only for a reconnecting GAME seat, never a spectator join.","verify":"manual"},{"id":"mp.send-action-at-input-time","text":"Send the action message the moment the input lands and the resolved outcome separately — never wait for your local animation or physics to finish before the first send.","verify":"manual"},{"id":"mp.honest-message-rate","text":"Declare an honest network.expectedMessagesPerMinute matching real traffic, not a padded value.","verify":"manual"},{"id":"mp.report-peer-availability","text":"Call Miniant.reportPeerAvailability at every seat-availability transition for a hand-rolled realtime game with an unavailable policy.","verify":"manual"}],"body":"# Multiplayer\n\nIf your manifest declares `capabilities.relay: true` ([manifest reference](/guide/requirements/technical)),\na room-based session (`online_friends` or `online_random`) hands you a non-null `context.room`, and\n`Miniant.net` is your only sanctioned way to exchange gameplay messages with the other seats in it —\nsee [what you must not build](/guide/requirements/technical), rule 3: **do not open your own\nWebSocket** for this unless you're also running your own validating (tier 2) server. For the\nrequirements-level framing — declaring modes, lobby sizes, and the `network` block — see\n[Multiplayer requirements](/guide/requirements/multiplayer). To let a friend watch a match without\njoining it, see [Spectating](/guide/sdk/spectating).\n\nThis page documents the raw `Miniant.net` surface — the contract for a `sync: \"realtime\"` mode,\nthe default when a mode omits `sync` ([Choose a sync\nmode](/guide/requirements/multiplayer#choose-a-sync-mode-for-an-online-mode)). If your mode\ndeclares `sync: \"turn_based\"` instead, use [Turn Kit](/guide/sdk/turn-kit) — it's built on this\nsame surface, so most of what's below (reconnect behavior, the message-rate budget) still applies,\nbut you drive it through `kit.endTurn()` rather than calling `net.send()` yourself.\n\n```ts\nawait Miniant.net.connect(); // opens the relay WebSocket for this session's room\n\nMiniant.net.on(\"message\", ({ from, data }) => {\n // `from` is the sending seat number; `data` is exactly what that peer passed to send()/sendTo(),\n // untouched by Miniant in transit.\n});\n\nMiniant.net.on(\"throttled\", ({ retryAfterMs, observedRate }) => {\n // You are sending faster than your own declared budget allows — back off. See \"Message-rate\n // budget\" below.\n});\n\nawait Miniant.net.send({ type: \"move\", x: 3, y: 4 }); // broadcast to every other seat\nawait Miniant.net.sendTo(\"1\", { type: \"ping\" }); // to one seat (seat number, as a string)\n\n// ... later, e.g. on your own \"leave game\" action ...\nawait Miniant.net.close();\n```\n\n## Send the action when it starts, not when it ends\n\nYou own the message shape in a `realtime` mode, and the single most common protocol mistake is\nsequencing: the acting client plays its own animation to completion, *then* sends the resolved\noutcome — a final velocity, a landed shot, a score. On the other seat that reads as a slideshow:\nnothing happens, then a result materializes mid-air with no wind-up and no tell. Send the action\nthe moment the input lands (the aim, the strike, the release — whatever starts the motion) and let\nthe receiving client animate from it; the resolved outcome is a *second* message, reconciled after\nthe fact. Anything sampled continuously (a run-up, a keeper drifting) streams as\n`{ reliable: false }` frames per the bullet below. This is the requirements-level rule at\n[Broadcast the action, not just the outcome](/guide/requirements/multiplayer) — and with\n`capabilities.spectate` declared, these same frames are what a spectator watches.\n\n## The `Miniant.net` surface\n\n- **`connect()` / `send()` / `sendTo()` / `on()` / `isConnected()` / `close()`** are the entire\n surface. `on()` returns an unsubscribe function.\n- **Know whether *you* are the one who dropped.** `isConnected()` reports whether your own socket\n is open right now, and `on(\"connected\", cb)` / `on(\"disconnected\", cb)` give you the edges —\n `connected` carries `{ reconnected }` (`false` on the session's first open, `true` on every\n re-open after a drop), `disconnected` carries `{ code, reason, willReconnect }`. This matters\n more than it looks: with no frames arriving, *\"my opponent went quiet\"* and *\"I lost the relay\"*\n are indistinguishable from inside your game. A game that decides **\"nobody has moved, so my\n opponent forfeited — I win\"** from silence alone gets that wrong under a network partition,\n where both clients reach the same conclusion at the same moment and each reports a win for the\n same match. Prefer a relay-reported `peerLeft` as the trigger for a forfeit; if all you have is\n silence, end the match as `abandoned` rather than declaring a winner.\n- **A `reconnected: true` is the moment to distrust your own state.** The relay can answer a cold\n reconnect from its own cached snapshot rather than from a live peer, and the two are\n indistinguishable on the wire — so the first snapshot you get back may be *older* than the true\n state. Apply it, but keep input locked until you hold a snapshot at or above the state version\n you last had.\n- **Calling `send`/`sendTo` before `connect()` resolves — never rejects.** Messages sent before the\n socket is open (including your very first call, before `connect()` has finished) are queued\n locally, in order, and flushed to the socket the moment it opens — you do not need to await\n `connect()` before you start calling `send`. The same queueing applies during a disconnect/\n reconnect: reliable sends made while offline queue rather than fail. Pass `{ reliable: false }`\n for a message that's only worth delivering *right now* (e.g. one frame's input sample) — an\n unreliable send that can't go out immediately is dropped, not queued, so you never deliver a\n stale value late.\n- **Reconnects automatically, with backoff**, on any unexpected disconnect — you never call\n `connect()` a second time yourself. The retry delay starts small and doubles up to a cap, reset\n after any successful reconnect. Your own `close()` call and a `roomClosed` event both stop\n reconnection — they're treated as final.\n- **Listen for `\"throttled\"`.** The relay enforces your own declared `network.expectedMessagesPerMinute`\n ([manifest reference](/guide/requirements/technical)) — exceeding it repeatedly gets you a\n `throttled` event first, and the relay closing your connection outright if you keep ignoring it.\n A conformant game reduces its send rate when it sees one, rather than treating it as informational\n only.\n- **No delivery acknowledgement exists.** `send`/`sendTo` resolving means \"handed to the transport,\"\n not \"received by a peer\" — build your own game-level ack if you need one.\n- `sendTo(peerId, payload)` — `peerId` is the target seat's number as a string (`\"1\"`, `\"2\"`, …).\n There is no peer identity independent of seat; `on(\"peerJoined\"/\"peerLeft\", cb)` payloads carry\n the same `peerId` string.\n\n## Absent players\n\nA seat going quiet mid-match — screen off, tab backgrounded, network drop, or the player switching\napps — is normal, not an error state. If your mode declares `unavailable` ([manifest\nreference](/guide/requirements/technical), [the policy\ncontract](/guide/requirements/multiplayer#when-a-player-becomes-unavailable)), the portal expects\nyour `realtime` game to implement the same availability semantics [Turn Kit](/guide/sdk/turn-kit)\ngets for free — this section is that recipe. A [Turn Kit](/guide/sdk/turn-kit) game never needs any\nof this itself; the kit already does it.\n\n**The recipe, mirroring [Turn Kit](/guide/sdk/turn-kit)'s own internal implementation exactly —\nbuild your own version of the same five things:**\n\n1. **A 5s liveness beacon, sent only while your tab is visible.** A hidden-tab beacon (or no\n beacon at all) is evidence of *absence*, never of liveness — a peer that stops beaconing, or\n whose beacons all say \"hidden,\" should be treated exactly like one that stopped sending\n entirely.\n2. **Away/back signaling on visibility transitions**, sent immediately on\n `visibilitychange`/`pagehide` rather than waiting for the next beacon — a fast path so the rest\n of the room learns \"gone\" and \"back\" without waiting out a full beacon interval.\n3. **A per-seat last-activity clock**, refreshed by any real game frame from that seat, a `\"back\"`\n signal, or a beacon that isn't marked hidden. A peer with no refresh for your mode's\n `timeoutSeconds` has gone unavailable.\n4. **A lowest-available-seat driver election**, staggered by rank: when a seat goes unavailable,\n the currently-available occupied seats (excluding the target) sort ascending, and each becomes\n eligible to act — auto-driving a move, or retiring/deciding the match under `\"forfeit\"` — at\n `deadline + rank * 3000ms`. The lowest-ranked candidate acts first; each further rank waits\n another 3s, so a still-silent target eventually gets handled even if a lower-ranked candidate has\n also gone quiet.\n5. **Call `Miniant.reportPeerAvailability({ seat, state, deadlineMs? })` at every transition** —\n `\"grace\"` when a seat's unavailability window begins (with `deadlineMs`, so the portal can render\n a countdown), `\"returned\"` when it comes back, and `\"auto\"`/`\"retired\"`/`\"forfeit\"` at your\n policy's own trigger points. **This is not optional decoration** — it's the only way the\n portal's grace/auto-pilot/forfeit banner chrome renders at all. Skip it and your game may work\n correctly while every player sees nothing but silence during a takeover.\n\n**Your frame bytes are your own** — the beacon/away/back/election messages above are payloads your\ngame defines and every peer's build already understands, since every seat runs the same build. What\nthe platform actually requires is the *semantics* (evidence, timing, election) and the\n`reportPeerAvailability()` calls — not any particular wire shape.\n\nA reconnecting peer's `peerJoined` (or your own `\"back\"` evidence) reclaims their seat exactly as if\nthey'd never left. The portal's own Exit flow gives you the same courtesy when a player leaves on\npurpose: it sends `terminate` with reason `\"player_exit\"` *before* it ends the session — your moment\nto broadcast a resignation message to the remaining seats, rather than that seat just going silent\nwith no explanation. `player_exit` **ends the game** and is recorded as a **forfeit loss** for that\nseat, never `\"abandoned\"` (2026-09-17) — the portal clears whatever it had saved for this\nplayer/game/mode the moment this fires, so there's nothing to flush for it — see\n[Scores and results](/guide/sdk/scores#reporting-results-in-an-online-multi-seat-session) for how\neach seat's own result reporting works in a room, and [Game](/guide/sdk/game)'s \"Pause, resume, and\nterminate\" for the full reason-by-reason contract.\n\nBuilding this yourself is real work — [Turn Kit](/guide/sdk/turn-kit) is the canonical\nimplementation of exactly this recipe (`onSeatUnavailable`/`onSeatAvailable`/`onAutoTurn`/\n`onSeatRetired`/`onMatchDecided`/`onSeatAutoDriven`, plus the `reportPeerAvailability` wiring, all\nbuilt in). If your mode's rotation is naturally turn-based, using it instead of hand-rolling this\nrecipe is almost always the better trade.\n\n**Spectators never appear here.** A spectator joining or leaving the room never fires `peerJoined`/\n`peerLeft` for anyone — spectators are invisible to the \"who's present\" story entirely, and a\nspectator join never triggers `syncRequest` either (removed 2026-09-21 — see\n[Spectating](/guide/sdk/spectating) for why). A spectator's own state bootstrap is served straight\nfrom the relay's cache of your last `{snapshot: true}`-tagged broadcast, entirely outside your\ngame's code — see [`snapshot:true`](/guide/sdk/spectating#snapshottrue-and-catching-up-a-spectator-mid-match).\n\n## Answering `syncRequest`\n\n**A conformant `realtime` game must answer `syncRequest`.** The relay sends\n`{\"type\":\"sync_request\"}` to exactly one connected game-channel socket when a game seat itself\n(re)joins a room that already has another live peer — a player refreshing mid-match, for example.\n**Not on a spectator join** — that case never sends this event (see \"Spectators never appear here\"\nabove). Your game is expected to respond by rebroadcasting a full state snapshot, the same one you\nalready send on a normal state change, tagged the same way:\n\n```ts\nMiniant.net.on(\"syncRequest\", () => {\n Miniant.net.send(getFullSnapshot(), { snapshot: true }); // the exact same snapshot shape your other broadcasts use\n});\n```\n\n**The relay marks your answer (SDK 2026.10.17).** A `{snapshot: true}` broadcast from the seat the\nrelay just asked, within 10 seconds of the request, reaches every client with `syncAnswer: true` on\nits `message` event (`NetMessageEvent.syncAnswer`). Nothing else carries it: a broadcast from any\nother seat, or one sent without `snapshot: true`, arrives unmarked. A game that adopts a peer's\nfull state on resync can therefore tell the real answer from a frame some other player chose to\nsend. Two rules follow:\n\n- **Send the answer with `{ snapshot: true }`**, as above. An untagged answer is never marked.\n- **Decide who may resync you, with `Miniant.net.resyncDropReason(event, rule)` (SDK 2026.10.18).**\n It returns `null` to take the frame, or the reason to drop it:\n\n ```ts\n Miniant.net.on(\"message\", (event) => {\n if (event.data?.k === \"sync\") {\n // \"sync\" is sent ONLY as the answer to syncRequest: take it with the relay's mark.\n if (Miniant.net.resyncDropReason(event, { kind: \"answer-only\" })) return;\n } else if (event.data?.k === \"snap\") {\n // \"snap\" is also your periodic board, unmarked by design: take it from the authority seat.\n if (Miniant.net.resyncDropReason(event, { kind: \"shared\", authority: hostSeat })) return;\n }\n applyFrame(event.data);\n });\n ```\n\n Do not use `answer-only` for a kind you also send in normal play: it is unmarked by design and\n would be dropped. A `shared` rule with no authority seat throws instead of trusting everyone.\n The mark needs the relay that sets it (2026-10-17) and ships relay-first, so a game that starts\n requiring it must be deployed after the relay carries it, or it refuses every resync.\n\n**What this does not cover (accepted limit).** The check judges who sent the frame, never what it\nsays. The seat that was asked, or the authority seat of a host-authoritative game, is still trusted\nfor the content: in a two-player match the opponent is the only seat the relay can ask, so a\ncheating opponent can send a false board inside the 10-second window. Closing that needs a server\nthat runs your game's rules, or every client replaying the match deterministically from a move log\n(Turn Kit games with a log can, with `applySnapshot(snapshot, source)`). Otherwise keep validating\nthe payload (ranges, history only ever appended) and expect the rest to be a trust decision.\n\nThis is existing behavior for any relay game, made explicit here: a `sync: \"turn_based\"` game\nusing [Turn Kit](/guide/sdk/turn-kit) gets this answered for you automatically and doesn't need the\nlistener above. A hand-rolled `realtime` game does — skipping it leaves a reconnecting peer stuck\nlooking at nothing until your next unrelated broadcast happens to fire. See\n[Spectating](/guide/sdk/spectating) for the full resync protocol, including why an old/cached SDK\nbuild that predates this event simply falls back to your next broadcast instead.\n\n## Message-rate budget\n\nYour manifest's `network.expectedMessagesPerMinute` ([manifest reference](/guide/requirements/technical))\nis not just documentation — it sets your game's actual rate ceiling. The relay enforces a **hard cap\nof 3× your declared rate**, applied consistently everywhere the platform computes a per-room rate\nlimit: sustained traffic above that ceiling gets `throttled`, then disconnected on continued abuse. A\nroom whose *aggregate* traffic sustains at or above that same ceiling is also fed into the platform's\nanti-cheat engine as a `RELAY_RATE` signal — declare a realistic `expectedMessagesPerMinute` for your\ngame rather than a padded one; padding it just raises your own game's ceiling for what the platform\nwill treat as suspicious.\n\n**Declare `expectedMessagesPerMinute` honestly and keep your traffic well under it in normal play.**\nThe relay's own cost envelope is why fast-action, high-frequency games are a poor fit for\n`Miniant.net` today — turn-based and low-rate games (chess, cards, quiz-style) are the target until\nplatform capacity for higher-frequency traffic is available.\n\n**What counts against your rate budget when your game is spectated.** Rate limits are enforced\nper-connection, on *inbound* sends only — the relay fanning one of your broadcasts out to any\nnumber of spectators costs you nothing extra; delivering it to N spectators still counts as exactly\none send against your own budget. Spectator (and portal chat/emote overlay) traffic also never\nfeeds the `RELAY_RATE`/anti-cheat aggregates above — those aggregate GAME-channel traffic only, the\nreal seats, never spectators. What *does* count: your own snapshot rebroadcasts — both the\nturn-change broadcasts and the `syncRequest`-triggered ones described in\n[Spectating](/guide/sdk/spectating) — are real sends from your own seat, so budget them into your\ndeclared `network.expectedMessagesPerMinute` the same as any other message you send.\n"},{"slug":"sdk/turn-kit","title":"Turn Kit","section":"SDK","agentSummary":"Miniant.createTurnKit is the mandatory turn-rotation helper for sync: 'turn_based' online modes: seat rotation, tagged snapshot broadcast, checkpoint(), and reconnect syncRequest, automatically.","checklist":[{"id":"turnkit.gate-turn-mode","text":"Use createTurnKit only for a mode declaring sync: 'turn_based' with capabilities.relay: true.","verify":"validator:manifest.mode-rules"},{"id":"turnkit.no-manual-send","text":"Never call Miniant.net.send() for game state once kit.start() has run; drive all state changes through kit.endTurn(). Action frames with your own k are allowed — the kit ignores them.","verify":"manual"},{"id":"turnkit.show-action-live","text":"Send an action frame the moment the acting player's input lands so opponents and spectators watch the move happen — the endTurn snapshot is authority, never the first sign of motion.","verify":"manual"},{"id":"turnkit.full-snapshot","text":"Make getSnapshot()/applySnapshot() round-trip a full state snapshot every turn, never a delta.","verify":"manual"},{"id":"turnkit.pass-unavailable-fully","text":"Pass unavailable: context.mode.unavailable to createTurnKit only when implementing every corresponding availability callback.","verify":"manual"},{"id":"turnkit.dispose-cleanup","text":"Call kit.dispose() from the leave-game path and the terminate handler, alongside Miniant.net.close().","verify":"manual"},{"id":"turnkit.checkpoint-multi-action-turns","text":"Call kit.checkpoint() after every committed action in a multi-action turn, and again at the start of the next in-flight action, so a spectator joining mid-turn can replay it.","verify":"manual"}],"body":"# Turn Kit\n\n`Miniant.createTurnKit(options)` is a thin turn-rotation helper layered on top of\n[`Miniant.net`](/guide/sdk/multiplayer) for a `sync: \"turn_based\"` online mode ([manifest\nreference](/guide/requirements/technical)) — seat rotation, a `{snapshot: true}`-tagged full-state\nbroadcast on every turn change, and automatic `syncRequest` answering (reconnect recovery for a\nGAME seat — **not** a spectator join, which never sends `syncRequest`; a spectator instead reads the\nkit's own tagged broadcasts straight from the relay's cache, see\n[Spectating](/guide/sdk/spectating)), all over that same relay connection. Your game writes rules\nand rendering; the kit writes \"whose turn is it, and does everyone agree on the board.\" This is\nwhat makes bringing a Chess- or Carrom-shaped game online cheap.\n\nMandatory **iff** the mode you're running declares `sync: \"turn_based\"` ([Choose a sync\nmode](/guide/requirements/multiplayer#choose-a-sync-mode-for-an-online-mode)). Gated on\n`capabilities.relay` the same way every other `net.*` call is — it throws\n`CapabilityNotGrantedError` immediately if your manifest didn't declare the relay.\n\n```ts\nconst context = await Miniant.init({ sdkVersion: 1 });\nawait Miniant.net.connect();\n\nconst seats = context.participants.map((p) => p.seat).sort((a, b) => a - b);\nconst teams = Object.fromEntries(\n context.participants.filter((p) => p.team !== undefined).map((p) => [p.seat, p.team]),\n);\n\nconst kit = Miniant.createTurnKit({\n seats,\n mySeat: context.player.seat,\n teams, // omit entirely on a free-for-all mode — see \"Teams\" below\n getSnapshot: () => game.serialize(),\n applySnapshot: (snapshot) => game.load(snapshot),\n onTurnStart: (seat) => ui.setActiveSeat(seat),\n onPeerAbandoned: (seat) => ui.showDisconnectNotice(seat), // a signal only — see below\n unavailable: context.mode.unavailable, // omit entirely for no auto-takeover/forfeit — see below\n});\n\nkit.start();\n\n// ... your move handler, once it's this client's turn ...\nfunction onPlayerMove(move) {\n if (!kit.isMyTurn()) return;\n game.applyMove(move);\n kit.endTurn({ lastMove: move }); // patch is optional, a cheap animation hint only\n}\n```\n\n## Build `seats`/`mySeat`/`teams` from the play context\n\n- **`seats`** — every occupied seat, from `context.participants[].seat`. Gaps are fine (`[0, 2, 3]`\n is a legal 3-seat rotation with seat 1 empty) — pass them in any order, the kit sorts its own\n copy.\n- **`mySeat`** — `context.player.seat`. Must be one of `seats`, or `createTurnKit` throws\n immediately. It must also be a non-negative integer — `createTurnKit` throws immediately for a\n spectator's synthetic seat (`-1`) too (2026-09-25), and `Miniant.createTurnKit` (the `guest.ts`\n wrapper) throws the same way whenever `context.spectator === true`, before ever building a kit. A\n spectator's own client uses [`Miniant.createSpectatorKit`](/guide/sdk/spectating) instead.\n- **`teams`** — build it from `context.participants[].team` ([Game](/guide/sdk/game)'s \"Team Up\"\n section), not by computing it yourself: `{ [seat]: team }` for every participant that has a\n `team`, omitted entirely for a seat with none. `kit.teamOf(seat)` reads it back and returns\n `null` for a seat with no entry — it never throws for an unrecognized seat. See \"[Declare Team Up\n if your game has it](/guide/requirements/multiplayer#declare-team-up-if-your-game-has-it)\" in\n [Multiplayer requirements](/guide/requirements/multiplayer) for where `participants[].team` itself\n comes from.\n\n## Drive every state change through `endTurn`\n\nOnce `start()` is called, the kit handles every relay frame it recognizes as its own —\n**never call `Miniant.net.send()` for game state yourself** once you're using the kit; the two\nwould race each other's turn sequence numbers. This ban is scoped to *state*: frames of your own\nkind are allowed and expected — see [Show the action live](#show-the-action-live) below.\n`endTurn(patch?)`:\n\n- Broadcasts `getSnapshot()` as the new authoritative state and advances the turn to the next\n occupied seat, wrapping past the highest back to the lowest.\n- Throws `NotYourTurnError` when called off-turn — guard your input handler with `isMyTurn()`\n before calling it, the same way you'd gate a move on it being this seat's turn at all.\n- `patch` is optional and rides alongside the full snapshot purely as a cheap animation hint (e.g.\n \"which square moved\") — the kit never reads it on the receiving end, so a game that ignores\n `patch` entirely still gets fully correct state from `snapshot` alone.\n\n**`getSnapshot()`/`applySnapshot()` must round-trip a full snapshot, every turn — no deltas.** The\nkit broadcasts the entire game state on every turn change rather than computing an incremental\ndiff, the same convention Ludo already uses today: simpler, and self-healing, since a dropped or\nlate frame never compounds into permanent desync — the very next turn carries the complete state\nagain. `applySnapshot` is only ever called with a peer's snapshot, never your own — `endTurn()`\nadvances your own local turn state directly, without round-tripping through the network.\n\n**`applySnapshot(snapshot, source)` tells you where a snapshot came from.** The kit trusts a\nsnapshot's content as sent, so any player could end their own turn with a rewritten history.\n`source` lets your game refuse that:\n- `{ kind: \"turn\", seq, from, fromSeat, auto, replaced }`: the turn of `fromSeat` just ended. `auto`\n means `from` played it on that seat's behalf. `replaced` means it outranked a move already applied\n at the same `seq`.\n- `{ kind: \"sync\", seq, from }`: reconnect recovery or a late join, which can jump several turns.\n- `{ kind: \"retire\", seq, from, seat }`: the forfeit policy retired `seat`, and no turn was played.\n\n`from` is always the sender the relay verified. A game whose state is a move log can check that a\n`turn` snapshot keeps the history it already holds and adds moves only for `fromSeat`.\n\n**`snapshotDropReason(snapshot, source)` lets your game refuse a snapshot (SDK 2026.10.19).** It is\nan optional kit option, called before the kit moves anything, with the arguments `applySnapshot`\nwould get. Return `null` to take the frame, or a short reason to drop it: the kit logs the reason\nand treats the frame as if it never arrived, so the turn does not move and an honest frame for the\nsame turn is still accepted. A hook that throws refuses the frame. Use it to say \"that is not one\nlegal move from the state I hold\":\n\n```ts\nMiniant.createTurnKit({\n // ...\n snapshotDropReason: (snapshot, source) =>\n source.kind === \"turn\" && !isOneLegalMove(localState, snapshot, source.from)\n ? \"not one legal move\"\n : null,\n});\n```\n\nCheck `source.from`, not `fromSeat`, for who moved: on a non-auto `turn` the kit now requires\n`fromSeat` to equal `from`, and on an `auto` one `from` is the driver. The kit also checks that a\n`turn` frame hands the turn to the next seat in the rotation (a sender can no longer play twice or\nskip a seat; under the `forfeit` policy it may skip ahead past a seat it has retired), and that a\n`sync` names a seat in the room. It still cannot check clocks, timeouts or abandon outcomes, and\nthe answering seat of a `sync` is trusted for what it says unless you check it here.\n\n## Show the action live\n\nThe kit keeps every seat *correct*; it does nothing to let anyone *watch* a move happen. If the\nonly thing that ever crosses the relay is `endTurn()`'s committed snapshot, your opponent — and\nevery spectator — sees a frozen board, then the result. The fix is a small action frame sent the\nmoment the acting player's input lands:\n\n```ts\n// The instant the input lands — finger lifts off the flick, piece starts moving:\nMiniant.net.send({ k: \"flick\", shot }); // your own `k`, your own payload\n\n// Every other client (spectators included) replays it live:\nMiniant.net.on(\"message\", ({ data }) => {\n if (data?.k === \"flick\") animateFlick(data.shot);\n});\n```\n\nThis coexists safely with the kit, by design: the kit only handles frames whose `k` is one of its\nown — `turn`, `sync`, `hb`, `away`, `back`, `retire` — and silently ignores everything else, while\nyour own `net.on(\"message\")` listener receives every frame alongside the kit's. Pick any other `k`\nand the two never touch. Three rules keep it honest:\n\n- **Send at input time, not after your animation finishes.** Whatever time your local presentation\n takes, every watcher inherits it on top of network latency — until the frame arrives they see\n nothing at all.\n- **The snapshot stays the authority.** Peers replay the action frame for motion, then reconcile\n to the very next `endTurn` snapshot once the replay settles. Never *derive state* from action\n frames — they're presentation, delivered best-effort, and a client that missed one must still\n end up correct from the snapshot alone.\n- **Count these frames in your `network.expectedMessagesPerMinute`** ([manifest\n reference](/guide/requirements/technical)) — they cross the same relay under the same budget.\n\nThis is the requirements-level rule at [Broadcast the action, not just the\noutcome](/guide/requirements/multiplayer); it's also what makes `capabilities.spectate` worth\ndeclaring, since spectators receive exactly the frames the seats exchange.\n\n## Reading whose turn it is\n\n- **`isMyTurn()`** — `true` when the current holder is your own `mySeat`. Gate input on this.\n- **`currentSeat()`** — the seat currently holding the turn.\n- **`onTurnStart(seat)`** — fired once, synchronously, from `start()` for the initial holder (the\n lowest occupied seat), and again on every accepted turn change, whoever's turn it becomes next.\n- **`onStateApplied(cb)`** — fired every time the kit calls your `applySnapshot` on your behalf\n (a normal turn change or a recovery frame), with that snapshot, after it's already applied.\n Returns an unsubscribe function. Use it if your game wants to react generically to \"state\n changed\" (re-render, replay a sound) without separately handling turn frames and recovery\n frames.\n\n## Reconnect and spectator recovery, and `checkpoint()`\n\nYou never see or handle the resync frame yourself. If this client reconnects into a room that\nalready has another live peer, the kit answers the relay's `syncRequest` on your behalf with your\nlatest `getSnapshot()`, tagged `{snapshot: true}` — the same auto-answer obligation\n[Multiplayer](/guide/sdk/multiplayer) asks a hand-rolled `realtime` game to implement itself, done\nfor you here.\n\nThe kit applies a resync only when the relay marks it as the answer to its own request. Any seat\ncan broadcast a frame shaped like a resync, and the kit drops every unmarked one. So the relay must\nbe at least as new as your SDK.\n\n**A spectator joining is a different, simpler case — it never reaches your game at all.** The kit\nalready tags every `turn`/`sync`/`retire` broadcast `{snapshot: true}`; the relay serves a joining\nspectator its cached copy of the latest one directly, with no `syncRequest` and no call into your\ncode. See [Spectating](/guide/sdk/spectating#snapshottrue-and-catching-up-a-spectator-mid-match) for why a spectator join\nwas deliberately kept off the wire path players' own frames travel.\n\n**`checkpoint()`.** Keeps a spectator current mid-turn: `kit.checkpoint()` broadcasts a `{snapshot: true}`-tagged\nmid-turn snapshot **without ending the turn** — for a turn that covers several committed actions\n(bowling: two balls per frame), a spectator joining mid-turn otherwise always replays from the\nturn's start, however far play has actually progressed.\n\n```ts\nfunction bowlBall(shot) {\n game.applyBall(shot);\n if (game.frameComplete()) {\n kit.endTurn({ lastShot: shot }); // normal turn end — unchanged\n } else {\n kit.checkpoint(); // mid-turn: keep a joining spectator's first paint current\n }\n}\n```\n\n- Call it **after every committed action** in a multi-action turn, and **again at the start of the\n next action** with that action already in flight and included in `getSnapshot()` (e.g. right as a\n ball is released, not only once it stops) — so a watcher who joins mid-action can replay the\n in-flight action from its own launch instead of picking it up partway through.\n- Legal only for whoever is entitled to act for the current holder right now: `mySeat` on its own\n turn, or — under `unavailable: { policy: \"auto\" }` — the currently eligible elected driver for the\n current seat, same eligibility `endTurnAs()` itself checks. Anyone else gets no send and a single\n `console.warn` per kit instance, not a thrown error — a checkpoint is an optimization, not a state\n transition your control flow depends on.\n- Returns `true` iff the frame was actually sent; `false` for every case above, and once the match\n has already been locally decided (the `\"forfeit\"` policy's 2-seat `onMatchDecided` branch) — there\n is no turn left to checkpoint.\n- Budget: one extra broadcast per call, same `snapshot: true`-tagged relay cache write a\n `turn`/`sync`/`retire` frame gets — count it in `network.expectedMessagesPerMinute` like any other\n send.\n\nThere is no shipped reference implementation for a checkpoint-using game yet — see the kit's own\ntests, `packages/game-sdk/tests/turnkit-checkpoint.test.ts`, for how it's called and what it sends.\n\n## `onPeerAbandoned` is a signal only\n\nFired the instant a seat's connection drops (`net`'s own `peerLeft`, for any of this kit's\noccupied `seats`) — no grace period, no reconnect-window timer, and no decision baked in about\nforfeiting or pausing the match. **Show a notice; nothing more** — the availability machinery below\nis what actually decides what happens to the seat when your mode declares `unavailable`. `onPeerAbandoned`\nstill fires independently of, and in addition to, that machinery either way.\n\n## Player availability: `unavailable`, auto-takeover, and forfeit\n\nPass `unavailable: context.mode.unavailable` and the kit takes over everything described in this\nsection for free — beacons, driver/retire election, staggering, handback, and reporting to the\nportal's own grace/auto-pilot/forfeit banner chrome. **Omit the option and none of it runs**: no\nextra timers, no extra listeners, byte-for-byte the same kit you already know from the sections\nabove. See [Multiplayer requirements](/guide/requirements/multiplayer#when-a-player-becomes-unavailable)\nfor the policy contract itself (`policy`/`timeoutSeconds`, what each policy means for players, the\nnetwork-headroom manifest rule) — this section covers only the API surface the kit adds on top.\n\n`context.mode.unavailable` is populated with the resolved default (`forfeit`/30s) even when your\nmanifest declared nothing — pass it into `createTurnKit` only if you're prepared to implement the\ncallbacks below.\n\nThe kit calls `Miniant.reportPeerAvailability()` for you at every transition it detects, unless you\npass your own `reportAvailability` option to `createTurnKit` — supply one only if you need to\nintercept or duplicate that reporting yourself; most games should leave it unset and let the kit's\ndefault wiring handle it.\n\n```ts\nconst kit = Miniant.createTurnKit({\n seats,\n mySeat: context.player.seat,\n teams,\n getSnapshot: () => game.serialize(),\n applySnapshot: (snapshot) => game.load(snapshot),\n onTurnStart: (seat) => ui.setActiveSeat(seat),\n\n // Only meaningful when your mode declares `unavailable` — omit context.mode.unavailable\n // entirely and none of the callbacks below ever fire.\n unavailable: context.mode.unavailable,\n onSeatUnavailable: (seat, deadlineMs) => ui.showGraceCountdown(seat, deadlineMs),\n onSeatAvailable: (seat) => ui.clearGraceCountdown(seat),\n\n // \"auto\" policy: compute the seat's move yourself and drive it.\n onAutoTurn: (seat) => kit.endTurnAs(seat, ai.computeMove(seat)),\n onSeatAutoDriven: (seat, bySeat) => ui.closeStaleTurnPrompt(seat),\n\n // Fires before the snapshot in `applySnapshot` is applied — cancel local in-flight play,\n // never gate the snapshot itself on it. See \"One committed history per turn\" below.\n onTurnOverridden: ({ seq, seat, bySeat, reason }) => {\n if (reason === \"takeover\") game.cancelLocalPlay(); // my own turn was played by a stand-in\n else game.discardDerivedFrom(seat); // a stand-in's turn I'd applied was replaced\n },\n\n // \"forfeit\" policy: 3+ seats retires the silent one, exactly 2 seats decides the match.\n onSeatRetired: (seat) => ui.showRetiredNotice(seat),\n onMatchDecided: ({ survivingSeat, forfeitedSeat }) => {\n if (survivingSeat === context.player.seat) showVictoryScreen();\n Miniant.reportResult({ outcome: \"win\", forfeit: true, durationMs });\n },\n});\n\nkit.start();\n```\n\n- **`onSeatUnavailable(seat, deadlineMs)`** — fires the moment a peer seat's grace period begins,\n either from natural silence crossing `timeoutSeconds` or an unambiguous disconnect (`peerLeft`)\n forcing it immediately. `deadlineMs` (epoch ms, usable with `Date.now()`) is when the resolved\n policy is expected to act — render a countdown from it if you want one, though the portal's own\n banner chrome already shows one without any work from you.\n- **`onSeatAvailable(seat)`** — fires when a peer seat's liveness evidence returns before its policy\n acted (the grace period is cancelled), and also for **handback**: a seat already being driven\n under `\"auto\"` sending real evidence again.\n- **`onAutoTurn(seat)`** — `policy: \"auto\"` only. Fires when it's currently `seat`'s turn, `seat`'s\n grace deadline has been reached (offset by this client's own driver rank — see \"Driver election\"\n below), and no fresher evidence has arrived. Compute that seat's move with your own AI and call\n `kit.endTurnAs(seat, patch?)` — this callback never advances the turn itself. May fire more than\n once for the same seat/turn if an earlier-ranked candidate never actually calls `endTurnAs`.\n `endTurnAs(seat, patch?)` is legal only while this client is the currently eligible elected driver\n for `seat` — call it outside `onAutoTurn`'s own firing (or after another rank has already driven)\n and it throws `NotDriverEligibleError` instead of broadcasting.\n- **`onSeatRetired(seat)`** — `policy: \"forfeit\"`, 3+ occupied seats at the deadline. Fires on every\n client once a `retire` frame for `seat` has been applied (whether this client sent it or received\n it) — `seat` is now permanently out of turn rotation, and the match continues without it.\n- **`onMatchDecided({ survivingSeat, forfeitedSeat })`** — `policy: \"forfeit\"`, exactly 2 occupied\n seats at the deadline. Fires **only on the surviving client** — there's no rotation left to\n protect and no one else in the room to tell, so no wire frame is sent for this transition. Show\n your victory screen and report the outcome with `forfeit: true` (see\n [Multiplayer requirements](/guide/requirements/multiplayer#when-a-player-becomes-unavailable)'s\n \"Reporting the result\").\n- **`onSeatAutoDriven(seat, bySeat)`** — fires on `seat`'s **own** client, after the auto-driven\n turn frame for its own seat has already been applied, when it learns it was played on its behalf\n while it was away. Close any stale \"your turn\" UI you might still be showing — this is your one\n hook for \"welcome back\" chrome for the returner themselves; the portal's own banner chrome\n deliberately has none. That same client immediately sends `back`, handing its seat back — you\n don't have to do this yourself.\n- **`onTurnOverridden({ seq, seat, bySeat, reason })`** — fires **before** `applySnapshot`, whenever\n an incoming frame is about to replace a turn this client already committed to locally. Two cases,\n told apart by `reason`:\n - **`\"takeover\"`** — `seat` is this client's **own** seat, and a stand-in (`bySeat`) played its\n turn while it held it. Abandon any local in-progress play for that turn — animation, aim,\n timers, self-`Auto`, AI planning — and let the incoming snapshot apply; don't race it.\n - **`\"replaced\"`** — a stand-in's turn for `seat` that this client had already applied is being\n replaced by `bySeat` (the seat's own real move, or a lower-numbered stand-in — see \"One\n committed history per turn\" below). Drop anything you derived from the turn being replaced.\n\n**Driver/retire election, so you never have to think about it.** Both policies elect a candidate the\nsame way: sort the currently-available occupied seats (excluding the target) ascending, and this\nclient becomes eligible to act at `graceDeadline + rank * 3000ms`, where `rank` is this client's own\nposition in that sorted list. The lowest-ranked candidate acts right at the deadline; each\nfurther-ranked one waits another 3s, so a still-silent target eventually gets acted on even if a\nlower-ranked candidate has also gone silent or crashed. Any real evidence from the target cancels\nevery pending rank's timer. You never see this directly — it's why `onAutoTurn`/`endTurnAs` and the\nretire/`onMatchDecided` path are simply \"eligible or not\" from your game's perspective.\n\n**Warning against your own timers.** Everything above — beacons, election, staggering, retire\npropagation, handback — is the kit's job the moment `unavailable` is set. Implement only the\ncallbacks; building your own disconnect timer or reconnect-window logic on top of this is redundant\nat best and a source of desync at worst (your timer and the kit's own election would race each\nother's decisions about the same seat).\n\n## One committed history per turn\n\nA stand-in's committed turn under `\"auto\"` — an elected driver's `kit.endTurnAs(seat, ...)` — is\nauthoritative for **every** client, including the seat it stood in for, even one that was still\nvisible (its heartbeats may just have been lost in transit). At an equal turn `seq`, one fixed\nprecedence decides on every client: the seat's own real move beats a stand-in's, and between two\nstand-ins the lower-numbered seat wins. A later `seq` always wins.\n\nThe other clients do not take a stand-in's move for a seat they heard from in the last\n`timeoutSeconds - 3` seconds. Without that check, any player could play a present player's turn.\nBots, seats that left, and seats a client has never heard from are still played at once. A client\nwhose own connection is down never judges another seat silent, and never stands in for one, until it\nis back.\n\nNever gate `kit.applySnapshot` on local uncommitted state (\"my own shot is still in flight, ignore\nthis frame\"). Cancel local in-flight play in `onTurnOverridden` and let the applied snapshot win.\n\n## Cleaning up\n\nCall `kit.dispose()` from your own \"leave game\" path and from your `\"terminate\"` handler\n([Game](/guide/sdk/game)'s \"Pause, resume, and terminate\"), alongside `Miniant.net.close()`. It\nremoves every `net` listener the kit registered — safe to call whether or not `start()` was ever\ncalled, and safe to call more than once. `start()` itself is idempotent too: a second call is a\nno-op (with a console warning) rather than double-registering listeners and double-firing\ncallbacks.\n"},{"slug":"sdk/sealed-deck","title":"Sealed deck","section":"SDK","agentSummary":"Miniant.net.deck(name): hidden cards held by the relay for online room card games. Server shuffle, each seat gets only its own ids. Pass the Turn Kit as kit; forfeit any seat onViolation names.","checklist":[{"id":"deck.hidden-cards-on-relay","text":"In an online room, keep every hidden card (hands, the closed pile) in Miniant.net.deck, never in a kit snapshot, a net.send payload or a shared seed.","verify":"manual"},{"id":"deck.turn-check","text":"Pass the match's Turn Kit as kit, and end the match with the violating seat forfeiting when onViolation fires.","verify":"manual"},{"id":"deck.timeout","text":"Use unavailable.timeoutSeconds of 15 or more in a sealed-deck game, so the relay already counts a silent seat as absent when the Turn Kit elects a driver for it.","verify":"manual"},{"id":"deck.driver-key","text":"Key drawFor and moveFor on the Turn Kit's turn (e.g. `${seq}:d`), the same on every client, so two drivers racing for one absent seat commit once.","verify":"manual"}],"body":"# Sealed deck\n\n`Miniant.net.deck(name)` gives an online room game hidden cards that no client can read until they\nare dealt to it or turned face up. The relay holds the deck: it shuffles with server randomness,\nsends each seat only its own card ids, and refuses a play of a card the seat does not hold.\n\nUse it for any card game in `online_friends` or `online_random` rooms. Dealing every hand from the\nshared `session.seed` lets a modified client read every hand and the whole closed pile, and a dealer\nseat can read everything too. `vs_ai` and AI-filled `online_random` matches have no room: keep their\nhidden cards in the stand-in's `secret` field ([Spectating](/guide/sdk/spectating)).\n\nGated on `capabilities.relay`, like every `net.*` call.\n\n```js\nconst context = await Miniant.init({ sdkVersion: 1 });\nawait Miniant.net.connect();\nconst kit = Miniant.createTurnKit({ /* ... */ unavailable: context.mode.unavailable });\nconst deck = Miniant.net.deck(`deal-${dealNo}`, {\n kit,\n onViolation: ({ by }) => endMatchWithForfeit(by),\n});\n\n// The dealer seat, by your game's rule:\nawait deck.create(allCardIds); // e.g. [\"AS.1\", \"AS.2\", ..., \"JK.1\"]; public, unique\nawait deck.deal({ 0: 13, 1: 13 });\nawait deck.flip(\"joker\"); // the cut joker, face up for everyone\n\n// On my turn:\nconst { cards: [drawn] } = await deck.draw(); // or: await deck.take(\"open\")\nawait deck.discard(chosenId, \"open\");\nkit.endTurn();\n```\n\n## Cards are opaque ids\n\nA card is a string of 1 to 16 characters from `A-Z a-z 0-9 . _ -`, unique within the deck. The relay\nnever reads meaning into it: `\"QH.2\"` is the second queen of hearts only to your game. A deck holds\nup to 520 ids. Deck frames have their own 16 KB cap and do not count against your manifest's\n`maxMessageBytes`; a 520-card `create` is about 10 KB. A call that is waiting when the connection\ndrops rejects with `offline`: whether the relay applied it shows in `deck.state()` after the\nreconnect.\n\nPiles are public, named `[A-Za-z0-9_-]`, up to 8 per deck. Each seat has one private zone (its hand).\nOps go top-first: `draw` takes the last card of the sealed pile, `take` the last card of a pile.\n\n## Ops\n\nEvery op returns a promise that resolves with the applied op (below) or rejects with a `DeckError`\nwhose `reason` is one of `no_deck`, `exists`, `bad_op`, `not_held`, `empty`, `seat_live`,\n`duplicate`, `offline`. The acting seat is always your own seat from the relay token; nothing in the\ncall can name another actor.\n\n| Call | Who | Effect |\n|---|---|---|\n| `create(cards)` | any seat | A new sealed pile, shuffled on the relay. The list (not the order) is public. A name can be used once: deal again under a new name. |\n| `deal({ seat: n })` | any seat | Top `n` cards to each seat's zone. |\n| `draw()` | own | Top sealed card to my zone. |\n| `take(pile, card?)` | own | Top (or the named) card of a public pile to my zone. |\n| `discard(card, pile)` | own | A card I hold onto a public pile. Refused `not_held` otherwise. |\n| `flip(pile)` | any seat | Top sealed card face up onto a pile. |\n| `recycle(pile, { keep })` | any seat | The pile minus `keep` back into the sealed pile, reshuffled (the closed pile ran out). |\n| `show()` | own | My zone face up to everyone for the rest of the deck. |\n| `showSeat(seat)` | others | Shows an **absent** seat's zone once every present seat has asked; until then it resolves `{ pending: true }`. A present seat is refused `seat_live`: it shows its own. |\n| `drawFor(seat, key)` | others | Top sealed card into an **absent** seat's zone, unseen by the caller. |\n| `moveFor(seat, pile, pick, key)` | others | One card of an absent seat's zone onto a pile: `\"last\"` (the card it got last) or `{ prefer: [[ids], [ids]] }` (the first held id of the earliest list). |\n\n`create`, `deal`, `flip`, `recycle` and `showSeat` are open to any seat because only your rules know\nthe dealer and the end of a deal. Check them in an `op` handler (below).\n\n## Read the deck\n\n```js\ndeck.on(\"op\", (op) => render(op)); // every applied op, in relay order\nconst view = deck.state();\n// { seq, sealed: 52, piles: { open: [\"7H.1\"] }, zones: { 0: 13, 1: 13 },\n// shown: { 1: { by: 1, cards: [...] } }, mine: [\"AS.1\", ...] }\n```\n\nAn op carries `deck`, `seq` (per deck, gap-free), `op`, `by` (the acting seat), and as it applies\n`seat`, `seats`, `pile`, `card` (a card that just became public), `keep`, `cards` (ids you may see:\nthe created list, a shown zone, or cards entering your own zone) and `counts` (`sealed`, `zones`,\n`piles`).\n\nRender card positions from the deck, never from your kit snapshot: the snapshot holds turn phase and\nscores only. A hand id in a snapshot reaches every seat and every watcher.\n\n**Reconnect and late watchers.** On connect the relay sends the current state of every deck before\nanything else, so `deck.state()` is right by the time your `hello`, `syncRequest` or snapshot\nhandler runs. A spectator's deck is read-only (calls throw) and never holds a private id.\n\n## Turns: the relay checks cards, your clients check turns\n\nThe relay has no turns, so it carries out a `draw` or `discard` whenever a seat sends one. Pass the\nTurn Kit as `kit`: every client then checks each op as it arrives against `kit.currentSeat()`. All\nclients see kit frames and deck ops in the same relay order, so they reach the same verdict.\n\n- `draw`, `take`, `discard` must come from the current seat.\n- `drawFor`, `moveFor` must target the current seat.\n\nA failure calls `onViolation({ by, op, reason: \"out_of_turn\" })`. The op is still applied (the relay\nalready did it). End the match with seat `by` forfeiting: it is attributable to that seat's token,\nand the most it gained was one extra card. Add your own rule checks (one draw per turn, the dealer\ndeals) in `deck.on(\"op\")` and treat a failure the same way.\n\n## An absent seat's Auto\n\nThe relay lets another seat play for seat `k` only while `k` is absent: no live connection, or\nnothing heard from it for 12 seconds. A present seat answers `seat_live`. Use\n`unavailable.timeoutSeconds` of 15 or more, so the Turn Kit never elects a driver before the relay\nagrees; `drawFor`/`moveFor` retry once after 4 seconds on `seat_live` to cover the edge.\n\nThe driver never sees `k`'s hand, so Auto plays blind and weak by design. For rummy:\n\n```js\n// `duplicate` means another driver already did this step: carry on to the next one.\nconst once = (p) => p.catch((e) => { if (e.reason !== \"duplicate\") throw e; });\n\nonAutoTurn: async (seat) => {\n const seq = kit.seq(); // the same on every client for this turn\n await once(deck.drawFor(seat, `${seq}:d`));\n await once(deck.moveFor(seat, \"open\", \"last\", `${seq}:m`));\n kit.endTurnAs(seat);\n},\n```\n\nThe `key` must be the same on every client for the same turn: two drivers racing for one seat then\ncommit once, and the second gets `duplicate`. The Turn Kit elects a second driver when the first is\nslow, so a second driver is normal: it must skip a `duplicate` step, not stop, because the first\ndriver may have left between the draw and the discard. For a trick game, list the ids of the led suit and\nthen every id: `moveFor(seat, \"trick\", { prefer: [ledSuitIds, allIds] }, key)`.\n\nWhen the match ends, show each seat's zone for scoring: present seats `show()` their own, and every\npresent seat calls `showSeat(k)` for an absent one. No seat can show a present opponent's zone, and\nan absent seat's zone is shown only once every present seat has asked.\n\n## Limits\n\n- Players trust the relay's shuffle; there is no commitment you can audit afterwards.\n- In a two-seat match one player can show an absent opponent's hand alone. The reveal is public and\n `shown[k].by` names who did it.\n- A portal-filled AI seat in a room plays blind like any absent seat.\n- A room keeps the four newest decks.\n"},{"slug":"sdk/spectating","title":"Spectating","section":"SDK","agentSummary":"Letting a friend watch a live match read-only via capabilities.spectate: snapshot:true-tagged broadcasts and checkpoint() for room games, publishState/publishAction for solo/vs-AI games.","checklist":[{"id":"spectate.declare-only-with-publishstate","text":"Implement publishState in the same change that declares spectate on a solo or vs-AI game — the declaration is mandatory, and without publishState a spectator sees nothing.","verify":"manual"},{"id":"spectate.render-read-only","text":"Render a spectate visit strictly read-only: no move or input controls, no turn-action prompts.","verify":"manual"},{"id":"spectate.call-ready-as-normal","text":"Call Miniant.ready() in a spectate visit exactly like a normal session.","verify":"validator:sdk.ready-called"},{"id":"spectate.tag-complete-state-snapshot-true","text":"Tag every complete-state broadcast (turn-change, sync/retire reply, checkpoint) with {snapshot:true} — never a delta, input sample, or chat line.","verify":"manual"},{"id":"spectate.broadcast-action-frames","text":"Broadcast in-turn action frames, as broadcasts never sendTo, so spectators watch moves happen live — snapshots alone make a match recoverable, not watchable.","verify":"manual"},{"id":"spectate.checkpoint-multi-action-turns","text":"For a multi-action turn (e.g. two balls per frame), call kit.checkpoint() after every committed action and at the start of the next in-flight one, so a joiner can replay it.","verify":"manual"},{"id":"spectate.publishstate-once-then-on-change","text":"Call Miniant.spectate.publishState(snapshot) once as soon as the first board is on screen, then again on every meaningful change — the Watch button needs an observed publish.","verify":"manual"},{"id":"spectate.snapshot-size-cap","text":"Keep every publishState/publishAction payload JSON-serializable and at or under the 16KB cap.","verify":"manual"},{"id":"spectate.gate-on-spectator-flag","text":"Gate is-this-my-seat logic on context.spectator === true first; never rely on the player.seat === -1 sentinel alone.","verify":"manual"},{"id":"spectate.render-from-anchor-seat","text":"Render the board from context.spectateAnchorSeat's perspective, using your own local-player seat-orientation logic.","verify":"manual"},{"id":"spectate.no-social-sends","text":"Never rely on send() or sendTo() reaching anyone as a spectator; those calls are silently dropped, receive-only by construction.","verify":"manual"},{"id":"spectate.watcher-never-drives-match","text":"Never let a spectate visit start a local match or run the AI — a watcher only ever renders what publishState/publishAction/checkpoint/broadcasts deliver to it.","verify":"manual"},{"id":"spectate.no-local-multiplayer-for-watchers","text":"Never offer local_multiplayer (pass-and-play) to a spectate visit — there is nothing for a read-only watcher to pass.","verify":"manual"},{"id":"spectate.call-showend-not-own-end-screen","text":"When a watched match ends, call Miniant.spectate.showEnd(); never draw your own watcher end screen.","verify":"manual"}],"body":"# Spectating\n\n`\"capabilities\": { \"spectate\": true }` ([manifest reference](/guide/requirements/technical)) lets\na player's friend watch one of their in-play matches, read-only, without joining it — in\n**any** mode, not just room-based ones. It is **required on every manifest**: spectating is a core\nportal feature (friend watching, tournament broadcasts, the Live hub), and the validator fails a\nbuild that omits the declaration — see\n[Spectating in Multiplayer requirements](/guide/requirements/multiplayer#spectating). Two paths exist, and which one applies to your game depends\non whether the match has a relay room at all (`context.room`, non-null only for\n`online_friends`/`online_random`):\n\n## The two paths\n\n- **Room-based games (`online_friends`/`online_random`, `capabilities.relay: true`).** Spectating\n rides the exact same [`Miniant.net`](/guide/sdk/multiplayer) relay connection the match itself\n already uses: a spectator joining is served the relay's cached copy of the latest broadcast you\n tagged `{snapshot: true}` (see \"`snapshot:true`\" below) — nothing more is asked of\n your game, and nothing is ever sent to a player on a spectator's behalf.\n- **Solo/vs-AI games (`context.room === null`, no `relay` declaration required).**\n\n > **Do not declare `capabilities.spectate` on a solo game unless you implement `publishState` in\n > the same change.** Declaring it is what makes the portal offer a **Watch** button to the\n > player's friends; the snapshots are the only thing that gives those friends something to look\n > at. Ship the declaration alone and a friend taps Watch, connects successfully, and stares at a\n > permanently blank frame with no error explaining why — strictly worse than not declaring it at\n > all. Either do both, or leave `spectate` off.\n\n **The Watch button appears once the portal has observed a publish, independent of `relay`.** The\n portal offers watching a solo/vs-AI session the moment it has seen at least one\n `publishState`/`publishAction` call from it — declaring `capabilities.relay: true` for an\n unrelated online mode in the same manifest never withholds this (the old \"`relay: true` ⇒ the\n portal offers the ROOM path only\" rule was removed in commit `7e1f629`). A mixed game that runs\n both an online mode and a solo/vs-AI mode gets both paths watchable, each on the strength of its\n own traffic.\n\n There is no relay\n connection for a spectate visit to ride, so you call `Miniant.spectate.publishState(snapshot)`\n yourself, whenever your state changes meaningfully (a turn, a move, a score event). The portal\n receives it over the same `postMessage` channel every other SDK call already uses and forwards it\n to any watchers on your behalf — your game never opens a connection for this, and never knows\n whether or when anyone is watching. There is no `syncRequest` in this path at all — a newly-joined\n spectator is served the portal's cached copy of your last published snapshot directly, without\n invoking your game code.\n\n**Correction: spectating is no longer \"only meaningful alongside `relay: true`.\"** That was true\nbefore solo/vs-AI spectating shipped; it isn't anymore. With `relay: true`, spectating rides your\nexisting broadcasts (first bullet above, no extra call). Without it, you call `publishState`\nyourself (second bullet) — one code path works across both, described next.\n\n## One code path for both\n\n**A game with both a room mode and a solo/vs-AI mode needs only one branch:**\n\n```ts\nfunction broadcastSnapshot(snapshot) {\n if (context.room) {\n Miniant.net.send(snapshot); // room mode: your existing broadcast\n } else {\n Miniant.spectate.publishState(snapshot); // roomless: pushes to watchers yourself\n }\n}\n```\n\nYou can even skip the branch and call `Miniant.spectate.publishState(snapshot)` unconditionally\nalongside your normal `net.send`: in a room session it's a **documented no-op** — resolves\nimmediately and warns once via `console.warn`, never throws — because the room's own relay\nbroadcasts are already mirrored to spectators, so publishing again there would be redundant, not\nwrong. That no-op is what makes the unconditional call safe.\n\nOne thing that no-op does **not** cover: `publishState` still throws `CapabilityNotGrantedError`\nimmediately if your manifest doesn't declare `capabilities.spectate` at all — that check runs before\nthe room/roomless branch, so it fires the same way whether or not `context.room` is set. The no-op\nabove only applies once the capability is granted.\n\n## What `init()` hands a spectator\n\nYou do not build any spectate-specific UI, entry point, or \"who's watching\" list in either path —\nthe portal owns all of that (the friend clicks \"Watch\" from its own chrome, is dropped straight\ninto your game, in whichever mode it happens to be). The only thing that changes on your side is\nwhat `Miniant.init()` resolves with:\n\n```ts\nconst context = await Miniant.init({ sdkVersion: 1 });\nif (context.spectator === true) {\n // Read-only visit: render the board/table/whatever exactly as for a real player, but with every\n // input affordance removed — no move controls, no \"your turn\" prompts, nothing clickable that\n // would normally act on the game state.\n}\n```\n\n- **`context.spectator === true`** is the only signal, in both paths — everything else in `context`\n still looks like an ordinary session for that mode: `context.mode`/`context.capabilities` are\n populated normally, and for a room-based spectate visit specifically, `context.room` is non-null\n and `context.participants` lists the match's real seats (never a \"spectator\" entry among them).\n Absent/`undefined` (every session before this field existed, and every session that isn't a\n spectate visit) means \"this is a real seat\" — treat it the same as `false`.\n- **`context.spectateAnchorSeat` — whose view to render.** Present only in a spectate visit\n (whenever `context.spectator === true`), and always equal to one of `context.participants[].seat`.\n `spectator`/`player.seat: -1` only tell you the visit is read-only — they say nothing about WHICH\n participant's perspective a multi-seat game should draw, and without that signal a game has no\n principled way to choose (a real gap this field closes: a spectator of a 2-player game with no\n anchor rendered both players stacked in a neutral orientation instead of the view the watched\n friend's own client shows). The rule is universal, independent of player count: **render the board\n from the anchor participant's perspective, identically to how your own local-player view renders\n when THEY are the local player** — whatever seat-rotation/orientation logic you already apply for\n \"this seat is mine\" is exactly the logic to apply here, substituting the anchor's seat for your own.\n (For a 2-player game this typically reproduces the anchor-at-bottom/opponent-diagonal view; for an\n N-player game it reproduces whatever seat-ordering your own local-player view already applies —\n there's no special-cased 2-player rule, just \"however many seats you have.\") Omitted/`undefined`\n for every non-spectate session, same back-compat pattern as `spectator` itself.\n- **`context.player` is synthetic in a spectate visit — it is not a portal identity.** A spectator\n still gets a `player` object (so code that reads it unconditionally doesn't crash), but every\n field describes the visit, not a person: `seat: -1` — a sentinel value the platform guarantees no\n real seat is ever assigned (real seats are always `>= 0`) — plus a synthetic `id`,\n `displayName: \"Spectator\"`, and `isGuest: false`. **Never derive \"is this my seat\" by comparing\n `participants[i].seat === player.seat` alone** in a game that declares `capabilities.spectate` —\n gate on `context.spectator === true` *first*; once that's true you have no seat, full stop, and\n should render read-only. The `-1` sentinel guarantees the naive per-seat comparison can never\n accidentally match a real participant, but the explicit `context.spectator` check is the\n documented, required pattern — don't rely on the sentinel as a substitute for it.\n- **Call `Miniant.ready()` in a spectate visit exactly like a normal session.** The portal uses it\n to clear its own loading state for the visitor the same way it does for a real player — skipping\n it just leaves the spectator stuck on a loading screen. The reviewer's 30-second first-frame check\n (see [\"What the reviewer sees\"](/guide/publishing) in Publishing your game) applies to normal\n review sessions only; a spectate visit is never smoke-tested that way, so there's no\n build-approval consequence either way — but the person actually watching still needs a prompt\n `ready()` call.\n## Rendering read-only\n\n- **Render read-only, in both paths.** No move/input controls, no turn-action prompts.\n - Room-based: apply every incoming `Miniant.net.on(\"message\", …)` frame exactly as you already do\n for a remote peer's move.\n - Solo/vs-AI: `Miniant.net` still works in a spectate visit — regardless of whether your manifest\n declares `capabilities.relay` at all, since a spectate visit's `net` access is the portal's own\n delivery channel for the watched stream, orthogonal to your game's own relay declaration. Call\n `Miniant.net.connect()` (this is the one case where that succeeds without `capabilities.relay`\n declared), then render exactly the blobs the playing instance published, delivered as ordinary\n `msg` frames:\n ```ts\n Miniant.net.on(\"message\", ({ data }) => {\n render(data); // `data` is exactly what the playing instance passed to publishState(snapshot)\n });\n ```\n- **Your `send()`/`sendTo()` calls are silently dropped by the platform, relay-side** — a\n spectator's connection is receive-only by construction (not by convention your code has to\n enforce), in both paths. Don't rely on sending anything; if you skip building input affordances\n per the point above, you never will anyway.\n## Relay events during a spectate visit\n\n- **`peerJoined`/`peerLeft` list the match's real seats** in a room-based spectate visit,\n same as for any other room connection — a spectator's own presence is never announced as a peer to\n anyone (not to the real seats, not to other spectators). See\n [Absent players](/guide/sdk/multiplayer#absent-players): a spectator's join/leave never fires\n `peerJoined`/`peerLeft` for anyone at all. A solo/vs-AI spectate visit has no other seats to list\n in the first place — there's no relay room backing the match itself, only the portal's own\n forwarding.\n- **`reportProgress()`/`reportResult()` may be called but are no-ops** — the portal acknowledges\n them (so your code never hangs waiting on a reply) without recording anything. Simplest to just\n skip calling them at all in a spectator session, but it's safe not to bother checking first.\n## `snapshot:true` and catching up a spectator mid-match\n\n**`snapshot:true`.** `Miniant.net.send(payload, { snapshot: true })` marks one broadcast as\n**complete state** — someone who has seen nothing else could render the whole board from it alone.\nThe relay caches the most recent tagged broadcast and hands it to a spectator the moment they join,\nso a watcher sees the real board immediately instead of an empty frame.\n\n- **Tag every complete-state broadcast**: the turn-change snapshot a turn-based game already sends,\n a hand-rolled realtime game's own periodic full-state broadcast, and — for a multi-action turn —\n every `kit.checkpoint()` (see [Turn Kit](/guide/sdk/turn-kit#reconnect-and-spectator-recovery-and-checkpoint)). **Never tag**\n a delta (\"player 2 moved a token\"), an input sample, or a chat line. `snapshot` is ignored on\n `sendTo` — it only means anything on a broadcast, since it's a statement about the room's state,\n not a private message to one seat.\n- **What lying costs.** The relay never looks inside your payload — tagging is a promise, not\n something it can check. Tag a delta and every future watcher of this game boots from a wrong\n board, which is worse than a slow one — this is the exact failure that took spectating off the air\n in 2026-08 (migration 0036).\n- A [Turn Kit](/guide/sdk/turn-kit) game gets this for free: every `turn`/`sync`/`retire` frame the\n kit sends is already tagged. A hand-rolled `realtime` game tags its own broadcasts explicitly:\n\n ```ts\n Miniant.net.send(getFullSnapshot(), { snapshot: true });\n ```\n\n- **Room-based: no wire request to your game at all.** A spectator joining is served the relay's\n cached `{snapshot: true}` broadcast directly — the relay answers from its own cache, without\n invoking any game code. **There is no `sync_request` on a spectator join** (removed 2026-09-21,\n after a watcher's join froze a live `penaltyshootout` match — asking a seat to rebroadcast state\n on a spectator's behalf injected an unsolicited frame into the middle of that seat's own in-flight\n animation. Watching must never be able to reach the players, no matter how well any one game\n guards its own snapshot handler). If nothing has been tagged yet, the spectator's board stays\n empty until your next tagged broadcast — there's no other bootstrap in this path.\n\n **A GAME seat itself reconnecting is unaffected — that case still triggers `sync_request`.** A\n player refreshing mid-match, or (re)joining a room that already has another live GAME peer, still\n gets the relay's `{\"type\":\"sync_request\"}` nudge to the room's lowest-seat connected peer, exactly\n as before:\n\n ```ts\n Miniant.net.on(\"syncRequest\", () => {\n // A peer (re)connected into a room with another live peer — rebroadcast your full snapshot,\n // the same one you tag {snapshot: true} on a turn change. Never fired for a spectator join.\n });\n ```\n\n A [Turn Kit](/guide/sdk/turn-kit) game answers this automatically; a hand-rolled `realtime` game\n must answer it itself — see [Multiplayer](/guide/sdk/multiplayer#answering-syncrequest).\n- **Solo/vs-AI: you never see `syncRequest` at all, in either direction.** The portal answers for\n you — a newly-joined spectator is caught up from the portal's own cached copy of your last\n `publishState(snapshot)` call, entirely outside your game's code.\n- **Required for a good spectator experience:**\n - **Room-based: tag a complete-state broadcast on every turn change (or at least every few\n seconds)**, not just incremental deltas — see \"`snapshot:true`\" above. A\n spectator can join mid-match with no history of prior moves; untagged, incremental-only traffic\n leaves them looking at a stale or empty board until you happen to tag one. This is the hard\n correctness requirement spectating asks of a room-based game's code.\n - **Room-based, multi-action turns: call `kit.checkpoint()`.** A turn that covers several\n committed actions (bowling: two balls per frame) otherwise always replays from the turn's start\n for a spectator who joins mid-turn, however far play has actually progressed. Call it after\n every committed action, and again at the start of the next action with that action already\n in-flight inside `getSnapshot()` — see [Turn Kit](/guide/sdk/turn-kit#reconnect-and-spectator-recovery-and-checkpoint) below.\n - **Room-based: broadcast the action itself, live — snapshots make a match recoverable, not\n watchable.** A spectator receives exactly the frames the seats exchange, so a game whose only\n traffic is committed snapshots shows its audience a board that teleports between end states.\n Send a small action frame the moment the acting player's input lands and animate from it on\n every receiving client — spectators get it with no extra work from you. The pattern (and how\n it coexists with the Turn Kit's own frames) is documented at\n [Show the action live](/guide/sdk/turn-kit) and the requirements-level rule at\n [Broadcast the action, not just the outcome](/guide/requirements/multiplayer).\n - **Solo/vs-AI: call `publishState(snapshot)` once as soon as the first board is on screen, then\n again on every meaningful state change** (a turn, a move, a score event) — not on a timer and\n not per animation frame. There's no `syncRequest` fallback in this path, so your last published\n snapshot is genuinely the only thing a newly-arrived spectator ever sees until your next call.\n Publishing at all — even once — is also what makes the portal offer the Watch button in the\n first place; see \"The two paths\" above.\n - **Solo/vs-AI, live motion: call `publishAction(action)`.** `publishState` alone makes a match\n recoverable, not watchable, for exactly the same reason a room-based game needs action frames —\n see \"`publishAction`\" below.\n\n## Live action, the spectator kit, and explicit rules\n\n**`publishAction`.** `Miniant.spectate.publishAction(action)` publishes a single discrete LIVE event for a roomless\n(solo/vs-AI) spectate visit — \"the dice just landed,\" \"a ball was just launched\" — the roomless\ncounterpart of a room-based game's own action frames\n([Show the action live](/guide/sdk/turn-kit#show-the-action-live)). It shares the same\ncapability gate, 16KB payload cap, and rate budget as `publishState` — the two are not separate\nallowances.\n\n- **Never cached, never replayed to a joiner.** A spectator who joins mid-match boots from the last\n `publishState` snapshot only, then receives actions live from that point on, same as a player\n would — calling `publishAction` before any `publishState` call reaches nobody but a spectator who\n happened to already be watching.\n- **One call per discrete event, never per animation frame** — same rate budget `publishState`\n shares with it: **240 messages per minute, 16KB per frame** (`SESSION_PUBLISH_RELAY_MAX_RATE`/\n `SESSION_PUBLISH_RELAY_MAX_BYTES`, 2026-09-25). One shared budget across three sources on this\n channel: every `publishState` call, every `publishAction` call, AND the portal shell's own resend\n of your last snapshot to a newly-arrived watcher (see \"`snapshot:true`\" above) — none of those are\n separately metered. If your game exceeds it, the relay silently drops the overage frame with no\n error surfaced to your code; publish at human-action cadence (a move, a turn, a score event, a\n discrete live event) and you will not come close.\n- A watcher receives these as the `spectateAction` net event, never `message` — see\n `createSpectatorKit`'s `onAction` below for the shape a game actually has to handle.\n\n```ts\n// The instant the input lands:\nMiniant.spectate.publishAction({ k: \"flick\", shot });\n```\n\n**`showEnd`: the match-end card.** Don't draw a watcher end screen. When the match ends, the spectator's own instance\ncalls `Miniant.spectate.showEnd({ title, detail?, scores? })` and the portal draws one card for\nevery game: your title, optional detail, score rows, and **Exit** (plus \"Watch the rematch\" once a\nrematch is in play). `title` (≤ 80 chars) is the headline, e.g. `\"Red wins\"` or\n`\"Rumi left — Red wins\"`. `detail` (≤ 120) only adds what the title doesn't say. `scores` is 1–8\n`{ name, score }` rows in your results board's order — never format a score into `detail`. Outside\na spectate visit it's a no-op that warns once. Without it the card still appears, as \"Match ended\".\n\n```ts\nMiniant.spectate.showEnd({ title: \"Red wins\", scores: [{ name: \"Red\", score: 3 }, { name: \"Blue\", score: 1 }] });\n```\n\n**`createSpectatorKit`: the receiving end.**\n`Miniant.createSpectatorKit({ applySnapshot, onHolder, onAction, onEnded, readAction?, readState? })`\nis the watcher-side counterpart — used only when `context.spectator === true` — that turns the wire\nshapes above (tagged room broadcasts, `checkpoint()`/`state` frames, roomless\n`publishState`/`publishAction` blobs, and — via `readState` — a hand-rolled realtime game's own\ncomplete-state frames) into one consistent stream your rendering code consumes. It calls\n`net.connect()` itself at construction — there's no separate `start()` — and never owns that\nconnection's lifecycle: its own `close()` only unsubscribes the kit's listeners, never `net.close()`.\n[`Miniant.createTurnKit`](/guide/sdk/turn-kit) is the participant-side counterpart and refuses a\nspectator visit outright (2026-09-25): it throws immediately if `context.spectator === true`, so a\nwatcher's client always uses this kit instead, never the other one.\n\n- **`applySnapshot(snapshot, { source, holder })`** — called with a COMPLETE snapshot only, never a\n delta: a room `turn`/`sync`/`retire`/`state` frame's `snapshot`, a roomless `publishState()` blob\n (`source: \"publish\"`), or a `readState`-classified custom frame (`source: \"custom\"`, see below).\n `holder` is the seat now holding the turn where the frame carries one — `null` for `\"publish\"`\n (a roomless publish carries no turn concept at all). **`\"retire\"` now reports `holder` too**\n (2026-09-25): a `RetireFrame` names the seat taking over from the one that just retired, so a\n joiner no longer has to infer the new holder from the snapshot body itself the way it used to.\n Ordering is `(seq, n)` lexicographic, an equal pair accepted (the relay's own cached bootstrap\n frame, or the portal shell's own resend to a newly-arrived roomless watcher — see the note below),\n a strictly older one refused.\n- **`applySnapshot` must be idempotent for an identical blob.** A roomless spectate visit can\n legitimately receive the exact same snapshot twice: the relay's own cached-frame bootstrap and\n `useSpectatePublish`'s first-paint resend (see \"`snapshot:true`\" above) are two independent paths\n that can both deliver the player's last published snapshot to the same newly-joined watcher.\n `applySnapshot` has no way to distinguish \"the state really is unchanged\" from \"this frame was\n redelivered\" — don't treat a second call with equal `(seq, n)`/identical content as an error or a\n reason to re-run first-load side effects (an entrance animation, a sound cue); render it the same\n way you'd render any other snapshot.\n- **`applySnapshot(snapshot, { info.ageMs })`** (2026-09-25) — how many milliseconds old this\n snapshot already was when the relay served it. Present only on the cached frame a JOINING or\n reconnecting watcher is bootstrapped from; absent (`undefined`, never `0`) on every live broadcast,\n since nothing there was cached at all. Use it to fast-forward a turn clock so a newly-arrived\n watcher doesn't see a countdown that started ticking before they joined:\n `const turnMsLeft = Math.max(0, snapshot.turnMsLeft - (info.ageMs ?? 0))`. Do **not** use it to\n fast-forward a physics replay — an in-flight action (a ball mid-roll, a die mid-tumble) must still\n replay from its own launch so it stays in lockstep with the deterministic animation every other\n viewer is already watching; only a display countdown is safe to offset by it.\n- **`onHolder(seat)`** — fired only when the holder actually *changes* from what the kit last\n reported, never on a same-holder replay or a mid-turn checkpoint. Now also fires off a `\"retire\"`\n frame when it reports a new holder (see the `applySnapshot` bullet above) — previously `\"retire\"`\n never changed the kit's notion of holder, so a game reading only `onHolder` (never `applySnapshot`'s\n own `holder` field) silently missed the handoff.\n- **`readState?(data) => { snapshot, holder?, version? } | null`** — for a hand-rolled `realtime`\n game (no Turn Kit) that already tags its own complete-state broadcasts (`{ snapshot: true }`, per\n \"`snapshot:true`\" above) in a shape the kit doesn't otherwise recognize. Called for every incoming\n `\"message\"` frame that isn't already one of the kit's known kinds; return `null` to say \"not one of\n mine\" (the kit ignores the frame) or `{ snapshot, holder?, version? }` to hand it to `applySnapshot`\n as `source: \"custom\"`. **With `version`**, the frame is ordered under the same `(seq, n)` watermark\n as every other source, as `(version, 0)` — a strictly older `version` is refused, an equal one\n accepted (so your own redelivery, e.g. answering a reconnecting peer's `syncRequest` with the same\n state, is safe exactly like the relay's own cached-frame replay). **Without `version`**, the frame\n is applied unconditionally, every time — appropriate only for a game whose own frames already\n self-order (e.g. a monotonic tick counter baked into `snapshot` itself), never for one that might\n replay an old broadcast.\n- **`onAction({ seat, data, from })`** — fired for a room-path frame `readAction` classifies as an\n action (default `readAction` reads a plain `{ seat?, seq? }` shape; override it for your own\n frame shape), or a roomless `publishAction()` event (`seat` always `null` there). `seat` is this\n kit's own attributed seat (`null` when it can't be trusted — see \"Explicit rules\" below); `from`\n is the untouched relay-assigned sender; `data` is the raw payload. Deduped by `(from, seq)` when\n `readAction` returns a `seq`.\n- **`onEnded({ reason })`** — fired once, on `net`'s `\"roomClosed\"` event; never carries a winner,\n only the relay's own closure reason (or `\"room_closed\"` when it supplied none) — a spectate visit\n never learns or reports a result.\n\nThere is no shipped reference implementation for the room/checkpoint path yet; the kit's own tests\n(`packages/game-sdk/tests/spectator-kit.test.ts`,\n`packages/game-sdk/tests/turnkit-checkpoint.test.ts`) show its usage until one exists. For the\nroomless publishing side, see tic-tac-toe's `src/spectate.js` and miniantludo's\n`src/ui/spectatePublish.js`.\n\n**One example per session kind:**\n\n```ts\n// Room, turn-based (Turn Kit) — spectator side:\nconst kit = Miniant.createSpectatorKit({\n applySnapshot: (snapshot, { holder }) => game.load(snapshot, holder),\n onHolder: (seat) => ui.setActiveSeat(seat),\n onAction: ({ seat, data }) => animateFromAction(seat, data),\n onEnded: ({ reason }) => ui.showMatchEnded(reason),\n});\n\n// Room, hand-rolled realtime — same kit, but with `readState` filling in for the Turn Kit frame\n// shapes it doesn't recognize: your OWN tagged { snapshot: true } broadcast (whatever shape you\n// already chose for it) is what readState has to unpack.\nconst realtimeKit = Miniant.createSpectatorKit({\n readState: (data) =>\n data && typeof data === \"object\" && \"board\" in data\n ? { snapshot: data.board, holder: data.turnSeat, version: data.tick }\n : null,\n applySnapshot: (snapshot, { holder }) => game.load(snapshot, holder),\n onAction: ({ seat, data }) => animateFromAction(seat, data),\n onEnded: ({ reason }) => ui.showMatchEnded(reason),\n});\n\n// Roomless (solo/vs-AI) — same kit again; publishState()/publishAction() blobs\n// arrive through the same applySnapshot/onAction callbacks (source: \"publish\", seat: null),\n// no branch needed.\n```\n\n**Explicit rules.**\n\n- **Live action frames must be broadcasts, never `sendTo`.** A spectator's connection is\n receive-only and never a target of a `sendTo` — a game that only ever sends action frames with\n `sendTo` shows spectators nothing.\n- **A watcher instance must never start a local match or run the AI.** Render exactly what\n `publishState`/`publishAction`/tagged broadcasts/`checkpoint()` deliver to it — nothing computed\n locally.\n- **An action frame for an auto-driven seat names the seat it describes**, not the seat that sent\n it — see [Turn Kit](/guide/sdk/turn-kit)'s `endTurnAs`/`auto` framing for the same convention on\n the state side.\n- **A spectator never sends frames players act on, and never reports a result.** `send()`/`sendTo()`\n from a spectator's own connection are dropped platform-side (see \"Rendering read-only\" above), and\n `reportProgress()`/`reportResult()` are no-ops in a spectate visit.\n- **`local_multiplayer` (pass-and-play) is never offered to a spectate visit** — there is no remote\n stream to watch for a mode where every seat is a human sharing one device.\n## Snapshot size, cadence, and cost\n\n- **Snapshot shape and cost — `publishState` specifically.** `snapshot` must be JSON-serializable;\n `publishState` throws `SPECTATE_SNAPSHOT_NOT_SERIALIZABLE` synchronously if it isn't, and\n `SPECTATE_SNAPSHOT_TOO_LARGE` (naming the actual byte count and the cap) if the serialized form\n exceeds **16KB**. This traffic is not relay traffic against your own declared budget — a solo/\n vs-AI game has no `network` block to declare in the first place ([manifest reference](/guide/requirements/technical))\n — and when nobody is watching it costs nothing beyond the call itself: the portal only caches the\n snapshot until a spectator connects, it forwards nothing over the wire until someone actually does.\n When someone IS watching, delivery rides the portal's own small, fixed budget for this channel, not\n anything your manifest declares. The practical rule either way: keep snapshots at or comfortably\n under the 16KB cap, and publish at human-action cadence (a move, a turn, a score change) — never\n per animation frame, whether or not anyone happens to be watching.\n- **What counts against your rate budget — room-based games:** fan-out to any number of spectators is\n free (rate limits are enforced per-connection on your own *inbound* sends only), and spectator\n traffic never feeds the anti-cheat `RELAY_RATE` signal (see\n [Message-rate budget](/guide/sdk/multiplayer#message-rate-budget)) — but your own snapshot sends\n above, both the turn-change ones and the `syncRequest`-answered ones (a peer resyncing, never a\n spectator join), are real sends from your seat and do count against your declared\n `network.expectedMessagesPerMinute`\n ([manifest reference](/guide/requirements/technical)). Solo/vs-AI games have no such budget to\n worry about — see the `publishState`-specific cost note above instead.\n- `Miniant.net.on(\"spectatorCount\", cb)` fires with `{ count, subjects }` whenever the room's\n spectator headcount changes, if your game wants its own subtle \"N watching\" indicator — available whenever\n `capabilities.relay` is granted, whether or not you declare `capabilities.spectate` yourself, and\n reaches your game connection in a real room-based match exactly as documented, no change here.\n **The portal already shows this count to the match's own seat-holders in its own chrome — don't\n duplicate it for them**; this event is for your own optional in-game indicator, if any, not a\n replacement for the portal's. There is no equivalent for a solo/vs-AI game: your game never holds a\n `net` connection to the spectate channel in that path (only the portal shell does, on your behalf),\n so it has no way to learn whether, or how many, spectators are watching — by design.\n `subjects` is the watching players' subject ids, matching the `id` field on `participants`\n entries. It is optional and absent on relay builds that predate it, so fall back to `count` alone\n rather than rendering an empty list. The ids are **not** display names and the SDK gives you no\n way to resolve one: a spectator is never a seat-holder, so they will not appear in\n `context.participants`. If you show anything at all, show the count — the portal already names\n the watchers to the player in its own chrome.\n\n## Stand-in: keep a roomless match playing while the phone sleeps\n\nA `vs_ai` match, or an `online_random` match filled with AI, runs in the player's page, so it freezes\nwhen the phone sleeps and every watcher sees a dead board. A **stand-in** is a small per-game Cloudflare\nWorker the relay calls to play the AI's turns on the server.\n\nThe relay `POST`s JSON `{ board, nowMs }` with `authorization: Bearer <STANDIN_SECRET>` and expects\n`{ board, delayMs, done }` back (plus `result` when `done` is true, and optional `actions`, below). `board` is the same blob you\npass to `Miniant.spectate.publishState`, and it must carry a top-level numeric `n` (the committed-action\ncount); the returned `board.n` must be greater. `createStandinWorker` handles the HTTP, auth and\nvalidation; you write only the step.\n\nHidden information (hands, fog, unfound items) goes in a top-level `secret` field: the relay gives it to\nyour stand-in and to the player's own tab, never to watchers. Never put hidden information anywhere else\nin the board. Here is the step:\n\n```ts\nimport { createStandinWorker } from \"@mag/game-sdk/standin\";\nimport { applyMove, chooseAiMove, isOver, resultFor } from \"./rules\";\nimport { THINK_MS, THINK_JITTER_MS, animationMsFor } from \"./pacing\"; // shared with your tab\n\nexport default createStandinWorker((board, nowMs, rng) => {\n const next = applyMove(board, chooseAiMove(board, rng)); // must bump next.n\n const done = isOver(next);\n // delayMs = the think time plus this move's animation, exactly as on screen.\n const delayMs = THINK_MS + rng() * THINK_JITTER_MS + animationMsFor(board, next);\n return { board: next, delayMs, done, ...(done ? { result: resultFor(next) } : {}) };\n});\n```\n\n**Pace the stand-in like the real match.** `delayMs` is when the relay calls again, and watchers\nanimate each step as it arrives. Set it to the same think time your game uses plus the length of the\nanimation the move plays on screen (import both from the code your tab uses), so a stand-in match\nlooks exactly like one the player is playing. A shorter `delayMs` plays the match at fast-forward.\n\n**Watcher events.** Return `actions` for the short-lived watcher events (hits, sounds) your tab sends\nwith `publishAction`, in order; the relay delivers them to watchers like the tab's own. Each item is\nexactly what you would pass to `publishAction`; at most 32 per step, each within the same 16 KB per-frame\ncap, otherwise the Worker answers 500. Actions are not cached (a watcher who joins later never replays\nthem) and a top-level `secret` on one is stripped.\n\n**A match the stand-in finishes is reported by the server.** The tab may never wake, so when `done` is\n`true` the reply must also carry `result`: exactly what your game would pass to `Miniant.reportResult(...)`\nfor that match (`outcome`, and optionally `score`, `detail`, `forfeit`, `placement`), from the seat the\nplayer holds. `durationMs` may be omitted: the board has no start time, so the portal measures it from\nthe session start. The Worker checks it against the same schema as `reportResult` and\nanswers 500 if it is missing or invalid; `done: false` must not carry one. The portal then records it\nthrough the same path as a tab's report, so verdict, points and tickets work as for any match, and marks\nthe session `resultSource: \"standin\"`. If the tab wakes afterwards it adopts the finished board and\ncalls `reportResult` as usual; that call is acknowledged as accepted and records nothing a second time.\n\n`result.perParticipant` may be empty or omitted. The stand-in cannot know player ids (the spectate board\ncarries none, because watchers receive it), so when it is empty the portal attributes the match to the\nsession owner with the result's `outcome` and `score`. Put per-seat standings in `detail` (for example\n`rankings` or `teams`) if the game needs them; ids are not required.\n\nDeclare `\"capabilities\": { \"standin\": true }` in `miniant.json`. **This is required** for a game that\ndeclares `spectate` and has a `vs_ai` or `online_random` mode: without it the validator fails the build\n(`standin.required`: \"Spectatable games with vs_ai/online_random must ship a stand-in (capabilities.standin)\nso a sleeping player's match keeps playing for watchers.\"). A game with only `online_friends`, or without\n`spectate`, is exempt. The validator also requires `standin/wrangler.jsonc` (or `wrangler.toml`) with a\n`main` once `standin` is declared. The portal lists a game in its home and \"All live\" sections only when it\ndeclares both `spectate` and `standin`, and lists its roomless matches only while the admin switch is on.\nDeploy with `wrangler deploy --config standin/wrangler.jsonc`\non the Miniant account, after setting the secret with `wrangler secret put STANDIN_SECRET`.\n\n## Player privacy\n\n- **Player privacy, both paths.** Every player has an \"Allow friends to watch my games\" switch in\n their own portal settings (on by default). Your game never sees or handles this switch in any\n way — it's mentioned here only so you understand why a spectate-capable game may simply never\n receive a visitor: a player who has turned it off is never watchable, and your game has no way to\n detect or query that state.\n\nSee also: [Multiplayer](/guide/sdk/multiplayer) for the underlying `Miniant.net` relay contract that\nroom-based spectating rides, and [Multiplayer requirements](/guide/requirements/multiplayer) for the\n`capabilities`/manifest-level framing.\n"},{"slug":"sdk/highlights","title":"Highlights","section":"SDK","agentSummary":"Optional Miniant.highlights records a canvas/WebGL match and posts a ~16.5 s vertical clip on a win: start(), frame() after each render, mark() per event, setScore(), then reportResult win.","checklist":[{"id":"highlights.start-once-before-ready","text":"Call Miniant.highlights.start({ canvas }) once per match, after Miniant.init() and before Miniant.ready(); a new match needs a new start(). Treat a false result as 'no highlights' and play on.","verify":"manual"},{"id":"highlights.frame-after-every-render","text":"Call Miniant.highlights.frame() after every render, in the same frame the canvas was drawn.","verify":"manual"},{"id":"highlights.mark-one-event-one-mark","text":"Call mark() once per event, classified by who it is about: by \"me\" or \"opponent\", never both for the same event, and never a second mark for the same moment.","verify":"manual"},{"id":"highlights.mark-by-for-good","text":"Pass `by` whenever you pass `good`; my keeper's save is { by: \"opponent\", good: false, label: \"SAVED!\" }.","verify":"manual"},{"id":"highlights.set-score-from-dom-hud","text":"Call setScore(text) whenever the score changes if your HUD is DOM; capture only sees the canvas.","verify":"manual"},{"id":"highlights.report-result-win","text":"Finish with reportResult({ outcome: \"win\" }) to post the clip; in online modes a \"loss\" or \"draw\" posts it too. Any other outcome discards it. Do not call any separate upload method.","verify":"manual"}],"body":"# Highlights\n\n`Miniant.highlights` turns a match your player wins, or an online match they lose or draw, into a\nshareable highlight clip. It is\n**optional**: a game that never calls it behaves exactly as before, and the recorder code is a\nseparate chunk (`v1-highlights.js`, loaded from the same place as `v1.js`) that is only downloaded\nwhen you call `start()`.\n\n**What the clip is.** A vertical (720×1280) video of about 16.5 seconds: the Miniant start\nanimation, then *all* of the winner's good moments plus up to two of the opponent's mistakes\n(always fewer than the winner's good moments), in match order, then a final-score card built from\nyour last `setScore()` text, then the end animation. After a win in a game that records, the\npost-match bar offers only **Share highlights** (it reads \"Preparing highlights…\" until the clip is\nready) and copies a ready caption, with the player's invite link, for their post; Share and\nChallenge come back only if the clip cannot be made. After an online loss or draw it offers the\nclip alone, and nothing if the clip cannot be made. You never see or handle the video. The clip has a soundtrack: Miniant's start sound and a music track that fades\ninto the end sound as the end animation plays. Your game's own audio is not recorded.\n\nEach moment is cut with a lead-in before it and 0.8 s held after it, so its caption can be read.\nThat means a cut shows a few seconds of play around each moment, and an event next to a chosen\nmoment (say the opponent's goal just before your save) can appear inside the cut even though it was\nnever selected.\n\n**A DOM-built game draws a recording canvas.** The recorder films a canvas, and a board made of\npage elements has none. Keep an offscreen canvas (4:5 suits a board), redraw the board onto it every\nanimation frame from what the page shows, and pass that canvas to `start()`. It is never added to\nthe page; the player sees nothing different.\n\n## `Miniant.highlights.start({ canvas, music?, framing?, cut?, playersOnScreen?, speed?, team?, partner? })`\n\n```ts\nconst on = await Miniant.highlights.start({ canvas });\nconst onPinned = await Miniant.highlights.start({ canvas, music: \"high-energy\" });\nconst onWhole = await Miniant.highlights.start({ canvas, framing: \"fit\" });\nconst onBoard = await Miniant.highlights.start({ canvas, cut: \"recent\" });\n```\n\n`music` picks the soundtrack's mood by id. Only `\"high-energy\"` exists today, and it is what you\nget when you omit `music`, so leave it out unless you want to pin a mood as more are added. An id\nthe portal does not know is not swapped for another: `start()` resolves `false` and the portal logs\na console warning.\n\n`framing` decides how your picture goes into the clip, which is always vertical (9:16):\n\n- `\"crop\"` (the default) fills the frame and trims the sides of a wide game. Use it when the action\n is in the middle of the screen: a bowling lane, a penalty kick.\n- `\"fit\"` keeps your whole picture: a 16:9 game shows at full width, and the space above and below\n is filled with a blurred, darkened copy of the same frame. Use it when the whole width matters:\n a one-screen room where the player and the goal sit at opposite ends.\n\nAny other value makes `start()` resolve `false` with a console warning.\n\n`speed` (1 to 2, default 1) plays the clip's game footage faster, so slow-moving play keeps up\nwith the soundtrack: `speed: 1.5` shows a 3 s roll in 2 s. The intro, the final card and the outro\nalways play at normal speed, and the footage is retimed, not re-encoded, so quality is unchanged.\nAt 1.5 the clip also covers 1.5 times as much play. Captions are part of the footage, so they show\nfor less time at a higher speed. Any other value makes `start()` resolve `false` with a warning.\n\n`playersOnScreen: true` says your canvas already shows every player's name and avatar (Ludo\ndraws them beside each corner). The strip across the top of the clip then leaves the names and\navatars out: it shows only the score cell if you call `setScore()`, and otherwise \"MATCH HIGHLIGHTS\" over your game's title. Leave it\nout if your canvas does not show the players.\n\n`cut` decides which footage the clip keeps:\n\n- `\"moments\"` (the default) keeps the player's good moments and up to two of the opponent's\n mistakes, as described under `mark()`.\n- `\"recent\"` suits short board games. Mark **every move**, either player's. The clip takes the\n latest moves counted back from the finish, each cut tight (0.3 s before the move lands, 0.9 s\n after, 1.6 s for the last), so the thinking time between moves drops out. If every move fits,\n the whole game is shown. `by` and `good` are not used for selection; `label` still captions the\n move.\n\nAny other value makes `start()` resolve `false` with a console warning.\n\nCall it **once per match**, after `Miniant.init()` and before `Miniant.ready()`. It can take a\ncouple of seconds (the brand pack, the recorder chunk and the brand pre-encode; longer on a slow phone\nthat is busy loading your game), and gives up with `false` once the pre-encode makes no progress for\n10 s or has run for 60 s, so call it during your own loading rather than at the first frame of play. It resolves `false`\nfor guests, spectators, portals too old to post a clip, and devices without WebCodecs H.264\nencoding. When it resolves `false`, every other `highlights` call is a silent no-op, so you never\nbranch on it. A new match needs a new `start()`; calling it again for a rematch starts a fresh\nrecording.\n\n## Team games: `start({ team: true })` and `setStars(n)`\n\nFor a co-op game (2 to 4 friends on one team, like a kitchen shift), start with `team: true`:\n\n```ts\nawait Miniant.highlights.start({ canvas, team: true });\nMiniant.highlights.mark({ by: \"me\", seat: 2, good: true, label: \"ORDER UP!\" }); // a teammate\nMiniant.highlights.setStars(2); // before reportResult\n```\n\n- **Teammates are our side.** A mark or `actor()` with `seat` is signed with that seat's photo and\n name, in the player's gold style (not an opponent's white). `by: \"me\"` without `seat` is the local\n player, as always.\n- **Only a win posts a clip.** A loss, draw or abandoned shift discards the recording, online or\n not (no clip for a failed shift); the portal then offers nothing for it, as for any lost match.\n- **The final card is the team card** instead of \"NAME WINS\": the game title, every team member's\n round photo with their name under it, three star icons (filled for the stars earned, outlined for\n the missed) and \"3 STARS!\", \"2 STARS!\" or \"1 STAR!\". Without `setStars` it reads \"TEAM WINS!\"\n with no star filled. If you call `setScore`, \"FINAL SCORE\" and the numeral stay above the team.\n- `setStars(n)` takes an integer 1 to 3 (clamped, rounded; a non-number is ignored). Call it any\n time before `reportResult()`. An older SDK has no `setStars`, and `team` is ignored there, so\n check `typeof Miniant.highlights.setStars === \"function\"` before calling it.\n\n## Partnership games: `start({ partner })`\n\nFor a game played in fixed partnerships against another side (a card game's two teams of two),\nname the local player's partner when you start:\n\n```ts\nawait Miniant.highlights.start({ canvas, partner: { name: \"Mitu\", seat: 2 } });\n```\n\n- **The final card names both, the local player first:** both round photos (the player's in front)\n and \"KAIS & MITU\", then \"WINS\" as usual, or the loss and draw lines online. Two long names go on\n two lines (\"KAISUDDIN AHMED &\" over the partner) rather than being cut.\n- `name` is the partner's name as your game shows it, so an AI partner in vs_ai can be named too.\n `seat` (optional, the partner's portal seat) supplies their photo when they are a participant;\n without one the card draws their initial.\n- A partner with an empty name or a non-integer `seat` makes `start()` resolve `false`, with a\n console warning. Nothing else changes: who posts a clip, the marks and the score work as in any\n versus game. This is not `team` (a co-op team with no rival, which discards loss clips).\n- An older SDK ignores `partner`, so passing it is always safe.\n\n## `frame()`\n\n```ts\nMiniant.highlights.frame();\n```\n\nCall it after **every** render, in the same frame the canvas was drawn. It reads the canvas as it\nis at that moment, so calling it from a timer or before drawing captures a stale or blank frame.\n\n## `mark({ by?, good?, label?, win?, leadMs?, minor?, seat?, beatMs?, montage?, loss?, agoMs? })`\n\n```ts\nMiniant.highlights.mark({ by: \"me\", good: true, label: \"GOAL!\" });\nMiniant.highlights.mark({ by: \"opponent\", good: false, label: \"SAVED!\" });\nMiniant.highlights.mark({ win: true });\n```\n\nTell it when something worth showing just happened. **One event is one mark**, classified by who\nthe event is *about*:\n\n- `by: \"me\"` or `by: \"opponent\"`: whose event this is.\n- `good`: whether it was good for that player. **`good` requires `by`.**\n- `label`: a short caption shown on the clip.\n- `win: true`: rides on the decisive moment of the match, or stands alone if there was no single\n moment.\n- `leadMs`: how long before this mark the move began, when a move animates\n for a while before it lands (a token hopping square by square). 0 to 10000; anything else\n ignores the mark with a console warning. The cut always starts at least this long before the mark.\n- `seat`: which seat the moment belongs to. Needed only in a game with more than one opponent\n (Ludo, a card table, a race): the clip signs each moment with its player's photo and name, and\n without a seat an \"opponent\" moment there is left unsigned rather than given the wrong name.\n- `minor: true`: a filler moment (a six in a dice game). It is cut only into play time your real\n moments leave unused, so a quiet match still fills the clip and a busy one never loses a real\n moment to it.\n- `beatMs` and `montage`: tell a win as a montage, see below.\n- `loss: true`: how the player lost (a fall, an elimination). Use it with `by: \"me\", good: false`;\n on any other mark it is ignored. See below.\n- `agoMs`: the moment happened this many ms ago (0 to 60000, clamped; a non-number is ignored). The\n mark is placed at that earlier time, and `leadMs` counts back from it. See below.\n\n**Back-dating a mark: `agoMs`.** When you only learn of a moment later (a shift is known won when\nit ends, but the win was the last serve a few seconds earlier), say how long ago it happened:\n`Miniant.highlights.mark({ win: true, agoMs: msSinceLastServe })`. The clip then ends right after\nthat serve instead of filming the quiet tail. A back-dated mark adds no caption, because the frames\non screen now are not its footage: give the original moment its caption when it happens. Footage is\nkept reliably for the last 10 s, or around any moment you marked; if a back-dated win's footage is\ngone, the win falls back to the time you called `mark`, so the clip still has a win.\n\n**How the player lost: `loss: true`.** Your own misses are never selected, so by default a loss clip\nshows the opponent's moments and the player's earlier good ones but never the fall itself. Mark that\none moment `{ by: \"me\", good: false, label: \"WRONG!\", loss: true }` and a loss or draw clip always\nincludes the latest such mark, in match order, cut like any moment (`leadMs` is honoured) and signed\nwith the player. It is dropped only after every other moment. A win clip never shows it.\n\nThe clip keeps the winner's good moments and up to two of the opponent's mistakes. Your own misses\nmay be marked but are never selected. The opponent's good moments (their goals) are never in a win\nclip; in a loss or draw clip the latest two are picked as real moments, even when the player's own\nmoments would fill the clip, and earlier ones fill spare time before plain fillers, so the clip\nshows what beat the player. Mark them `{ by: \"opponent\", good: true }`. So a save by the player's keeper is\nan event about the opponent's failed attempt: `{ by: \"opponent\", good: false, label: \"SAVED!\" }`,\nnot a good mark for the player.\n\n**Every moment is signed.** Under the picture the clip shows whose moment it is: the player's\nphoto and name over a gold caption, or the opponent's over a white one, and a filler without a\ncaption shows the photo and name alone. So pass `by` on every mark you can, fillers included; a\nmark without `by` (or `seat`) is shown unsigned. The white flash and the punch-in stay on the\nplayer's own moments.\n\n**A win as a montage: `beatMs` and `montage`.** For a win that is a climb rather than one moment (a\nquiz ladder, a combo chain), mark every step with `beatMs` (300 to 1200, ms: how long the step reads\nat a glance) and put `montage: N` (2 to 12) on the `win: true` mark. The clip then tells the win as\nquick cuts of your latest N beat marks, then your own marks after the last beat (the question\nopening, with its own `leadMs`), then the win. Nothing else is in it: no opponent moments, no other\ngood moments, no fillers. A beat is cut to its own length with no minimum, shows no flash, punch-in\nor caption slam, and keeps the player's name tag. If the beats do not fit the clip, the earliest go\nfirst; with fewer than two beats the clip is cut as usual. Both fields are clamped to their range,\n`beatMs` counts only on the player's own good or filler marks, `montage` only on `win: true`, and a\nvalue that is not a number is ignored. `beatMs` alone changes nothing about which moments are\nchosen, but a captioned beat-hinted mark is always drawn without the flash, punch-in and slam (its\ncaption still holds as usual), because the clip is filmed while the match runs.\n\n```ts\n// Quiz Ladder: every rung, then the million.\nMiniant.highlights.mark({ by: \"me\", good: true, label: \"SAFE!\", beatMs: 500 });\nMiniant.highlights.mark({ by: \"me\", good: true, label: \"FOR 1,000,000\", leadMs: 1500 }); // not a beat\nMiniant.highlights.mark({ win: true, montage: 8 });\n```\n\n## `actor({ by?, seat? })`\n\n```ts\nMiniant.highlights.actor({ by: \"me\" }); // the player strikes the cue ball\nMiniant.highlights.actor({ by: \"opponent\" }); // the opponent's dart leaves their hand\nMiniant.highlights.actor({ by: \"opponent\", seat: 2 }); // seat 2 picks up a token\n```\n\nCall it when an action **starts** — a shot struck, a dart thrown, a token picked up — so the clip\nsigns the action while it plays, not only from its landing (a mark only arrives when the action\nlands). The tag stays until the next `actor()` call; a mark's caption or filler shows its own player\nover it while it is up. Pass `seat` in a game with more than one opponent, as for `mark()`.\n`actor({})` clears the tag. Portals older than 2026-10-01 do not have it, so check\n`typeof Miniant.highlights.actor === \"function\"` before calling.\n\n## `setScore(text)`\n\n```ts\nMiniant.highlights.setScore(\"3 - 1\");\n```\n\nYour HUD is DOM, and DOM is invisible to canvas capture. Call `setScore` whenever the score\nchanges and the clip's score overlay follows it. If your score is not drawn on the canvas and you\nnever call `setScore()`, the clip's score plate shows the two names only, with no score.\n\n## `stop()`\n\nStops recording. Use it when the match ends without a `reportResult` that posts a clip.\n\n## Finishing: `reportResult`\n\n```ts\nawait Miniant.reportResult({ outcome: \"win\" });\n```\n\nThe clip is always exactly 16.5 seconds: a match too short or too sparsely recorded to fill it\nrepeats its best shots, the winning moment first, rather than ending sooner.\n\n`reportResult` is what finishes the clip. `outcome: \"win\"` finishes and posts it, ending on a\n\"WINS\" card. In an online mode (`online_friends`, `online_random`), `\"loss\"` and `\"draw\"` post it\ntoo, ending on a \"GOOD GAME\" card under a small \"LOST, BUT\" or \"DRAW,\"; the player's good moments are what it shows. Any other outcome,\nor a loss in any other mode, discards it. There is no separate upload call. `reportResult` remains mandatory for every game (see\n[Scores](/guide/sdk/scores#reporting-results-is-mandatory)).\n\nA game with more than two finishing places adds the player's place to the same call:\n\n```ts\nawait Miniant.reportResult({ outcome: \"loss\", placement: 2, durationMs });\n```\n\nIn an online mode, `placement: 2` or `3` ends the clip on a \"2ND PLACE\" or \"3RD PLACE\" card\ninstead of \"GOOD GAME\"; 4th and below stays \"GOOD GAME\". Against the AI a place makes no clip,\nthe same as a loss.\n\n## What the validator checks\n\n`miniant-validate` and the publishing check read your built bundle for seven highlight rules. None\nof them blocks a publish; a flagged rule is a warning and nothing more.\n\n- `highlights.missing`: a game with a `vs_ai` or online mode (and not a levels game) never calls `highlights.start`.\n- `highlights.not-for-solo`: a solo-only or levels game calls `highlights.start`.\n- `highlights.no-frame`: `highlights.start` is called but `frame()` never is, so the clip is empty.\n- `highlights.no-win-mark`: no `mark()` has `win: true`.\n- `highlights.no-opponent-marks`: a game with an online mode never marks `{ by: \"opponent\", good: true }`.\n- `highlights.no-placement`: a game that seats 3 or more players in a recording mode never passes `placement` to `reportResult`.\n- `highlights.speed-range`: a numeric `speed` passed to `start()` is outside 1 to 2.\n\nThe scan is a text search of the minified bundle, so it can miss a call hidden behind a renamed\nvariable. When the bundle calls `highlights.start`, the report also lists \"Highlights: check by\nwatching a clip\", a short list of things only a person watching a clip can confirm.\n\n## Spectators\n\n`start()` resolves `false` for a spectator visit, so none of this runs there. See\n[Spectating](/guide/sdk/spectating).\n"},{"slug":"sdk/video-ads","title":"Video ads","section":"SDK","agentSummary":"Miniant.ads requests interstitial or rewarded ads; the portal decides everything and runs its own placements (rematch, XP doubler, more) that bill to the game with no game code. Both calls optional.","checklist":[{"id":"ads.no-third-party-sdk","text":"Never embed a third-party ad SDK, network, or ad script; Miniant.ads is the only sanctioned way to show a video ad.","verify":"validator:ads.no-third-party-sdk"},{"id":"ads.no-iframe-embeds","text":"Never embed an ad iframe in your game; there is no method that puts a video on screen directly.","verify":"validator:html.no-iframe"},{"id":"ads.request-after-ready","text":"Call requestInterstitial or requestRewarded only once the game is running, never as part of load-time initialization.","verify":"manual"},{"id":"ads.request-at-natural-break","text":"Call requestInterstitial only at a natural break in play, never mid-gameplay or on a timer your own code drives.","verify":"manual"},{"id":"ads.no-interstitial-before-rematch","text":"In a one-round session that ends in a Rematch CTA, don't call requestInterstitial at round end; the portal's rematch placement shows that ad. Use it only at level ends inside a session.","verify":"manual"},{"id":"ads.rewarded-results-screen-only","text":"requestRewarded is optional (the portal offers the XP doubler itself); if you call it, do so only from the results screen after computing the session's normal reward, never mid-gameplay.","verify":"manual"},{"id":"ads.no-tickets-from-rewarded","text":"Never grant Tickets for a rewarded ad; only double the game's own already-earned XP or quest progress.","verify":"manual"},{"id":"ads.dont-double-pause","text":"Never add a second ad-specific pause path; rely on the existing host:pause/host:resume handlers.","verify":"manual"}],"body":"# Video ads\n\n`Miniant.ads` is how your game asks for a video ad. You **request**; the portal **decides and\nshows**. There is no method that puts a video on screen directly, and no way to control which\nnetwork serves it, how often it appears, or what a player sees while it's playing — that's all\nportal policy, the same way `wallet.purchase()` never lets your game render its own confirm\ndialog (see [In-game purchases](/guide/sdk/purchases)).\n\nTwo calls, two different jobs. Don't mix them up:\n\n- `requestInterstitial(reason?)` — a full-screen ad at a natural break *inside* a session, such\n as a level end.\n- `requestRewarded()` — optional: a video the player opts into from your results screen, for a\n doubled reward. The portal already offers this after every result on its own (see\n \"Portal-owned placements\" below), so most games never need to call it.\n\n**Most games need neither call to earn from ads.** The portal runs its own placements around\nevery game — the rematch interstitial, the results-screen XP doubler, the matchmaking wait\nand the spend waiver — and each of those bills to your game. Your own calls add\ninventory only where the portal cannot see a break: a level end inside a longer session.\n\n## `Miniant.ads.requestInterstitial(reason?)`\n\n```ts\nconst outcome = await Miniant.ads.requestInterstitial(\"level_end\");\n// \"shown\" | \"skipped\" | \"unavailable\"\n```\n\nCall this at a natural break — a level end, returning to a menu — never mid-gameplay, never on a\ntimer your own code drives. `reason` is an optional, short (≤40 characters) hint for *why* you're\nasking (e.g. `\"level_end\"`); it's informational only and doesn't influence the portal's decision.\n\n**Don't call it at the end of a one-round session.** If your session is a single round that ends\non a results screen with a Rematch CTA (a Ludo match, a round of Tic Tac Toe), leave the break to\nthe portal: its own rematch placement shows the interstitial only when the player has chosen to\nplay again, whereas a call at round end fires even for a player who is about to leave. Calling\nboth is harmless — the shared 3-minute gap means the portal's ad is skipped — but it wastes the\nbetter moment. Reserve `requestInterstitial` for level ends inside a session the portal cannot see.\n\n**The portal enforces spacing you don't see:**\n- At least 3 minutes between interstitials, portal-wide, regardless of how often your game asks.\n- None at all during a player's first session.\n\nSo a `\"skipped\"` reply is the normal, expected result of most calls — not an error, and not\nsomething to retry immediately. Never stall gameplay waiting for `\"shown\"`; the call resolves\nquickly either way and your game continues the moment it does.\n\n`\"unavailable\"` covers everything else that isn't a normal skip — no inventory to fill the slot,\nthe portal shell isn't wired for ads yet, or the request simply didn't get a reply in time. Treat\nit exactly like `\"skipped\"`.\n\n**The shell pauses and resumes your game around the ad — don't also pause yourself.** When the\nportal actually shows an interstitial, it sends your game the same `host:pause` /\n`Miniant.on(\"pause\", cb)` push it uses for any other overlay (see [Game](/guide/sdk/game)), and\n`host:resume` once the ad closes. If your game already listens for `\"pause\"`/`\"resume\"` to freeze\nits own simulation (it should), this \"just works\" — don't add a second, ad-specific pause path.\n\n## `Miniant.ads.requestRewarded()`\n\n```ts\nconst outcome = await Miniant.ads.requestRewarded();\n// \"rewarded\" | \"dismissed\" | \"unavailable\"\n```\n\n**Optional, and results-screen doubling only.** The portal offers \"double this round's XP\" as a\nchip over your results screen after every eligible result, with no code from you, and the doubled\nXP is applied server-side either way. Call this only if you want the offer to live inside your own\nresults UI instead — the portal's chip then stays silent for that session. If you do call it, do\nso from your game-over/results screen, after you've already computed the session's normal XP or\nquest progress — never mid-gameplay, never to gate progress a player would otherwise get for free. On `\"rewarded\"` (the player watched to\ncompletion), **double** the XP or quest progress your game already awarded for that session.\nThat's the entire contract: there is no separate \"reward payload\" from the portal telling you what\nto grant — your own game decides what \"doubled\" means for its own scoring.\n\n**A rewarded ad never grants Tickets.** Tickets come only from the portal's own watch-to-play\nflow, never from `Miniant.ads`. Don't build a \"watch an ad, get tickets\" flow — it isn't wired to\nanything, and won't be.\n\n`\"dismissed\"` means the player backed out before the video finished — treat it exactly like\n`\"unavailable\"`: no reward, and don't stall or retry. Same pause/resume behavior as\n`requestInterstitial` above applies here too, for the duration the video is on screen.\n\n## Not a pricing mechanism\n\nDon't use a rewarded ad to unlock an undo, an extra life, a hint, or anything else you'd otherwise\nsell. Price that with `wallet.requestSpend()` (or a registered SKU via `wallet.purchase()`) — see\n[In-game purchases](/guide/sdk/purchases). The one exception is the portal's own spend waiver\n(see \"Portal-owned placements\" below): when a player cannot afford a spend, the portal may offer\nan ad and waive the player's next tap of that control. Your game sees an ordinary `{ ok: true }`\nand needs no code for it. From a game's point of view, `Miniant.ads` and `Miniant.wallet` stay two\nseparate systems: your own rewarded request only ever doubles an already-earned reward on your\nresults screen, it never buys anything.\n\n## Not implemented via any other route\n\nNo third-party ad SDK, ad iframe, or ad script — embedding one yourself is still not permitted (see\n[Advertisement](/guide/requirements/advertisement)) and will be rejected in review. `Miniant.ads`\nis the only sanctioned way to show a video ad in your game.\n\n## Portal-owned placements\n\nNot every ad a player sees comes from your `requestInterstitial`/`requestRewarded` calls. The\nportal also runs five ad placements entirely of its own, at moments your game code never sees:\n\n| Placement | Format | Where | Gate |\n|---|---|---|---|\n| Rematch | interstitial | \"Play again\" from the results screen | 3-minute gap, never a player's first session, never for a Plus subscriber |\n| XP doubler offer | rewarded | A chip on the results screen after your game reports its result | signed in, session XP-eligible, once per session |\n| Streak restore | rewarded | The daily-claim UI on the portal home | exactly one missed day, previous streak ≥ 3, once per 30 days |\n| Spend waiver | rewarded | The portal's \"Not enough tickets\" notice after a `wallet.requestSpend()` the player could not afford; the player watches, then taps the same control again and the portal waives that one spend | once per session, signed in, only when the player cannot pay |\n| Finding-players wait | interstitial | The online-random matchmaking card while it's searching | 3-minute gap, never a player's first session, never for a Plus subscriber |\n\nNone of this needs any code from you, and four of the five **bill to your game** exactly like your\nown `Miniant.ads` calls: the rematch interstitial, the XP doubler, the spend waiver and the\nfinding-players wait are all recorded against the game they happened around, so they\ncount toward your 70/30 statement. Only the streak restore, which happens on the portal home with no\ngame involved, is recorded without a game slug. Two things are worth knowing because they interact\nwith your own `Miniant.ads` calls:\n\n- **The rematch and finding-players interstitials share your game's own 3-minute gap.** If your\n game calls `requestInterstitial(\"level_end\")` and the player then clicks \"Play again,\" the\n rematch interstitial is gated by the exact same portal-wide ad history your call just advanced —\n a game that shows its own interstitial and also triggers a rematch will never see two ads back\n to back.\n- **The XP-doubler chip stays silent once your game has shown its own rewarded ad this session.**\n If your results screen already called `requestRewarded()`, the portal's own \"double this round's\n XP\" offer never appears alongside it — a player sees at most one rewarded-ad prompt per results\n screen.\n\n## Attribution and revenue share\n\nThere's nothing for you to implement here — this section just explains what happens after your\ngame calls `requestInterstitial`/`requestRewarded`, since it feeds your earnings statements (see\n[Monetization](/guide/publishing/monetization) and [Earnings](/guide/publishing#earnings)).\n\nEvery `requestInterstitial` or `requestRewarded` call the portal actually serves is recorded per\ngame. Not every recorded impression is billable: only `\"shown\"` (interstitial) or `\"rewarded\"`\n(rewarded) outcomes served by a **real ad network** count — an outcome served by the portal's own\nhouse cross-promo inventory (no real advertiser paid for it) is recorded the same way but never\nbillable, and neither is `\"skipped\"`/`\"dismissed\"`/`\"unavailable\"`.\n\nBillable impressions are billed to your game at a **70/30 split in your favour** — you keep 70% of\nthe revenue the ad actually generated. Statements are drafted **monthly** on the 1st. Once the\nportal team has imported the ad network's actual reported revenue for that month, the draft is\nre-priced pro-rata against that real figure before it is finalized, so your finalized share tracks\nwhat the network really paid rather than the provisional estimate a draft starts with.\n"},{"slug":"sdk/banners","title":"Banners","section":"SDK","agentSummary":"There is no banner API or ad system on the platform at all; monetization is the manifest-declared SKU purchase system, and third-party ad networks are rejected in review.","checklist":[{"id":"banners.no-third-party-ads","text":"Never embed a third-party banner ad, ad iframe, or ad script; the platform has no banner API and runs no banners.","verify":"validator:ads.no-third-party-sdk"}],"body":"# Banners\n\nThere is no banner API in the SDK, and no ad system on the platform at all. No banner\nplacements, no banner sizes or slots to request, no ad revenue share. If you're looking for a\nmethod to call to request a banner, there isn't one — that surface doesn't exist here.\n\nThe portal runs no banners inside games, full stop — this is unrelated to\n[`Miniant.ads`](/guide/sdk/video-ads)'s interstitial/rewarded requests, which are the only ad\nsurface that exists.\n\n## Third-party ad networks are not permitted\n\nDon't embed a third-party banner ad, ad iframe, or ad script in a game you submit. A build\nthat does this will be rejected in review.\n\n## Monetization is the in-game purchase system\n\nThe way to make money on this platform today is the built-in purchase system: you declare\nSKUs in your manifest and players buy them through the portal's own wallet. See\n[`/guide/sdk/purchases`](/guide/sdk/purchases) for the purchase contract.\n\nFor the platform's policy statement on advertising, see\n[`/guide/requirements/advertisement`](/guide/requirements/advertisement).\n\n## If that changes\n\nIf the platform ever adds a banner-ad system, this page is where the API will be documented.\n"},{"slug":"sdk/leaderboards","title":"Leaderboards","section":"SDK","agentSummary":"Clarifies that the portal has player-facing leaderboards but the SDK has no leaderboard API today; games only ever report via reportResult() and reportProgress().","checklist":[],"body":"# Leaderboards\n\nThis one is mixed, so read carefully — it isn't a flat \"doesn't exist.\"\n\n## The portal has leaderboards\n\nThe platform itself ranks games and players from real play data — that surface exists and is\nlive for players browsing the portal.\n\n## The SDK has no leaderboard API\n\nA game cannot create or post to a named leaderboard, cannot read standings back, and cannot\nrender portal standings inside itself. There is no method for any of that in the SDK today.\n\n## What a game can do today\n\nReport an outcome and score at the end of a session with `Miniant.reportResult(...)`, and send\nperiodic progress with `Miniant.reportProgress(...)` during the session. The portal derives its\nown rankings from those reports — you don't build or maintain a leaderboard yourself, you just\nreport honestly. See [`/guide/sdk/scores`](/guide/sdk/scores) for the full contract on both\ncalls.\n\n## This is a known gap, not a design stance\n\nAn SDK-facing leaderboard API — submit to a named board, read standings back for display\ninside your own game — is planned work. It does not exist yet. This page will document it,\nwith real method names and payload shapes, once it ships. There's no method name, shape, or\ndate to share ahead of that.\n"},{"slug":"sdk/engines","title":"Engines","section":"SDK","agentSummary":"Per-engine build pipeline support: Phaser/plain JS, Godot 4.2.2, and the prebuilt path for Construct, GameMaker, Cocos, and Unity, plus the SDK script tag each one loads.","checklist":[{"id":"engines.sdk-url-www-host","text":"Load the SDK script from the www host (https://www.miniant.games/sdk/v1.js), never the apex domain.","verify":"validator:html.sdk-url-www"},{"id":"engines.commit-lockfile","text":"Commit your package manager lockfile so builds are reproducible.","verify":"manual"},{"id":"engines.godot-web-preset-name","text":"Name your Godot HTML5 export preset exactly Web and commit export_presets.cfg to the repo root.","verify":"manual"},{"id":"engines.unity-bridge-gameobject-name","text":"Create a GameObject named exactly MiniAntBridge and attach the MiniAntBridge component to it.","verify":"manual"},{"id":"engines.prebuilt-commit-export","text":"For a prebuilt or Unity engine, export the static web build yourself and commit a non-empty output directory at your target commit.","verify":"manual"}],"body":"# Engines\n\nWhat the build pipeline actually supports, engine by engine.\n\n## Phaser / plain JavaScript\n\nAny build that outputs static files into a directory you point the portal at (`./dist` by\ndefault, or a custom output directory you set when you connect your repo — see\n[`/guide/publishing`](/guide/publishing)). npm, pnpm, and yarn are all auto-detected from your\nlockfile, and you should commit that lockfile so builds are reproducible.\n\nLoad the SDK with a plain script tag:\n\n```html\n<script src=\"https://www.miniant.games/sdk/v1.js\"></script>\n```\n\nThis sets a global `Miniant` object your game calls into (`Miniant.init()`,\n`Miniant.ready()`, and so on — see [`/guide/sdk`](/guide/sdk) for the full contract).\n\n**Load it from `www`, not the apex domain.** `https://miniant.games/sdk/...` redirects in a way\nthat breaks under the play frame's isolation, so the SDK silently never loads. The validator\nenforces the `www` host and fails your build if it's missing. See\n[`/guide/requirements/technical`](/guide/requirements/technical) for the full validation\nchecklist.\n\n## Godot 4.2.2\n\nSupported via an HTML5 export. Two things are required:\n\n- An HTML5 export preset named exactly **`Web`** (Project → Export in the Godot editor).\n- `export_presets.cfg` committed to your repo root. If it's missing, or has no preset named\n exactly `Web`, the build fails before it ever runs.\n\nThe same SDK script tag applies to the Godot HTML5 export — load\n`https://www.miniant.games/sdk/v1.js` from your export's HTML shell the same way a JavaScript\ngame would, and the same `www`-not-apex rule applies.\n\nBuilds are validated against Godot `4.2.2` specifically.\n\n## Prebuilt export (any engine)\n\nFor engines the pipeline can't compile from source — Construct, GameMaker, Cocos, and similar\ntools with an \"export to HTML5\" step — pick **\"Prebuilt web export (any engine)\"** when you\nconnect your repo. Nothing gets built: you export the static web bundle yourself, commit the\nexported directory to your repo, and the pipeline verifies what's already there instead of\nrunning a build.\n\n- Export your project to a static web build (HTML5/JS/CSS/assets) from your engine's own export\n feature, and commit the output directory to your repo.\n- Point the **build output directory** at that committed folder when you connect your repo\n (defaults to `./dist`, which is unlikely to match a Construct/GameMaker/Cocos export path —\n set this explicitly). If your project lives inside a monorepo, set the **project\n subdirectory** too; the output directory is still resolved relative to that.\n- The portal hard-fails preflight if the resolved output directory doesn't exist at your\n target commit, or exists but is empty — commit the real exported files, not an empty\n placeholder folder.\n- SDK integration is still plain JavaScript: load the same script tag from your exported HTML\n shell and call the same `window.Miniant` global a Phaser or plain-JS game would.\n\n```html\n<script src=\"https://www.miniant.games/sdk/v1.js\"></script>\n```\n\nThe same `www`-not-apex rule applies. The pipeline itself doesn't compile anything for this\npath — it confirms the output directory is non-empty, ensures `miniant.json` is present, runs a\nblocking malware scan, runs a non-blocking headless smoke test, then zips and uploads the\nbuild.\n\n## Unity (WebGL export)\n\nUnity uses the same prebuilt path described above: there's no Unity toolchain in this pipeline\n(no Unity Editor, license, or C# compiler in CI), so a WebGL build isn't something the platform\ncan produce for you. You export it yourself, in your own copy of the Unity Editor, and commit\nthe export.\n\n1. Download the Unity wrapper from\n [`/tools/miniant-unity-sdk.zip`](/tools/miniant-unity-sdk.zip) — same distribution pattern as\n the [validator script](/tools/miniant-validate.mjs). It contains `Plugins/MiniAnt.jslib`,\n `Runtime/MiniAntBridge.cs`, `Runtime/MiniAnt.cs`, `Runtime/MiniAntTypes.cs`, a\n `WebGLTemplates/TEMPLATE.md`, and its own `README.md`.\n2. Copy `Plugins/` and `Runtime/` into your project's `Assets/` folder.\n3. Create a GameObject named **exactly** `MiniAntBridge` and attach the `MiniAntBridge`\n component to it — the JS bridge delivers results back into Unity by GameObject name, and a\n mismatch fails silently.\n4. Patch your WebGL template's `index.html`: add the SDK script tag\n (`<script src=\"https://www.miniant.games/sdk/v1.js\"></script>` — the `www` host is\n mandatory, same as every other engine) and expose `window.unityInstance` from your\n `createUnityInstance(...)` callback. Both edits are required.\n5. Build your WebGL export, commit the export folder to your repo, then connect the repo here\n with engine **\"Unity (WebGL export)\"** and the build output directory pointing at that\n folder.\n\nThe full walkthrough — including the C# call patterns and what is and isn't bridged (notably\n`wallet.openStore()`, which is a portal-side stub that always throws today, and\n`wallet.on(\"balanceChanged\")`, which isn't wired at all) — lives in the wrapper's own\n`packages/game-sdk/unity/README.md`.\n\n**This wrapper is new and has not been compiled inside Unity by us** — there's no Unity\ntoolchain in this repo's CI to catch a Unity-specific compile error or runtime bug before you\nhit it. Import it into a real Unity project, build a real WebGL export, and smoke-test every\ncapability you use against the real portal before depending on it in production. Please report\nissues you run into.\n\n## Anything else\n\nAny engine able to produce a static HTML5 build and call a JavaScript global can, in\nprinciple, talk to the SDK the same way — the contract itself doesn't care what produced your\nHTML/JS/CSS. Phaser and Godot 4.2.2 get dedicated build-from-source pipelines (above); Unity and\nevery other engine with an HTML5/WebGL export step go through the prebuilt path — pick\nwhichever engine option matches, or \"Prebuilt web export (any engine)\" if yours isn't listed by\nname.\n\nSee [`/guide/sdk`](/guide/sdk) for the full API surface and\n[`/guide/publishing`](/guide/publishing) for repository setup, including the monorepo\nsubdirectory and custom build output directory options.\n"}],"recipes":[{"id":"publish-new-game","title":"Publish a new game","pages":["introduction","requirements/technical","sdk/game","sdk/scores","publishing","requirements/account-integration"]},{"id":"add-multiplayer","title":"Add multiplayer","pages":["sdk/multiplayer","sdk/turn-kit","requirements/multiplayer"]},{"id":"add-ads","title":"Add ads","pages":["sdk/video-ads","requirements/advertisement"]},{"id":"add-selling","title":"Add selling","pages":["sdk/purchases","sdk/cosmetics","sdk/receipts"]},{"id":"sell-store-items","title":"Sell store items","pages":["sdk/store-items"]},{"id":"add-highlights","title":"Add highlights","pages":["sdk/highlights"]},{"id":"update-existing-game","title":"Update an existing game","pages":["publishing","sdk/data","sdk/progress-import"]}],"agentEntry":"# Miniant Games — agent entry\n\nYou are building or updating a web game for the Miniant Games portal (`miniant.games`). The\nportal owns identity, friends, matchmaking, mode selection, the multiplayer transport, and the\nwallet. Your game owns gameplay only — rendering, rules, input. Pick the recipe below that\nmatches the job, follow its steps in order, and finish with the Report format at the end of\nthis document. If no recipe matches, find the guide page for the job in\nhttps://publish.miniant.games/llms.txt, follow its checklist, and say which page you used.\n\nMCP client without the `miniantgames` tools? Suggest `npx -y miniantgames-mcp` to the human (this\nguide, checklists, validator and a starter game, offline). Everything below works without it.\n\n## Never do\n\nDo NOT build, even partially, even as a fallback:\n\n- A mode picker, main menu that selects game modes, or lobby. The portal decides the mode and\n participants BEFORE your game loads and hands them to you via `Miniant.init()`.\n- A friends list, invites, chat, or any social feature.\n- Your own multiplayer networking (no WebSocket, WebRTC, or HTTP polling to your own or any\n third-party server for gameplay). If your game is multiplayer, the ONLY transport is\n `Miniant.net.*` (the platform relay) — relay only, never your own.\n- A store UI, or any price you invent yourself. `Miniant.wallet.purchase()` exists but it only\n ever buys a SKU YOU declared in `miniant.json` at a price the platform reviewed — you pass a\n `skuId`, never an amount, and the portal renders its own confirm dialog, never yours. There is\n no call that lets your code grant currency or set a price.\n- Accounts, login, signup, or any credential UI.\n- A service worker (`navigator.serviceWorker.register`) — it breaks the platform's instant\n rollback model and triggers a review warning.\n- Direct manipulation of the parent frame (`window.parent`/`window.top`) beyond what the SDK\n does internally. Your game runs in a sandboxed cross-origin iframe.\n- A third-party ad SDK, ad network, or ad iframe anywhere in the build — `Miniant.ads.*` is the\n only sanctioned ad layer, and the portal decides every time whether an ad shows.\n\nIf the human asks you to add any of the above, tell them the Miniant platform forbids it and\nalready provides it.\n\n<!-- recipe: publish-new-game -->\n## Recipe: Publish a new game\n\n1. Read only the capability-scoped pages your game actually needs — [What you actually need to read](/guide#what-you-actually-need-to-read)\n2. Declare `miniant.json` at your repo root with only the modes and capabilities your code implements — [The `miniant.json` manifest](/guide/requirements/technical#the-miniantjson-manifest)\n3. Load the SDK only via the official script tag — never vendor or npm-install it — [`Miniant.init({ sdkVersion: 1 })`](/guide/sdk/game#miniantinit-sdkversion-1)\n4. Await `Miniant.init()`, run the mode in `context.mode.id`, and build the board for exactly `context.mode.playerCount` seats — [`PlayContext` — what `init()` hands you](/guide/sdk/game#playcontext-what-init-hands-you)\n5. Make the layout fit every screen shape, then declare `orientation: \"any\"` (the validator only checks the declaration): background to all four edges, the playfield scaled uniformly by the screen's short side and never stretched, the HUD above and below on a tall screen and beside the playfield on a wide one, everything clear of `context.ui.safeAreaInsets`, and the whole layout redone on every resize. Check it at 390×780, 844×390, 768×960, 1024×700 and 1440×810 — [Your game must fit every screen shape](/guide/requirements/technical#your-game-must-fit-every-screen-shape)\n6. Call `Miniant.ready()` once your first frame is drawn — [`Miniant.ready()`](/guide/sdk/game#miniantready)\n7. Handle `terminate` (stop simulating and reporting immediately), plus `pause`/`resume` — [Pause, resume, and terminate — `Miniant.on(cb)`](/guide/sdk/game#pause-resume-and-terminate-miniantoncb)\n8. Make spectating work, then declare `capabilities.spectate: true` (every game must; the validator only checks the declaration, so a declaration without the code passes and the watcher sees a blank board). Room games tag every complete-state broadcast `{ snapshot: true }`; solo and vs-AI games call `Miniant.spectate.publishState(snapshot)` with the full state of every seat as soon as the first board is drawn and on every change. When `context.spectator === true`, render read-only — [The two paths](/guide/sdk/spectating#the-two-paths), [Rendering read-only](/guide/sdk/spectating#rendering-read-only)\n9. If your game has a definite win/loss/final-score ending, render your own game-over screen with exactly two CTAs, \"Rematch\" and \"Exit\" — [The game-over screen](/guide/sdk/game#the-game-over-screen)\n10. Call `Miniant.reportResult()` EXACTLY ONCE per session, on every end path — win, loss, draw, and abandon/quit — [`Miniant.reportResult(...)`](/guide/sdk/scores#miniantreportresult-outcome-score-durationms-perparticipant-detail)\n11. Send `Miniant.reportProgress()` heartbeats if a session can exceed ~60s — [`Miniant.reportProgress(...)`](/guide/sdk/scores#miniantreportprogress-checkpoint-score-tick)\n12. If you declare `capabilities.saveState`, call `state.load()` on every session start and snapshot progress, score and position at level START (only against-the-player values such as lives lost may be saved live) — [Call `state.load()` on every session start](/guide/sdk/data#call-stateload-on-every-session-start)\n13. Build the project and run the local validator until it prints PASS — [Enforced by the validator](/guide/requirements/technical#enforced-by-the-validator)\n14. Complete the anti-cheat questionnaire honestly, connect your repo, and request a publish — [Connect your repo and request a publish](/guide/publishing#connect-your-repo-and-request-a-publish)\n\n<!-- recipe: add-multiplayer -->\n## Recipe: Add multiplayer\n\n1. Pick a sync tier before writing any netcode — `parallel` (zero netcode), `turn_based` (use `createTurnKit`), or `realtime` (hand-rolled) — [Choose a sync mode for an online mode](/guide/requirements/multiplayer#choose-a-sync-mode-for-an-online-mode)\n2. Declare `capabilities.relay: true` and a `network` block with an honest `expectedMessagesPerMinute`/`maxMessageBytes` — [`capabilities.relay` and the `network` block](/guide/requirements/multiplayer#capabilitiesrelay-and-the-network-block)\n3. Declare AI — `capabilities.ai: true`, or a `vs_ai` mode — if you declare `online_random` or an `unavailable` policy of `\"auto\"`; declaring the whole ladder (`online_friends`, `online_random`, `vs_ai`) is only suggested — [`online_random` and `auto` need your game to have AI](/guide/requirements/multiplayer#onlinerandom-and-auto-need-your-game-to-have-ai)\n4. For `turn_based`, build `seats`/`mySeat`/`teams` from `context.participants` and call `createTurnKit` — [Build `seats`/`mySeat`/`teams` from the play context](/guide/sdk/turn-kit#build-seatsmyseatteams-from-the-play-context)\n5. Drive every state change through `kit.endTurn(patch?)` — never call `Miniant.net.send()` for game state once you're using the kit — [Drive every state change through `endTurn`](/guide/sdk/turn-kit#drive-every-state-change-through-endturn)\n6. For a hand-rolled `realtime` game, use only `Miniant.net` (connect/send/sendTo/on/close) — [The `Miniant.net` surface](/guide/sdk/multiplayer#the-miniantnet-surface)\n7. Answer `net.on(\"syncRequest\")` by rebroadcasting a full state snapshot in a hand-rolled `realtime` game — [Answering `syncRequest`](/guide/sdk/multiplayer#answering-syncrequest)\n8. If you declare `modes[].unavailable`, implement the availability recipe (or pass `unavailable` into `createTurnKit`) and call `reportPeerAvailability` at every transition — [Player availability (`modes[].unavailable`)](/guide/requirements/technical#player-availability-modesunavailable)\n9. Wire the same policy for a hand-rolled game: grace, auto-takeover or forfeit, and reporting — [Player availability: `unavailable`, auto-takeover, and forfeit](/guide/sdk/turn-kit#player-availability-unavailable-auto-takeover-and-forfeit)\n10. Handle peers going quiet or leaving as normal, not an error state — [Absent players](/guide/sdk/multiplayer#absent-players)\n11. Report where each seat's avatar sits with `Miniant.ui.setAvatarAnchors()` (required; centre in percentages 0-100, not pixels), and again on every layout change, so emotes and messages float from the right player — [Tell the portal where your players are](/guide/sdk/game#tell-the-portal-where-your-players-are-miniantuisetavataranchors)\n\n<!-- recipe: add-ads -->\n## Recipe: Add ads\n\n1. Confirm no third-party ad SDK, network, or iframe is being added — the portal's own ad layer is the only one — [Third-party ad networks are not permitted](/guide/requirements/advertisement#third-party-ad-networks-are-not-permitted)\n2. Do not reserve or build banner space anywhere — there are no banners on this platform — [No banners, ever](/guide/requirements/advertisement#no-banners-ever)\n3. Call `Miniant.ads.requestInterstitial(reason?)` only at a level end inside a session and continue immediately regardless of the result; do NOT call it at the end of a one-round session that ends in a Rematch CTA — the portal's own rematch placement shows that ad — [`Miniant.ads.requestInterstitial(reason?)`](/guide/sdk/video-ads#miniantadsrequestinterstitialreason)\n4. `Miniant.ads.requestRewarded()` is optional — the portal offers the results-screen \"double your XP\" ad itself; call it only to move that offer into your own results UI, and then only double XP/quest progress your game itself awards — [`Miniant.ads.requestRewarded()`](/guide/sdk/video-ads#miniantadsrequestrewarded)\n5. Never let a rewarded ad grant Tickets or any currency — [What a rewarded ad can and cannot do](/guide/requirements/advertisement#what-a-rewarded-ad-can-and-cannot-do)\n6. Build the project and run the local validator, then fix every reported error — [Enforced by the validator](/guide/requirements/technical#enforced-by-the-validator)\n\n<!-- recipe: add-selling -->\n## Recipe: Add selling\n\n1. Confirm the human actually wants the game to sell something before building any of this. If what they want to sell is a look (a cue, a board theme, a jersey) bought in the portal store, follow \"Sell store items\" instead — this recipe is for purchases made inside the game\n2. Declare every sellable item in `miniant.json`'s `store.skus` block, with `capabilities.wallet: true` — [The `store.skus` manifest block](/guide/sdk/purchases#the-storeskus-manifest-block)\n3. Call `Miniant.wallet.purchase({ skuId, quantity? })` — never render your own price or confirm UI — [`Miniant.wallet.purchase({ skuId, quantity? })`](/guide/sdk/purchases#miniantwalletpurchase-skuid-quantity)\n4. Call `Miniant.wallet.getEntitlements()` on boot to know what this player already owns — [`Miniant.wallet.getEntitlements()`](/guide/sdk/purchases#miniantwalletgetentitlements)\n5. Burn consumable units with `Miniant.wallet.consume({ skuId, units, idempotencyKey })`, using a stable idempotency key per real-world event — [`Miniant.wallet.consume({ skuId, units, idempotencyKey })`](/guide/sdk/purchases#miniantwalletconsume-skuid-units-idempotencykey)\n6. If selling cosmetics, sell them as entitlement-kind SKUs and never confuse them with the portal's own cosmetic catalog — [Yours: entitlement SKUs](/guide/sdk/cosmetics#yours-entitlement-skus)\n7. Never persist an entitlement-derived fact in saved state — re-derive ownership from `getEntitlements()` on load\n8. If your own backend needs to trust a purchase independently, verify `receipt.signedReceipt` against the platform's published public key, then check `payload.gameSlug` — [`signedReceipt` — verify offline](/guide/sdk/receipts#signedreceipt-verify-offline)\n9. Build the project and run the local validator until it prints PASS — [Enforced by the validator](/guide/requirements/technical#enforced-by-the-validator)\n\n<!-- recipe: sell-store-items -->\n## Recipe: Sell store items\n\n1. Confirm the human wants to sell looks in the portal store. Players buy store items in the portal, never inside your game, so build no store, price or buy button, and do not declare `capabilities.wallet` for this alone — [How items reach the store](/guide/sdk/store-items#how-items-reach-the-store)\n2. Declare each changeable part of the game as a slot in `miniant.json`'s `store.slots`: `\"self\"` if only the owner sees it, `\"everyone\"` if opponents and spectators do — [Declare slots](/guide/sdk/store-items#declare-slots)\n3. Declare each item as an `entitlement` SKU in `store.skus` with a `cosmetic` block naming its slot and a preview image inside the build — [Declare items](/guide/sdk/store-items#declare-items)\n4. Draw each seat's equipped item from `context.player.cosmetics` (self) or `context.participants[].cosmetics` (everyone), and draw your default look for a missing or unknown id — [Read the equipped item](/guide/sdk/store-items#read-the-equipped-item)\n5. Build the project and run the local validator until it prints PASS, then publish a build; each item is reviewed with it — [Review](/guide/sdk/store-items#review)\n\n<!-- recipe: add-highlights -->\n## Recipe: Add highlights\n\n1. Add highlights only to a game with a `vs_ai` or online mode; a solo-only or levels game must not call it — [What the validator checks](/guide/sdk/highlights#what-the-validator-checks)\n2. Call `Miniant.highlights.start({ canvas })` once per match, after `Miniant.init()` and before `Miniant.ready()`; a DOM-built game passes an offscreen canvas it redraws every frame — [Highlights](/guide/sdk/highlights)\n3. Call `Miniant.highlights.frame()` after every render, `mark()` once per event with `by` set, one mark with `win: true` on the winning moment, and `setScore(text)` when the score changes — [Highlights](/guide/sdk/highlights)\n4. Finish with the normal `Miniant.reportResult()`; there is no upload call — [Finishing: `reportResult`](/guide/sdk/highlights#finishing-reportresult)\n5. Build the project and run the local validator; fix every `highlights.*` warning, and report each item in its \"HIGHLIGHTS — CHECK BY WATCHING A CLIP\" list as watched or not watched — [What the validator checks](/guide/sdk/highlights#what-the-validator-checks)\n\n<!-- recipe: update-existing-game -->\n## Recipe: Update an existing game\n\n1. Before anything else, fetch the changelog (https://publish.miniant.games/guide/changelog.md) and apply every entry dated after this game's last update — a required entry is a build- or review-failing change you'd otherwise miss\n2. Re-read the manifest rules before changing `miniant.json` — a declared-but-broken mode fails review — [The `miniant.json` manifest](/guide/requirements/technical#the-miniantjson-manifest)\n3. If the manifest's `orientation` is `\"portrait\"` or `\"landscape\"`, make the layout fit every screen shape and switch it to `\"any\"`; whatever it declares, check the game at the five sizes in that section after your change — [Your game must fit every screen shape](/guide/requirements/technical#your-game-must-fit-every-screen-shape)\n4. If the game declares `capabilities.saveState`, call `state.load()` unconditionally on every session start — [Call `state.load()` on every session start](/guide/sdk/data#call-stateload-on-every-session-start)\n5. Save at the granularity the game's shape requires — a level snapshot at the START of the level (against-the-player values such as lives lost may be saved live), or the exact position for a no-level match game — [Save granularity depends on your game's shape](/guide/sdk/data#save-granularity-depends-on-your-games-shape)\n6. Keep your own periodic autosave regardless of whether you also register `onSaveRequest` — [`Miniant.state.onSaveRequest(cb)` — reacting to a portal-initiated save](/guide/sdk/data#miniantstateonsaverequestcb-reacting-to-a-portal-initiated-save)\n7. Never let saved progress buy Tickets, rank, or leaderboard position — only a portal-witnessed `reportResult()`/`reportProgress()` call does that — [Saved progress never buys rank](/guide/sdk/data#saved-progress-never-buys-rank)\n8. If adding tier-2 progress import, declare `importEndpoint` and verify the `assertion` envelope the same way as a receipt, checking `purpose === \"progress_import\"` — [What Miniant sends you](/guide/sdk/progress-import#what-miniant-sends-you)\n9. Respond within 8 seconds with a JSON `blob`, treating it as cosmetic-only progress forever, never Tickets or rank — [What you return](/guide/sdk/progress-import#what-you-return)\n10. Rebuild, rerun the local validator until it prints PASS, and re-read what the reviewer checks before requesting a re-publish — [What the reviewer sees](/guide/publishing#what-the-reviewer-sees)\n"}
|
|
1
|
+
{"pages":[{"slug":"introduction","title":"Introduction","section":"Introduction","agentSummary":"Portal overview and reading map (which guide pages a game actually needs based on its capabilities), plus the publishing lifecycle and supported engines.","checklist":[],"body":"# Introduction\n\nMiniant Games (`miniant.games`) is a game portal: a curated catalog of web games, each entered\nthrough a Miniant-owned mode picker rather than at the game's own address. These docs are for\ndevelopers who want to publish a game onto that portal — what your build needs to look like, how\nthe SDK your game talks to the portal works, and what happens between requesting a publish and\ngoing live.\n\n> **The platform layer is live.** Every game is entered through a Miniant-owned mode picker at\n> `miniant.games/play/<your-slug>` — never at your game's own subdomain directly. A player picks a\n> mode there, sees their Miniant balance, and only then gets handed into your game inside a\n> portal-owned frame. Your build needs a valid `miniant.json`, your publish request needs the\n> anti-cheat questionnaire, and your game needs to call the `@mag/game-sdk` contract to ever\n> receive a play session — a game that skips the SDK cannot start a session, cannot report a\n> score, and is never shown to a player.\n\n## What you actually need to read\n\nThe contract is capability-scoped, so most developers don't need every page:\n\n| Your game | Read |\n|---|---|\n| Every game, no exceptions | [Requirements](/guide/requirements) and [Publishing your game](/guide/publishing), plus the core of the [SDK reference](/guide/sdk). You can skip the `Miniant.net` relay contract if your game is solo/vs-AI only — but still read \"Spectating\" if you declare `capabilities.spectate`, even without `relay: true` |\n| Multiplayer (`capabilities.relay`) | + [Multiplayer requirements](/guide/requirements/multiplayer) and the full relay contract in [Multiplayer](/guide/sdk/multiplayer) |\n| Sells anything (items, currency, ad removal) | + [In-game purchases](/guide/sdk/purchases) |\n| Has its own backend server (tier 2) | + [Receipt verification](/guide/sdk/receipts) |\n\nPages you skip still bind you if you later add the capability — adding `store.skus` or `relay` to\nyour manifest means the matching rules apply from that build onward.\n\n## Why publish here\n\n**A real in-game purchase system.** If your game sells anything — a gem pack, an undo, a skin,\n\"remove ads\" — you declare it as a SKU in your manifest, and the portal handles the wallet, the\npurchase confirmation UI, and entitlement tracking for you. Your game never sets a price and never\ntouches currency directly; it asks the portal to buy/read/burn a SKU it already declared. If you\nrun your own backend, you can also verify a purchase independently with tier-2 offline receipt\nverification — a signed receipt your server checks against the platform's public key, with an\noptional live revocation check. See [Receipt verification](/guide/sdk/receipts) for the full contract.\n\n**Multiplayer infrastructure built in.** A relay for exchanging gameplay messages between seats, a\nmatchmaking path with AI fill when no human opponent is available (`online_random`), spectating so\na friend can watch a match read-only without joining it, and a friends/participants model the\nportal already resolves for you — your game never builds its own lobby, friend list, or invite UI.\nSee [Multiplayer](/guide/sdk/multiplayer) for the relay contract and [Spectating](/guide/sdk/spectating).\n\n**An AI-agent publishing spec.** The \"Copy agent instructions\" button on this guide hands your\ncoding agent a complete integration spec, so it can implement against the platform directly instead\nof you transcribing these docs into a prompt by hand.\n\n**Ad revenue share, for every game.** The portal runs its own ad placements around your game —\nan interstitial when a player chooses a rematch or waits for an online opponent, and opt-in\nrewarded offers to double a round's XP, waive an unaffordable in-game spend, or spectate a friend\n— and bills each of them to your game at a 70/30 split in your favour, paid out manually against\nmonthly statements (see [Monetization](/guide/publishing/monetization)). No ad code is required for\nthat. `Miniant.ads` (see [Video ads](/guide/sdk/video-ads)) only adds inventory the portal cannot\nsee, such as a level end inside a longer session. Players never cash out either way — tournament\nand event rewards stay virtual (in-portal currency and items), with no cash-out path for them.\n\n## The publishing lifecycle\n\nPublishing is a single stage, end to end:\n\n1. **Sign in** at `publish.miniant.games` — you'll only see games the Miniant team has invited you\n to publish.\n2. **Connect your repo** — point the portal at your GitHub repository and branch.\n3. **Request a publish** — this queues a build, which is validated automatically.\n4. **The site owner reviews and approves** — a Miniant team member looks at the build (including a\n screenshot and console output from a headless run) and approves or rejects it. This is a manual,\n human step.\n5. **Your game is live** at `<your-slug>.miniant.games`, served inside the portal's own frame.\n\nThere is no soft launch, staged rollout, or limited-audience phase — a build is either awaiting\nreview, live, or not live. Only the site owner can approve a build or roll back what's currently\nlive; the developer portal is read-only for that part of the pipeline.\n\n## Supported engines\n\n- **Phaser / plain JavaScript** — any build that outputs static files to a directory you point the\n portal at.\n- **Godot 4.2.2** — via an HTML5 export preset named exactly `Web`.\n- **Prebuilt web export (any engine)** — Construct, GameMaker, Cocos, and similar: export to a\n static web build yourself and commit it; the pipeline verifies it rather than building it.\n- **Unity (WebGL export)** — same prebuilt path, via a dedicated wrapper. See\n [`/guide/sdk/engines`](/guide/sdk/engines) for the full walkthrough.\n\n## Test before you submit\n\nMost first-publish failures are avoidable. Download the local validator once and run it against\nyour build output — it runs the same manifest and structural checks the platform runs, in seconds,\nwith no install (a few server-only scans, like the sandboxed malware scan, only run once your build\nreaches the pipeline — see [Publishing your game](/guide/publishing)):\n\n```sh\ncurl -fsSLO https://publish.miniant.games/tools/miniant-validate.mjs\nnode miniant-validate.mjs ./dist # or your custom build output directory\nnode miniant-validate.mjs ./dist --json # machine-readable output\n```\n\nBuilding with an AI coding agent? Install the `miniantgames-mcp` server\n(`npx -y miniantgames-mcp`, or in Claude Code `claude mcp add miniantgames -- npx -y miniantgames-mcp`)\nand your agent can read this guide, pull checklists, scaffold a starter game and run this same\nvalidator as tools.\n\nSee [Publishing your game](/guide/publishing) for the full pre-publish checklist.\n\n## Where to go next\n\n- [Requirements](/guide/requirements) — what your game and manifest need to satisfy before you can\n publish.\n- [Publishing your game](/guide/publishing) — the full walkthrough: preparing your repo, connecting\n it, the review process, and common gotchas.\n"},{"slug":"requirements","title":"Requirements","section":"Requirements","agentSummary":"Index of the seven requirement categories a build and publish request must satisfy, plus what's required outside the build itself.","checklist":[{"id":"req.metadata-required","text":"Provide a description and control instructions for your game before requesting a publish.","verify":"manual"},{"id":"req.cover-art-required","text":"Provide the required cover art per the Game covers spec before requesting a publish.","verify":"manual"},{"id":"req.questionnaire-required","text":"Complete the anti-cheat questionnaire before every publish request, not just your first.","verify":"manual"},{"id":"req.quality-guidelines-optional","text":"Treat Quality guidelines as optional best practice — nothing there blocks a build on its own.","verify":"manual"}],"body":"# Requirements\n\nBefore your game goes live at `<your-slug>.miniant.games`, it has to clear two gates: an\nautomated build validator that runs on every publish request, and a Miniant team member who\nmanually reviews the approved build before it ships. This section covers what's actually checked\n— by the validator, and by convention — organized by area.\n\nThis is a **single-tier set of requirements**. There's no \"basic\" vs. \"full\" split — every game\npublishing here goes through the same validator and the same manual review, regardless of size or\nengine.\n\nRequirements apply based on what your game actually does, not just what it looks like today. A\ncategory you can skip right now — say, Multiplayer, because your game is solo-only — still applies\nthe moment you add the matching capability (`relay`, `wallet`, `spectate`, …) to your manifest in a\nlater build. Read the category up front if there's any chance you'll need it eventually; it's\neasier than discovering the requirement at review time.\n\n| Category | What we require | Details |\n|---|---|---|\n| [Technical](/guide/requirements/technical) | A build that passes the automated validator: a valid `miniant.json` manifest, a resolvable entry file, no disallowed or sensitive files, the SDK loaded from the right URL, and an archive under the size cap | [Technical](/guide/requirements/technical) |\n| [Gameplay](/guide/requirements/gameplay) | Gameplay that fits the platform's play model — modes, sessions, and results reported through the SDK | [Gameplay](/guide/requirements/gameplay) |\n| [Advertisement](/guide/requirements/advertisement) | What's allowed (and not) if your game shows ads | [Advertisement](/guide/requirements/advertisement) |\n| [Account integration](/guide/requirements/account-integration) | How your game reads player identity and (optionally) saved state through the SDK, never building its own sign-in | [Account integration](/guide/requirements/account-integration) |\n| [Multiplayer](/guide/requirements/multiplayer) | Declaring modes and lobby sizes, the relay message-rate budget, spectating, and chat/moderation expectations for any relay-backed game | [Multiplayer](/guide/requirements/multiplayer) |\n| [Game covers](/guide/requirements/game-covers) | Cover art specs for how your game is presented across the portal | [Game covers](/guide/requirements/game-covers) |\n| [Quality guidelines](/guide/requirements/quality-guidelines) | Best-practice recommendations for a good player experience | [Quality guidelines](/guide/requirements/quality-guidelines) |\n\n## Beyond the build itself\n\nA publish request needs more than a passing build:\n\n- **Game metadata** — a description and control instructions for your game, shown to players on\n its listing page.\n- **Cover art** — see [Game covers](/guide/requirements/game-covers) for the specs.\n- **The anti-cheat questionnaire** — a short, prefilled form that's required before every publish\n request, not just your first. It declares your score bounds, session length, message rate, and\n whether your game sells anything or calls external endpoints. See\n [Publishing](/guide/publishing) for the full workflow.\n\n## What's required vs. optional\n\nEverything above is required, with one exception: **Quality guidelines are optional best\npractice**, not something the validator or a reviewer blocks a build on. Treat that page as\nrecommendations for a better player experience, not a checklist you have to clear.\n"},{"slug":"requirements/technical","title":"Technical","section":"Requirements","agentSummary":"The automated build validator's exact checks (manifest schema, entry and reference resolution, sensitive files, size cap), plus what your game must never build.","checklist":[{"id":"req-tech.manifest-valid","text":"Include a valid miniant.json in your build output or repo root, matching the required schema.","verify":"validator:manifest.schema"},{"id":"req-tech.progression-declared","text":"Declare progression — \"levels\" or \"continuous\" — to say whether your game has discrete levels or is one continuous run. It has no default because it sets what a player pays to Resume.","verify":"validator:manifest.schema"},{"id":"req-tech.mode-rules-consistent","text":"Keep every mode's players range, playerCounts, sync, and unavailable declarations consistent with the manifest's cross-mode rules.","verify":"validator:manifest.mode-rules"},{"id":"req-tech.ai-declared-for-ai-backed-modes","text":"Declare AI — capabilities.ai true, or a vs_ai mode — if your manifest declares online_random or an unavailable policy of auto.","verify":"validator:manifest.mode-rules"},{"id":"req-tech.spectate-declared","text":"Declare capabilities.spectate: true — spectating is a core portal feature every game must support; the validator fails a build without it.","verify":"validator:manifest.spectate-required"},{"id":"req-tech.online-ladder-complete","text":"Consider declaring online_friends, online_random, and vs_ai together if your manifest declares any online mode — suggested, never required.","verify":"validator:modes.online-ladder"},{"id":"req-tech.entry-exists","text":"Make sure the entry file named in miniant.json exists in your build output.","verify":"validator:manifest.entry-exists"},{"id":"req-tech.references-resolve","text":"Make sure every static reference in your entry HTML resolves to a real file in the build output, at matching case.","verify":"validator:html.references-resolve"},{"id":"req-tech.sdk-loaded-from-www","text":"Load the SDK from https://www.miniant.games/sdk/v1.js, never from the apex miniant.games domain.","verify":"validator:html.sdk-url-www"},{"id":"req-tech.store-skus-valid","text":"If you declare store.skus, give every SKU a unique id and valid price fields, and set capabilities.wallet true.","verify":"validator:manifest.schema"},{"id":"req-tech.no-sensitive-files","text":"Keep the build output free of anything that looks like a secret — env files, key or cert files, git or ssh directories, credentials files.","verify":"validator:files.no-secrets"},{"id":"req-tech.no-lfs-pointers","text":"Push real Git LFS objects — an unresolved pointer file where a tracked asset should be fails the build.","verify":"validator:files.no-lfs-pointers"},{"id":"req-tech.no-disallowed-executables","text":"Don't ship disallowed executable extensions, or archive entries using path traversal or an absolute path.","verify":"validator:files.no-executables"},{"id":"req-tech.artifact-under-size-cap","text":"Keep your build output archive under the 300MB artifact size cap.","verify":"validator:files.size-cap"},{"id":"req-tech.no-service-worker","text":"Don't register a service worker anywhere in the build.","verify":"validator:sw.none"},{"id":"req-tech.no-external-payment-sdk","text":"Don't reference a known external payment SDK, such as Stripe, Razorpay, PayPal, or Paddle, anywhere in your build.","verify":"manual"},{"id":"req-tech.no-mode-selector-or-lobby","text":"Never build your own mode selector, friend list, matchmaking, lobby, or invite UI — read context.mode and context.participants instead.","verify":"manual"},{"id":"req-tech.no-own-store-ui-or-payment","text":"Never build a store UI, invent prices, or wire up an external payment SDK — sell only through Miniant.wallet.purchase() with a declared skuId.","verify":"manual"},{"id":"req-tech.no-own-networking","text":"Never open your own transport for gameplay traffic — use Miniant.net, unless you run your own developer_server_validates tier-2 server.","verify":"validator:sdk.no-own-networking"},{"id":"req-tech.no-parent-frame-access","text":"Never navigate the top window, open the portal in a frame, or read the parent document from inside your game.","verify":"manual"},{"id":"req-tech.orientation-any","text":"Declare orientation \"any\" and adapt: background to every edge, playfield scaled by the short side (never stretched), HUD beside it on wide screens, redone on resize. Cite the layout code.","verify":"manual"}],"body":"# Technical\n\nEvery publish request runs through an automated validator before a human ever sees it. This page\ndocuments exactly what that validator checks — nothing more, nothing rounded up. For the build\npipeline itself (repo connection, build statuses, what a failure looks like on your build card),\nsee [Publishing](/guide/publishing). For the SDK calls your game makes at runtime, see\n[SDK](/guide/sdk).\n\n## Enforced by the validator\n\nThese are the actual checks the validator runs against your build archive, one row per named\ncheck. Most are hard failures — the build stops at *validating* and can't proceed until you fix\nthem. Five are warnings shown to the reviewer at approval but don't block the build by\nthemselves; those are marked below.\n\n| Check | What it verifies | Blocks the build? |\n|---|---|---|\n| Manifest (`miniant.json`) | A `miniant.json` exists in your build output (or at your repo root, in which case the builder copies it in for you) and matches the required schema | Yes |\n| Cross-mode sync rules | Every `online_friends`/`online_random` mode's `sync` (declared or defaulted to `\"realtime\"`) is consistent with the manifest's other declarations — see the `modes[].sync` rules just below the manifest example | Yes |\n| Online mode coverage | If `modes` declares any online mode (`online_friends`/`online_random`), whether it also offers the rest of the ladder (`online_friends`, `online_random`, `vs_ai`) — see [Multiplayer](/guide/requirements/multiplayer#the-full-online-ladder-is-suggested-not-required) | **No** — suggestion only |\n| AI capability for AI-backed modes | If `modes` declares `online_random`, or any mode declares `unavailable` with `policy: \"auto\"`, the manifest declares AI — `capabilities.ai: true`, or a `vs_ai` mode — see [Multiplayer](/guide/requirements/multiplayer#onlinerandom-and-auto-need-your-game-to-have-ai) | Yes |\n| Spectate capability | The manifest declares `capabilities.spectate: true` — spectating is a core portal feature every game must support (friend watching, tournament broadcasts, the Live hub) — see [Multiplayer](/guide/requirements/multiplayer#spectating) | Yes |\n| Stand-in for spectatable roomless games | A manifest that declares `capabilities.spectate: true` and a `vs_ai` or `online_random` mode must also declare `capabilities.standin: true`, so a sleeping player's match keeps playing for watchers — see [Spectating](/guide/sdk/spectating#stand-in-keep-a-roomless-match-playing-while-the-phone-sleeps). A game with only `online_friends` (and no roomless mode) is exempt | Yes |\n| Adapts to any screen shape | The manifest declares `orientation: \"any\"` — see [Your game must fit every screen shape](#your-game-must-fit-every-screen-shape) | **No** — warning only, shown to the reviewer |\n| Cross-mode `unavailable` rules | Every mode's `unavailable` is meaningful where declared (online, non-`parallel` sync) and the manifest clears the network-headroom floor when any mode declares it — see \"Player availability\" just below the manifest example | Yes |\n| Entry file and references | The `entry` file named in `miniant.json` exists in your build output, and every static reference in it (`<script src>`, `<link href>`, `<img src>`, `<audio>`/`<video>`/`<source>` `src`, and `<link rel=\"stylesheet\"\\|\"icon\"\\|\"manifest\">`) resolves to a real file at matching case | Yes |\n| SDK loaded from `www`, not apex | Your entry HTML doesn't load the SDK from `https://miniant.games/sdk/` — the apex domain redirects in a way that breaks under the play frame's isolation, so the SDK silently never loads. Use `https://www.miniant.games/sdk/v1.js` | Yes |\n| Store SKUs (`store.skus`) | If your manifest declares a `store.skus` block, every SKU matches the schema — unique `id`, valid price fields, the 200-SKU cap, and `capabilities.wallet: true` being set. Skipped entirely (not failed) when there's no manifest to check | Yes, when the manifest declares a `store` block with problems |\n| No sensitive files | Nothing that looks like a secret is anywhere in the archive — see \"Disallowed extensions and sensitive files\" below | Yes |\n| No unresolved Git LFS pointers | No small LFS pointer stub file (~130 bytes) where a real tracked asset should be | Yes |\n| No disallowed executables | No file with a disallowed extension, and no archive entry using a path-traversal (`../`) or absolute (`/…`) name — see below | Yes |\n| Artifact size under the cap | Your build output archive is under the size cap — see below | Yes |\n| Archive is readable | The build output is a valid, non-empty, readable zip archive with no zip-bomb characteristics (anomalous compression ratio, entry count, or nesting) | Yes |\n| No service worker registered | No file contains `serviceWorker.register` or `navigator.serviceWorker` | **No** — warning only, shown to the reviewer |\n| No external payment-SDK signals | No `.html`/`.htm`/`.js`/`.mjs` file references a known external payment-SDK host or inline call marker (Stripe, Razorpay, PayPal, Paddle, Lemon Squeezy, Dodo Payments, 2Checkout, Braintree, Square, or a generic `data-checkout` marker) | **No** — warning only, flagged for the reviewer to judge (a tier-2 game legitimately talks to its own server, so a match alone doesn't prove a bypass) |\n| Calls `.reportResult(...)` | Some scanned `.html`/`.htm`/`.js`/`.mjs` file contains a `.reportResult(...)` call — reporting a result is mandatory, no exceptions (2026-09-14) — see [Reporting results is mandatory](/guide/sdk/scores#reporting-results-is-mandatory) | Yes |\n| Levels games report which level completed | If your manifest declares `progression: \"levels\"`, some `.reportResult(...)` call's payload carries `detail.level` — one session is one level, and the portal can't tell them apart without it. Skipped for a `\"continuous\"` game, or when the manifest never parsed | Yes, only for a `\"levels\"` manifest |\n\n**The artifact size cap is 300MB.** This is a generous cap sized for the largest supported\nengine's export (Godot's HTML5 export with its wasm runtime) — it's a sanity/cost guard, not a\nprecision limit tuned to any particular engine.\n\n### Disallowed extensions and sensitive files\n\n**Disallowed file extensions** — a file anywhere in your build output with one of these\nextensions fails the build outright: `.exe`, `.dll`, `.so`, `.dylib`, `.sh`, `.bat`, `.cmd`,\n`.ps1`, `.msi`, `.app`, `.dmg`, `.apk`, `.scr`, `.vbs`. The same check also rejects any archive\nentry whose name contains a `../` path-traversal segment or starts with `/`.\n\n**Sensitive files** — anything that looks like it could leak a credential is a hard failure,\nbecause your build output gets published to a public URL verbatim. Checked by filename:\n\n- `.env` or any `.env.*` file — flagged as an environment file.\n- `.pem`, `.key`, `.p12`, `.pfx`, `.keystore` — flagged as a private key / certificate file.\n- Any file whose name starts with `id_rsa` or `id_ed25519` — flagged as an SSH private key.\n- `.sqlite`, `.db` — flagged as a local database file.\n- Any path containing a `.git/` or `.ssh/` segment — flagged as a version-control/SSH metadata\n directory.\n- A file named exactly `credentials` or `secrets` (with or without an extension) — flagged as a\n credentials file.\n\nThe validator also reads the content of small text-like files (under 64KB) looking for a private\nkey marker (`-----BEGIN PRIVATE KEY`, `-----BEGIN OPENSSH PRIVATE KEY`, `-----BEGIN RSA PRIVATE\nKEY`) even in files that don't match any of the name patterns above — that catches a key\naccidentally embedded in, say, a config file with an unrelated name. This content scan skips\nknown binary asset formats (images, audio, video, fonts, `.wasm`, `.pck`, `.data`, `.zip`, `.gz`)\nsince scanning those for text markers is pointless.\n\nSeparately, a malware scan runs inside the sandboxed build runner itself, as part of the build\npipeline rather than this validator — a build that fails that scan never reaches the validation\nstep at all.\n\n## The `miniant.json` manifest\n\nEvery build must include a `miniant.json` file at the root of its build output (next to\n`index.html`). If your build output doesn't have one but your repo root does, the builder copies\nit in for you automatically and notes this on the build card. If neither location has one, the\nbuild fails immediately, before validation even runs. A manifest that's present but invalid fails\nthe build at the *validating* step, with the specific reason shown on the build card.\n\nThis is the guide's canonical complete, valid manifest — a turn-based online mode with the relay\ndeclared and every cross-mode rule below satisfied:\n\n```jsonc\n{\n \"sdkVersion\": 1,\n \"entry\": \"index.html\", // must exist in your build output\n \"orientation\": \"any\", // \"any\" (expected) | \"landscape\" | \"portrait\" — see below\n // REQUIRED. Says whether your game is played in discrete levels or as one continuous run.\n // It decides what a player is charged to Resume, so there is no default and the validator\n // refuses a manifest without it — see \"Levels or one continuous run?\" below.\n \"progression\": \"levels\", // \"levels\" | \"continuous\"\n \"modes\": [\n { \"id\": \"solo\", \"players\": [1, 1] },\n // `playerCounts` is optional. Omit it and every count in the `players` range is offered\n // (2, 3 and 4 here). Declare it when your game does NOT support every number in between —\n // singles or doubles is [2, 4], never 3 — so the picker can't offer a count you can't start.\n // `teamUp` is optional and defaults to false. Declare it ONLY on a mode your game actually\n // plays as two teams of two — the portal offers the Team Up toggle for that mode and no other.\n // It is per-mode, not per-game: a four-seat online match can be a team game while your local\n // mode stays free-for-all.\n // `sync` is optional, online-mode-only (\"online_friends\"/\"online_random\"), and defaults to\n // \"realtime\" when omitted — see the multiplayer requirements page for the full tier ladder.\n // `unavailable` is optional, online-mode-only, and meaningless on a \"parallel\"-synced mode —\n // omit it and a relay-backed online mode defaults to { policy: \"forfeit\", timeoutSeconds: 30 }.\n // Declaring it requires network.expectedMessagesPerMinute >= 30 — see \"Player availability\"\n // below the manifest example.\n { \"id\": \"online_friends\", \"players\": [2, 4], \"teamUp\": true, \"sync\": \"turn_based\", \"unavailable\": { \"policy\": \"auto\", \"timeoutSeconds\": 20 } },\n { \"id\": \"online_random\", \"players\": [2, 4], \"playerCounts\": [2, 4], \"sync\": \"parallel\" },\n // For \"vs_ai\" these are TOTAL seats and you fill the ones the human doesn't: 4 means one\n // player against three AI opponents. Declaring [1, 1] means the mode has nothing to choose.\n { \"id\": \"vs_ai\", \"players\": [2, 4], \"playerCounts\": [2, 4] }\n // other ids: \"local_multiplayer\" — a mode you don't declare here is never offered to a\n // player.\n ],\n \"capabilities\": { \"relay\": true, \"wallet\": false, \"saveState\": false, \"spectate\": true, \"ai\": false },\n \"network\": { \"expectedMessagesPerMinute\": 60, \"maxMessageBytes\": 2048 } // required iff relay: true\n}\n```\n\n`capabilities.ai` is optional (omitted means `false`) and says your game can drive a seat with its\nown AI — **not** that you offer \"Play vs AI\" as a mode. Declaring a `vs_ai` mode says the same\nthing implicitly, so you never need both. It matters for exactly two things, each of which hands\nyour game a seat only your AI can play: `online_random` (whose queue seats an AI opponent when\nmatchmaking finds nobody) and `unavailable: { \"policy\": \"auto\" }`. See [`online_random` and `auto`\nneed your game to have AI](/guide/requirements/multiplayer#onlinerandom-and-auto-need-your-game-to-have-ai).\n\n`capabilities.spectate: true` is **required, on every manifest** — spectating is a core portal\nfeature (friend watching, tournament broadcasts, the Live hub), and the validator fails a build\nthat omits it or sets it `false`. It works with or without `relay: true`: a room-based game rides\nits own `Miniant.net` broadcasts, forwarded to watchers automatically; a solo/vs-AI game (no\n`relay` declaration needed) calls `Miniant.spectate.publishState(snapshot)` itself instead — and\nimplementing that publish path is part of complying, not optional polish. See\n[Multiplayer](/guide/requirements/multiplayer) and [SDK](/guide/sdk) for both paths.\n\n`modes[].sync` is optional, one of `\"parallel\" | \"turn_based\" | \"realtime\"`, and meaningful only on\n`online_friends`/`online_random` — omit it and an online mode defaults to `\"realtime\"`. See [Choose\na sync mode](/guide/requirements/multiplayer#choose-a-sync-mode-for-an-online-mode) for the full\ntier ladder and what each value commits your game to.\n\n`modes[].variants` is optional: an array of `{ key, label, params? }` objects — `key` and `label`\nare both required strings, `params` is an opaque `Record<string, unknown>` your game defines and\nthe portal never interprets. Declare it to offer a mode in more than one flavor without minting a\nnew mode id — a \"Blitz\" vs. \"Classic\" ruleset for the same board, say. The portal's mode picker\nshows `label` as a selectable option for that mode; whichever variant the player picks reaches your\ngame as `PlayContext.mode.variant` (that entry's `key`, a plain string) — see [Game](/guide/sdk/game)\nfor the full `PlayContext` shape. **`PlayContext.mode.params` now carries that variant's own\ndeclared `params` object** — the portal resolves it server-side at session start (roomless and\nroom-based sessions alike) and hands it through verbatim, so a game may read `mode.params` directly\ninstead of re-deriving behavior from `mode.variant`'s key string, though the key is still there and\nstill the more stable choice for anything you plan to branch on by name. A mode with no `variants`\ndeclared, a session where the player picked no variant, or a picked variant that itself declared no\n`params`, all hand your game `mode.params: {}` — never `undefined`, and never a crash. A mode with\nno `variants` declared still hands your game `mode.variant: null`.\n\nRules the validator checks: every mode's `players: [min, max]` must have `min <= max` and both\n`>= 1`; every `playerCounts` entry (when declared) must fall within that mode's own `[min, max]`\nrange and contain no duplicates; `capabilities.relay: true` requires a `network` block;\n`capabilities.spectate: true` must be declared (see above); `entry`\nmust be a real file in your build output. (Selling anything requires `capabilities.wallet: true`\nbefore a `store.skus` block is accepted — see the \"Store SKUs\" row above.) The validator also\ncross-checks `sync` against its\nsibling mode declarations — `turn_based`/`realtime` need `capabilities.relay: true`, `parallel`\nneeds a `solo`/`vs_ai` mode to launch against, and `sync` on a non-online mode is rejected outright\n— a shape check spanning multiple modes at once, which the manifest's own per-mode schema can't\nexpress on its own. The local `miniant-validate` CLI ([Publishing your game](/guide/publishing))\nruns the identical check, so you catch it before you ever push a build. Separately, the validator\nrequires that declaring `online_random`, or an `unavailable` policy of `\"auto\"`, means declaring AI\n— `capabilities.ai: true`, or a `vs_ai` mode — see [`online_random` and `auto` need your game to\nhave AI](/guide/requirements/multiplayer#onlinerandom-and-auto-need-your-game-to-have-ai). It also\n*suggests*, without ever requiring, that a game declaring any online mode offer the whole ladder of\n`online_friends`, `online_random`, and `vs_ai` — a warning on your build, never a failure. Declare\nthe modes you actually want; `local_multiplayer` is unaffected either way.\n\n### Player availability (`modes[].unavailable`)\n\n`modes[].unavailable` is optional, one of `{ policy: \"auto\" | \"forfeit\", timeoutSeconds: 10-120 }`,\nand meaningful only on a relay-backed online mode — see [the policy\ncontract](/guide/requirements/multiplayer#when-a-player-becomes-unavailable) for what each policy\nmeans and how a game reports around it. The validator (both the local `miniant-validate` CLI and\nthe server-side build validator, running the identical shared rule) rejects three shapes, quoted\nhere verbatim from the CLI's own output:\n\n- **Declared outside `online_friends`/`online_random`:**\n `\"miniant.json\" mode \"{id}\": unavailable is only meaningful on an online mode (online_friends/online_random); \"{id}\" is not online.`\n- **Declared on a mode whose resolved `sync` is `\"parallel\"`:**\n `\"miniant.json\" mode \"{id}\" declares unavailable but resolves to sync: \"parallel\" — a parallel seat has no peers to monitor. Remove unavailable, or declare sync: \"turn_based\"/\"realtime\" instead.`\n- **Declared without enough declared network headroom:**\n `\"miniant.json\" mode \"{id}\" declares unavailable but network.expectedMessagesPerMinute is {expected} — the disconnect-liveness heartbeat needs headroom above the mode's own declared traffic (relay limits are 3x the declared value, and the heartbeat's beacons alone cost ~12/min per seat). Raise network.expectedMessagesPerMinute to at least 30.`\n\nThat floor (`network.expectedMessagesPerMinute >= 30`) applies the moment *any* mode in the manifest\ndeclares `unavailable` — not per mode, since `network` is a manifest-level block. Omit `unavailable`\nentirely on a relay-backed online mode and it defaults to `{ policy: \"forfeit\", timeoutSeconds: 30\n}` — every online mode published before this field existed already behaves exactly this way, so\nnothing here is a breaking change for an existing game.\n\n### Levels or one continuous run?\n\n`progression` is required, and it is the one manifest field that decides what a **player is\ncharged**. There is deliberately no default: a default would let a game ship with a Resume price\nnobody chose, and silence is not a safe answer to a money question. `miniant-validate` fails a\nmanifest without it.\n\n- **`\"levels\"`** — your game has discrete levels or stages the player advances through and keeps. A\n Resume here is a new sitting for a game that already ended, so it starts a new session and costs\n the usual session fee. The picker leads with **Resume** and demotes **Start over** behind it,\n because nobody replays a level-50 campaign from level 1. Start over is not destructive: the\n portal banks the player's furthest run first and offers a way back to it, so you never have to\n warn them.\n- **`\"continuous\"`** — one unbroken run for as long as the player lasts, with no levels to return\n to (an endless merge or arcade game). A Resume here is **free**: the player is continuing the very\n game they already paid to start, and charging twice for one game would be wrong. The picker keeps\n **Resume** and **Start** as equals, because finishing a run and beginning another is the normal\n loop for this kind of game, not an afterthought.\n\nPick the one that describes how your game actually plays, not the one whose price you prefer — a\nmismatch shows up immediately as a player either paying to continue a single run, or continuing a\nfinished campaign for free.\n\n**If your game is `\"continuous\"`, clear or overwrite your save when a run ends.** The portal never\nclears a solo progression slot on your behalf, so a save left behind by a finished run makes Resume\noffer to continue a game that is already over.\n\n`orientation` decides how the portal's play screen frames your game:\n\n- **`\"any\"` (expected)** — your game gets the whole play area, edge to edge, on every screen:\n upright phone, sideways phone, 4:3 tablet, 16:9 desktop. Your game does the adapting — see\n \"Your game must fit every screen shape\" below.\n- **`\"portrait\"`** — full screen on a portrait-shaped viewport, but on every desktop and landscape\n tablet the portal shows your game in a centered 9:16 column with bars either side.\n- **`\"landscape\"`** — a centered 16:9 box with bars above and below. A phone or tablet held upright\n is not asked to rotate: the portal turns its own play screen a quarter turn, so your game still\n gets a landscape-shaped frame and the player simply turns the handset to meet it. (Most people\n have rotation lock on, and a \"please rotate\" prompt is a dead end for them.) **Nothing about this\n is visible to your game** — it receives an ordinary landscape viewport and must not try to detect\n or compensate for the rotation. A tall, narrow desktop *window* is the one case still asked to\n change, since there is no device to turn.\n\nThe validator warns on `\"portrait\"` and `\"landscape\"` (see the table above). The warning never\nblocks a build, because a few games really are one shape: a vertical shooting cage, a side-scrolling\nracer. If yours is one of them, keep the lock — the reviewer sees the warning at approval.\n\n### Your game must fit every screen shape\n\nDeclare `orientation: \"any\"` and build your layout to adapt. The portal hands you whatever shape the\nplayer's screen is, and every bar the portal would otherwise draw is dead space on a screen the\nplayer chose. Concretely:\n\n- **Background to all four edges.** Color, pattern, or art — not gameplay or UI — reaches every\n edge at every aspect ratio, from a tall phone (~9:19.5) to a wide desktop (~21:9). A solid color\n or a repeating pattern is the most reliable; a bespoke illustration has to survive being cropped\n or extended at both extremes.\n- **The playfield keeps its shape.** A board, table, or court scales *uniformly*, sized by the\n screen's **short side** minus what the HUD needs. Never stretch it to fill the long side.\n- **The HUD moves, it doesn't grow.** On a portrait screen, stack the HUD above and below the\n playfield as a phone game normally does. On a landscape screen (wider than about 5:4), move it\n into the spare space on the left and right, so the playfield can take nearly the full height.\n Keep HUD elements close to the playfield rather than pinned to the far screen edges, and keep text\n at phone size. One modest step up on large screens is fine; scaling text with the board is not.\n- **Seats stay by their pieces.** In a board game, put each player's panel on the side nearest\n their own pieces or colour: opponent beside their back rank, each Ludo player beside their\n corner.\n- **A short screen is still a screen.** A phone held sideways gives you ~390px of height. If a\n vertical stack of four player panels doesn't fit, arrange them two by two instead.\n- **Keep honoring the insets.** Read `context.ui.safeAreaInsets` and keep interactive controls clear\n of them in every layout, especially at the bottom, where the portal floats its chat dock.\n- **Re-measure on every layout change.** Anything you position from measured rects must be\n re-measured when the viewport changes shape, including the avatar anchors you send with\n `Miniant.ui.setAvatarAnchors` (see [SDK → Game](/guide/sdk/game)). A phone rotating mid-game is\n a normal event.\n- **Test at five sizes before you publish:** 390×780 (phone), 844×390 (phone sideways), 768×960\n (tablet upright), 1024×700 (tablet sideways), 1440×810 (desktop). Look for hard edges, a\n stretched playfield, unpainted space, and HUD overlapping the playfield or the insets.\n\n#### Republishing an existing `\"portrait\"` or `\"landscape\"` game\n\nNothing breaks today: a locked game keeps its bars and its build still publishes, with the\nvalidator's warning. At your next republish, make the layout adapt using the rules above, then\nswitch the manifest to `\"any\"`. Usually this is a CSS change: lift fixed `max-width` caps sized for\na phone column, add a landscape rule that moves the HUD beside the playfield, and size the\nplayfield from the short side.\n\n### Text selection and double-tap zoom are off inside your game\n\nSince SDK 2026.08.29 the portal installs two rules into your game's own document, so a tap inside\nyour game behaves like a tap anywhere else in Miniant: **nothing selects text, and a double tap\nnever zooms.** They arrive automatically — the browser SDK applies them when it loads, and the game\nhost injects the same stylesheet into every published HTML document, so a build that never calls the\nSDK (a Unity or prebuilt export) gets them too. There is nothing to add to your game.\n\nWhat this means for you:\n\n- **Your own `<input>`, `<textarea>`, and `contenteditable` fields still work normally.** They are\n exempt, because selection is how a player fixes a typo. A name-entry box behaves exactly as\n before.\n- **If some part of your game genuinely needs selectable text**, set `user-select: text` on that\n element. The portal's rule sits on `html`, so any rule of your own on a specific element wins.\n- **If your game does its own multi-touch or pinch handling, it is unaffected.** The rule only\n drops the browser's double-tap zoom; every touch event your code listens for still fires. A canvas\n that sets its own `touch-action` also keeps it — the portal never overrides an element-level rule.\n- **Pinch zoom itself is not blocked**, in your game or anywhere else. The portal caps it at 200%,\n which is what accessibility rules require, and that cap lives on the portal page, not in your\n document.\n\n## What you must not build\n\nThe one sentence that should settle any \"should my game do X\" question: **if it's not gameplay —\nrendering, rules, input — the portal already owns it, and you should not build your own.**\nConcretely:\n\n1. **No mode selector, friend list, matchmaking, lobby, or invite UI.** Read `context.mode` and\n `context.participants` from `init()` and start playing. A mode you didn't declare in\n `miniant.json` is never offered to a player in the first place — there's nothing for a\n game-side selector to add.\n2. **No store UI, no prices you invent, and no external payment SDK, ever.**\n `Miniant.wallet.purchase()` exists, but it only ever buys a manifest-declared, reviewed SKU at\n the portal's own registry price — your game supplies a `skuId`, never an amount, and the portal\n renders its own confirm dialog, never yours. There is still no call that lets your code grant\n currency or set a price. Wiring up your own Stripe/Razorpay/PayPal/Paddle/etc. checkout is a\n platform-policy violation, flagged automatically at build validation (see the \"No external\n payment-SDK signals\" check above) and enforced the same way as any other banned behavior.\n3. **No WebSocket (or any other transport) of your own for gameplay traffic**, unless you're\n declaring `authorityModel: \"developer_server_validates\"` (tier 2) and running your own\n validating server. Tier 0/1 games must use `Miniant.net` — the platform can only apply its\n rules-blind anti-cheat checks to traffic that actually passes through it.\n4. **Never navigate the top window, open the portal in a frame, or read the parent document.**\n The iframe sandbox blocks all of this structurally; attempting it is logged as a protocol\n violation, visible to Miniant staff.\n5. **Never report a result more than once per session.** Call `reportResult` exactly once;\n Miniant rejects a duplicate with `SESSION_ALREADY_SUBMITTED`.\n6. **No loading screen of your own — the portal's splash is the only one a player ever sees.**\n No loading bar, percentage, spinner, \"downloading assets\" message, or branded boot cover\n between the portal's splash retiring and your game being playable. See [No game-owned loading\n UI](#no-game-owned-loading-ui) below for the full rule and what to do if your game genuinely\n has a lot to load.\n\nIf your game needs something not covered here — leaderboards, tournaments, richer presence — it\nisn't available yet at any tier; ask before building a workaround.\n\n## No game-owned loading UI\n\nThe portal shows its own branded splash over your game from the moment the play shell mounts\nuntil you call `Miniant.ready()` (see [`Miniant.ready()`](/guide/sdk/game#miniantready)). That\nsplash is the **only** loading screen a player is meant to see. Never show your own — no loading\nbar, no percentage, no spinner, no \"downloading assets\" message, and no branded boot/splash cover\nof your own — anywhere between the portal's splash retiring and your first interactive screen\nappearing. Your game must hand off directly from the portal's splash to something the player can\nactually play.\n\n**Call `Miniant.ready()` at your first interactive screen, not your first frame.** A menu or a\nstart screen is fine to call it from; a loading indicator of any kind is not — see\n[`Miniant.ready()`](/guide/sdk/game#miniantready) for the full contract, including the ordering\nrule (hide your own cover, let a frame paint, *then* call `ready()`).\n\nThis is enforced at manual review, not by the automated validator — there's no reliable way to\ninspect a build archive and tell a boot cover apart from ordinary game art. The reviewer's smoke\ntest (see [Publishing](/guide/publishing#what-the-reviewer-sees)) already screenshots what a\nplayer sees while the portal waits on `Miniant.ready()`; a build whose screenshot shows a\ngame-owned loading bar, or whose console makes clear one flashed past before the game appeared,\nfails review on this even with every automated check green.\n\n**If your game has a large asset download, report progress with `Miniant.setLoadProgress()`**\nwhile it loads (see\n[`Miniant.setLoadProgress()`](/guide/sdk/game#miniantsetloadprogressfraction)) — this doesn't\nexcuse the rule above, it's what makes a long load survivable under it. Reporting real progress\nturns the portal's own splash into a determinate bar and pushes back its give-up failsafe (12\nseconds by default, up to a hard 30-second ceiling), so a game that's genuinely still downloading\nisn't abandoned mid-load. It's the only lever you have for a slow load — you still can't cover the\nwait with a loading screen of your own.\n\n## Not enforced today\n\nTo be direct about where the line actually is: the validator does **not** currently check any of\nthe following. These are things worth being mindful of for your own players' sake — guidance, not\nrequirements the platform enforces.\n\n- **No file-count limit.** There's no cap on how many individual files your build output\n contains.\n- **No initial-download-size rule.** Nothing measures or limits how much a player has to download\n before your game shows a first frame, beyond the overall 300MB archive cap above.\n- **No time-to-gameplay measurement.** Nothing times how long a player waits between landing on\n your game and being able to play. The reviewer's 30-second `Miniant.ready()` check (see\n [Publishing](/guide/publishing)) is about the manual review step, not an enforced player-facing\n limit. Calling [`Miniant.setLoadProgress()`](/guide/sdk/game#miniantsetloadprogressfraction)\n while you load pushes the splash's 12-second idle failsafe back out, up to that same hard\n 30-second ceiling, so a genuinely slow but honest load isn't the thing that costs you here — a\n loading screen of your own is (see\n [No game-owned loading UI](/guide/requirements/technical#no-game-owned-loading-ui) above).\n\nA game that's slow to start or ships an unnecessarily large download won't fail validation for it\n— but it's still a worse experience for your players, and a reviewer forming an impression of your\ngame during manual review. Keep an eye on it even though nothing here will stop you.\n"},{"slug":"requirements/gameplay","title":"Gameplay","section":"Requirements","agentSummary":"What a human reviewer checks by hand when playing your game: readability, refresh-rate consistency, controls, language, originality, and age-appropriateness.","checklist":[{"id":"req-gameplay.readable-content","text":"Make text and UI legible at normal device pixel ratio, on both desktop and mobile sizes.","verify":"manual"},{"id":"req-gameplay.consistent-refresh-rates","text":"Use delta-time or fixed-timestep simulation so gameplay behaves the same at 60Hz, 144Hz, and 165Hz — never tie physics to raw frame count.","verify":"manual"},{"id":"req-gameplay.english-required","text":"Ship English as a required language, and make sure any additional translation is accurate.","verify":"manual"},{"id":"req-gameplay.locale-for-language-choice","text":"Use player.locale from PlayContext to choose a language, falling back to English — never guess a language another way.","verify":"manual"},{"id":"req-gameplay.intuitive-controls-both-inputs","text":"Make controls intuitive on both desktop (mouse and keyboard) and mobile (touch), since the portal serves your game to both.","verify":"manual"},{"id":"req-gameplay.no-crashes-or-errors","text":"Load quickly and play without visible errors, hangs, or crashes.","verify":"manual"},{"id":"req-gameplay.original-content-only","text":"Use only art, music, and copy you own or have properly licensed.","verify":"manual"},{"id":"req-gameplay.no-own-fullscreen","text":"Don't build your own fullscreen button or call the Fullscreen API — the portal's play shell owns fullscreen.","verify":"manual"},{"id":"req-gameplay.no-cross-promotion","text":"Don't advertise other portals or drive players off-site as a primary call to action from inside the game.","verify":"manual"},{"id":"req-gameplay.age-appropriate","text":"Keep content suitable for a general audience of teenagers and up.","verify":"manual"}],"body":"# Gameplay\n\nThere is no automated gameplay check. A human reviewer plays your game and applies the\nexpectations on this page by hand. The bar is not \"perfect\" — it's \"no broken or\ninappropriate experiences.\" Reviewers are looking for reasons a normal player would bounce\noff your game or feel misled by it, not nitpicking polish.\n\n## Readable content\n\nText and UI must be legible at normal device pixel ratio, on both desktop and mobile sizes.\nIf a label, score, or instruction is only readable on a large monitor, it fails on a phone —\ncheck both before you submit.\n\n## Consistent behavior across refresh rates\n\nYour simulation must behave the same on a 60Hz display, a 144Hz display, and a 165Hz display.\nThe usual bug is tying physics or timing to raw frame count (`update()` called once per\n`requestAnimationFrame`) instead of elapsed time — that makes your game run faster on a\nhigh-refresh-rate monitor. Use a delta-time or fixed-timestep simulation loop instead.\n\n## Language\n\nEnglish is required. If you ship translations on top of it, they must be accurate — a\nmistranslated menu is worse than no translation.\n\nThe portal hands you `player.locale` inside `PlayContext` (see\n[Game](/guide/sdk/game)). Use it to pick a language for players who have one set, and\nfall back to English for everyone else — never guess a language another way.\n\n## Intuitive controls\n\nControls must be intuitive on both desktop and mobile — mouse and keyboard on desktop, touch on\nmobile — because the portal serves your game to both. A control scheme that only makes sense with\na keyboard will fail review on a touch device. There is no device-targeting field in the manifest:\n`orientation` (`\"any\"` — expected — `\"landscape\"`, `\"portrait\"`) is the only related declaration,\nand it does not exempt you from touch support. See [Technical → Your game must fit every screen\nshape](/guide/requirements/technical#your-game-must-fit-every-screen-shape).\n\n## Smooth performance\n\nThe game should load quickly and play without errors or crashes. A game that throws visible\nerrors, hangs on load, or crashes mid-session doesn't meet the bar, regardless of how good it\nlooks otherwise.\n\n## Originality\n\nNames, assets, and content must be your own or properly licensed. Don't ship someone else's\nart, music, or copy without the right to use it.\n\n## Fullscreen belongs to the portal\n\nThe play shell owns fullscreen. Don't build your own fullscreen button or call the\nFullscreen API yourself — it fights the portal's shell, which already decides when and how\nyour game goes fullscreen. See\n[`/guide/requirements/technical`](/guide/requirements/technical) for the full list of things\nthe portal owns that your game must not duplicate.\n\n## No cross-promotion\n\nDon't advertise other portals or drive players off-site from inside the game. A link to your\nown site or community is acceptable in a menu, but never as a primary call to action —\nplayers came here to play, not to be funneled elsewhere.\n\n## Age-appropriate\n\nContent must suit a general audience of teenagers and up. We don't have a formal\ncontent-rating field yet, so this is judged by the reviewer's own read of the content — when\nin doubt, tone it down.\n"},{"slug":"requirements/advertisement","title":"Advertisement","section":"Requirements","agentSummary":"What ad surfaces are and are not allowed: the portal's own ad layer only, no third-party SDKs, no banners, and rewarded ads never grant Tickets.","checklist":[{"id":"req-ads.no-banner-placements","text":"Don't build or reserve space for a banner ad placement inside your game.","verify":"manual"}],"body":"# Advertisement\n\nThe portal runs the ads — your game never does. There is one ad surface, and it lives in the\nportal's play shell, not in your iframe. Nothing interrupts a player on the way into a game: the\nportal shows its own interstitial only when a player chooses a rematch or waits for an online\nopponent, and offers its own opt-in rewarded ads (double a round's XP, waive an unaffordable\nin-game spend, spectate a friend's match). Your game may additionally *ask* for an interstitial at\na level end inside a session, and may optionally *ask* for a rewarded ad on its results screen.\nEvery one of those decisions is the portal's, made against its own frequency and first-session\nrules. See [Ads](/guide/sdk/video-ads) for the `Miniant.ads.*` API.\n\n## Third-party ad networks are not permitted\n\nDon't embed an external ad SDK or ad iframe in a game you submit. This applies to any\nnetwork — banner ads, interstitials, rewarded video, anything. A game that ships with one\nwill be rejected in review. The portal's own ad layer is the only one, and it is what the\nrevenue share (when enabled for your account) is computed from.\n\n## No banners, ever\n\nThere is no banner placement inside a game and none in the portal around it. Don't reserve\nspace for one.\n\n## What a rewarded ad can and cannot do\n\nThe portal offers a \"double this round's XP\" rewarded ad over your results screen on its own;\ncalling `Miniant.ads.requestRewarded()` yourself is optional and only moves that offer into your\nown UI. Either way it resolves `\"rewarded\"` when the player finished the ad, and doubling is the\nonly thing it does. It never grants Tickets to the player — Tickets come only from the portal's\nown flows, so a game cannot mint currency by showing ads.\n\n## Monetization beyond ads is the in-game purchase system\n\nTo sell things, declare SKUs in your manifest; players buy them through the portal's own\nwallet, and your game reads back what they own as entitlements. You never set a price, render\na store UI, or move currency directly — the portal does all of that.\n\nSee [In-game purchases](/guide/sdk/purchases) for the `Miniant.wallet.*` API and\n[`/guide/requirements/technical`](/guide/requirements/technical) for the manifest's\n`store.skus` block.\n"},{"slug":"requirements/account-integration","title":"Account integration","section":"Requirements","agentSummary":"How your game reads player identity and saved progress through the SDK: no login UI, save-at-checkpoint-start granularity, and progress never buys rank.","checklist":[{"id":"req-account.no-own-login","text":"Never build a login screen, ask who the player is, or add a third-party login inside your game.","verify":"manual"},{"id":"req-account.render-displayname-live","text":"Render player.displayName as given and re-read it from init() every session — never derive or persist your own copy.","verify":"manual"},{"id":"req-account.countryflag-no-inference","text":"Never infer a player's country yourself, including by IP geolocation — render countryFlag only if present, and treat null as no country shared.","verify":"manual"},{"id":"req-account.no-blocking-guests-from-wallet","text":"Never block a guest from playing because they don't have a wallet.","verify":"manual"},{"id":"req-account.no-custom-persistence-layer","text":"Never build your own persistence layer on top of localStorage to route around calling state.save() and state.load().","verify":"manual"},{"id":"req-account.save-load-on-start","text":"If capabilities.saveState is declared, call state.load() on every session start.","verify":"validator:save.load-on-start"},{"id":"req-account.save-at-checkpoint-start","text":"Snapshot progress, score, rewards and position at level or checkpoint start, never at exit. Only values that can work solely against the player (lives lost, a cooldown, a consumed item) save live.","verify":"manual"},{"id":"req-account.no-savestate-without-checkpoints","text":"Don't declare capabilities.saveState for a game with no level or checkpoint structure and no natural resume point.","verify":"manual"},{"id":"req-account.imported-progress-never-ranks","text":"Never let saved or imported progress confer Tickets, rank, leaderboard position, or tournament eligibility.","verify":"manual"}],"body":"# Account integration\n\nThe portal owns player identity. Your game never builds a login screen, never asks the\nplayer who they are, and never adds a third-party login inside the game — there is nothing\nfor a game-side identity flow to add, and one will read as broken or suspicious to players.\n\n## Where identity comes from\n\n`Miniant.init()` resolves with a `PlayContext` whose `player` field is:\n\n```ts\n{ id, displayName, avatarUrl, isGuest, locale, seat, countryFlag }\n```\n\nEvery entry in `participants` (the other seats in the session) carries the same shape. See\n[Game](/guide/sdk/game) for the full `PlayContext` reference.\n\n## `displayName`\n\nRender it as-is. Never derive a name yourself, and never persist it in your own saved state\n— players can change their display name at any time, so a copy you saved last session can go\nstale. Re-read it from `init()` every session instead.\n\n## `countryFlag`\n\nAn ISO 3166-1 alpha-2 country code, or `null`. `null` is not an error — it means the player\nhasn't shared a country. Render a flag only if your game already supports flag rendering, and\nnever infer a country another way, including IP geolocation. That's a consent decision that\nbelongs to the player, made in their portal profile.\n\n## `isGuest`\n\nTells you the player is unregistered. Use it to know which parts of the platform are\navailable to them — see \"Saved progress\" below — not to gate whether they can play at all.\n\n## Saved progress\n\n`Miniant.state.save(blob, meta?)` / `Miniant.state.load()` persist up to 64KB of JSON, gated on\n`capabilities.saveState` in your manifest — one slot per `(this player, your game, the session's\nmode)`, resolved automatically from the session; you never pass a mode yourself. For a signed-in\nplayer it's stored server-side and follows them to any device. For a guest it's stored locally, in\nthat one browser — see below, and [Data](/guide/sdk/data) for the full API, including the portal's\nown save-request push and exactly how loading behaves for each case.\n\n### Save granularity: checkpoint-start, not exit-time\n\n**This is a publishing requirement, checked at manual review — not a recommendation.** Games save\nat the **start** of a level or checkpoint, never at the moment the player happens to leave. A\nconformant `save()` call persists a snapshot taken when the level/checkpoint began: score, lives,\nand any other run-state in the blob must reflect that starting point, not the player's live\nmid-level progress. A player who quits partway through a level returns, next session, to the\n**beginning** of that level — anything gained since it started (points, resources, position) does\nnot carry over. This is deliberate anti-abuse, not an accident: it closes the exploit where a\nplayer grinds half a level for score, quits before anything can go wrong, and banks the gain\nanyway. See [Data](/guide/sdk/data) for exactly how to build this snapshot and what to pass as\n`meta.label`.\n\n**The one exception: values that can only work against the player.** Progress, score, rewards and\nposition snapshot at level START. Values that can only work against the player — lives lost, a\nlockout or cooldown timestamp, a consumed paid item — may be saved live, on every change. The test:\nif a player could gain from quitting and resuming at that moment, it must snapshot.\n\nA game with no level or checkpoint structure — a single continuous match with no natural resume\npoint, such as an offline board-game match — should not declare `saveState` at all. Ending the\nmatch when the player leaves is correct for that shape of game, not a shortcoming to work around.\n\nOnline multiplayer is unaffected: leaving a room-based match ends only the leaver's own session;\nwhat happens to their seat is governed by the mode's availability policy (`modes[].unavailable` —\nsee [Multiplayer requirements](/guide/requirements/multiplayer#when-a-player-becomes-unavailable)'s\n\"When a player becomes unavailable\" section), regardless of whether your game declares `saveState`.\n\nThe platform cannot enforce this in code — the saved blob is opaque JSON your game controls — so a\nreviewer checks it by hand during manual review: quitting mid-level and resuming should visibly\nreturn to the level's start, not the player's exact spot, with equal-or-fewer lives (or other\nagainst-the-player values) than the player had when they quit — never more.\n\n## The wallet is registered-only; saved progress is same-browser for a guest\n\nThe wallet has no guest fallback. Guests do not get a wallet at all — that's part of what converts\na guest into a registered player, and it's an intentional product decision, not a gap to work\naround.\n\nSaved progress used to work the same way. It no longer does: a guest's `state.save()` now persists\nto that browser, transparently, so a guest who closes the tab and comes back can resume — and the\nmoment they sign in, that browser's saved progress uploads onto the new account automatically. This\nis best-effort and entirely outside your game's control: a network hiccup just leaves the local copy\nin place to retry on the player's next sign-in, and if the account already holds a newer save for\nthat slot, the account's copy wins and the local one is cleared without overwriting it. What's still\nregistered-only is *carrying progress to another device before signing in* — a guest playing across\ntwo browsers has two independent local saves until one of them signs in.\n\nYour game must still degrade gracefully for a guest — but it doesn't need to know or care which\nbackend `state.save()`/`state.load()` are using underneath it:\n\n- Never block a guest from playing because they don't have a wallet.\n- **Never build your own persistence layer on top of `localStorage` (or anything else) to route\n around calling `state.save()`.** The portal's own slot — server-side for a signed-in player,\n same-browser for a guest — is the source of truth either way; a competing save system undermines\n the guest-conversion pitch and the rule below about what saved progress can and can't buy a\n player. Game-side `localStorage` is fine only as a short-lived cache (for example, keeping the\n last frame around so a mid-session reload feels instant) — never as where your actual saved\n progress lives.\n\n`Miniant.state.save()` resolves successfully for both a guest and a signed-in player — your game\nnever needs to branch on `isGuest` before calling it. `load()` returns whatever was last saved for\nthis player/game/mode in whichever backend is currently in effect, or `null` if nothing was.\n\n## Imported progress never buys rank\n\nWhatever your game persists through `state.save()` — level, checkpoints, unlocks — is progress, and\nonly ever progress. It never confers Tickets, rank, a leaderboard position, or tournament\neligibility, no matter what the save data claims or what state the player says they reached on any\nother platform. Those only ever come from a portal-witnessed play session reported through\n`reportResult()`/`reportProgress()` (see [Scores and results](/guide/sdk/scores)) — never from a\nsave blob, imported or otherwise.\n"},{"slug":"requirements/multiplayer","title":"Multiplayer","section":"Requirements","agentSummary":"Requirements for multiplayer and spectating games: modes, sync, and relay in miniant.json, the message-rate budget, the unavailable-player policy, and chat moderation.","checklist":[{"id":"req-mp.declare-modes-and-players-range","text":"Declare every supported mode in miniant.json's modes array with a players min-max range — an undeclared mode is never offered.","verify":"validator:manifest.mode-rules"},{"id":"req-mp.ai-declared-for-ai-backed-modes","text":"Declare AI — capabilities.ai true, or a vs_ai mode — if your manifest declares online_random or an unavailable policy of auto.","verify":"validator:manifest.mode-rules"},{"id":"req-mp.online-ladder-complete","text":"Consider declaring online_friends, online_random, and vs_ai together if your manifest declares any online mode — suggested, never required.","verify":"validator:modes.online-ladder"},{"id":"req-mp.turn-clock-and-auto-toggle","text":"Give every turn a deadline that auto-plays on expiry, plus an Auto toggle on the local player's own seat — theirs alone, cancelled by any tap. The unavailable policy bounds absence, not idleness.","verify":"manual"},{"id":"req-mp.hidden-match-real-pace","text":"Keep a hidden page's match at its real pace — every move still takes its think time plus its full animation. Never skip or shorten animation time off screen; spectators watch.","verify":"manual"},{"id":"req-mp.hidden-page-silent","text":"Make no sound while the page is hidden or the portal has paused it, and never resume a suspended AudioContext from inside a sound call.","verify":"manual"},{"id":"req-mp.teamup-declared-and-implemented","text":"Declare teamUp per mode only where your game actually seats two teams of two, and read mode.teamUp plus each participant's team before declaring it.","verify":"manual"},{"id":"req-mp.sync-requires-relay","text":"Declare capabilities.relay true, with a network block, for any online mode whose sync is turn_based or realtime.","verify":"validator:manifest.mode-rules"},{"id":"req-mp.parallel-requires-base-mode","text":"Declare a solo or vs_ai mode in the same manifest for any mode using sync parallel.","verify":"validator:manifest.mode-rules"},{"id":"req-mp.network-block-with-relay","text":"Declare a network block with expectedMessagesPerMinute and maxMessageBytes whenever capabilities.relay is true.","verify":"validator:manifest.mode-rules"},{"id":"req-mp.unavailable-requires-network-headroom","text":"Raise network.expectedMessagesPerMinute to at least 30 before declaring unavailable on any mode.","verify":"validator:manifest.mode-rules"},{"id":"req-mp.forfeit-win-flag","text":"Report a forfeit win with Miniant.reportResult({ outcome: \"win\", forfeit: true, durationMs }).","verify":"manual"},{"id":"req-mp.never-reveal-simulated-seat","text":"Never reveal a simulated, bot-filled online_random seat as AI to the player — no label or different visual treatment.","verify":"manual"},{"id":"req-mp.pass-and-play-no-relay","text":"Never use Miniant.net in local_multiplayer — context.room is null and there is no relay for Pass and Play.","verify":"manual"},{"id":"req-mp.broadcast-action-not-just-outcome","text":"Broadcast the acting player's move the moment the input lands, so opponents and spectators watch it happen — never only the committed outcome or end-of-turn snapshot.","verify":"manual"},{"id":"req-mp.declare-spectate-online","text":"Declare capabilities.spectate: true on every manifest — spectating is a core portal feature; the validator fails a build without it.","verify":"validator:manifest.spectate-required"},{"id":"req-mp.standin-for-roomless-spectate","text":"Declare capabilities.standin: true (and ship the standin/ Worker) when the manifest has capabilities.spectate and a vs_ai or online_random mode — the validator fails a build without it.","verify":"validator:standin.required"},{"id":"req-mp.tag-snapshot-true-for-spectators","text":"Tag every complete-state relay broadcast with {snapshot: true} — a joining spectator is served this cached copy directly, never by reaching your game code or via syncRequest.","verify":"manual"},{"id":"req-mp.pass-and-play-real-names","text":"Render each Pass and Play participant's real displayName in seat order, never a placeholder name you invent yourself.","verify":"manual"},{"id":"req-mp.player-is-the-account-not-a-seat-label","text":"Never overwrite a seat's name with context.player.displayName — player identifies the signed-in account, participants[].displayName labels the seats.","verify":"manual"},{"id":"req-mp.chat-responsibility","text":"If your game exposes chat, emotes, or player-to-player text, your game is responsible for moderating it — the platform only moderates reactively via reports.","verify":"manual"}],"body":"# Multiplayer\n\nThis page covers what's required of your game if it supports multiplayer or spectating — declaring\nthe right things in `miniant.json`, staying within the relay's message-rate budget, and being\nhonest with yourself about what the platform does (and doesn't) moderate. For the actual API\nsurface — `Miniant.net.*`, `Miniant.spectate.*`, the full `PlayContext` shape — see\n[SDK](/guide/sdk); this page stays at the requirements level and cross-links there rather than\nrepeating it.\n\n## Declare your modes and lobby sizes\n\nEvery mode your game supports must be declared in `miniant.json`'s `modes` array, each with a\n`players: [min, max]` range — this is your lobby-size declaration. A mode you don't declare is\nnever offered to a player, full stop. Valid mode ids are `solo`, `vs_ai`, `local_multiplayer`,\n`online_friends`, and `online_random`. The validator requires `min <= max` and both `>= 1` for\nevery mode. See [Technical](/guide/requirements/technical) for the full manifest schema.\n\n## `online_random` and `auto` need your game to have AI\n\nTwo declarations hand your game a seat that only your own AI can play, so both require you to say\nthe AI exists:\n\n- **`online_random`.** Its matchmaking queue doesn't fail when it finds nobody — it expires, and the\n portal seats an AI opponent so the player gets a game instead of a dead end. That fallback always\n happens; your game has to be able to play it.\n- **`unavailable: { \"policy\": \"auto\" }`.** \"Auto\" means your AI takes over a seat that has gone\n silent mid-match. Nothing on our side can play those turns for you.\n\nSay it either way — they mean the same thing to the validator:\n\n```jsonc\n// Either: declare a vs_ai mode, and players get a \"Play vs AI\" card in the picker.\n\"modes\": [\n { \"id\": \"online_friends\", \"players\": [2, 4] },\n { \"id\": \"online_random\", \"players\": [2, 4] },\n { \"id\": \"vs_ai\", \"players\": [2, 4] }\n]\n\n// Or: declare the capability alone, and no Vs AI card is ever shown. Use this when your game\n// has AI internally but you don't want to sell it as its own mode.\n\"modes\": [\n { \"id\": \"online_friends\", \"players\": [2, 4] },\n { \"id\": \"online_random\", \"players\": [2, 4] }\n],\n\"capabilities\": { \"relay\": true, \"wallet\": false, \"saveState\": false, \"ai\": true }\n```\n\nA `vs_ai` mode counts whether or not it's `enabled` — `enabled` controls what players are offered,\nand this is about whether the code exists. If you declare neither, the validator fails the build and\nnames which of the two declarations tripped it.\n\nNothing else needs AI. `online_friends` on its own is a room your invitees fill; declare it without\nAI freely.\n\n## The full online ladder is suggested, not required\n\nIf your `modes` array declares any online mode, we'll *suggest* declaring all three of\n`online_friends`, `online_random`, and `vs_ai` — a player who finds friend-play can then find\nmatchmaking and an AI game on the same title, rather than a menu that changes shape from game to\ngame. That's a suggestion and nothing more: an incomplete ladder shows up as a **warning** on your\nbuild and publishes anyway.\n\nThis used to be a hard rule, and it isn't any more. It was really enforcing the AI requirement above\n— `vs_ai` was the only way to prove a game had AI — and it charged a Vs AI card to every developer\nwho just wanted online play. `capabilities.ai` proves that directly now, so declare the modes you\nactually want:\n\n```jsonc\n// Fine — friend rooms only, no matchmaking, no AI needed. Warns about the ladder; publishes.\n\"modes\": [{ \"id\": \"online_friends\", \"players\": [2, 4] }]\n\n// Fine — online play with no Vs AI card, the AI declared where it's actually needed.\n\"modes\": [\n { \"id\": \"online_friends\", \"players\": [2, 4] },\n { \"id\": \"online_random\", \"players\": [2, 4] }\n],\n\"capabilities\": { \"relay\": true, \"wallet\": false, \"saveState\": false, \"ai\": true }\n```\n\n`local_multiplayer` (Pass & Play) was never part of the ladder and is unaffected. The suggestion\ntriggers on **declaring an online mode**, not on \"having any mode that seats 2+ players\": a\ncouch-only game that declares only `local_multiplayer` is never nagged about netcode just because\nmore than one person can sit at it. A solo-only game, or a game with only `solo`/`vs_ai`, is\nuntouched too.\n\nA relay-backed online mode (sync `\"turn_based\"`/`\"realtime\"`, the default) still needs\n`\"capabilities\": { \"relay\": true }` and a `\"network\"` block — see \"capabilities.relay and the\nnetwork block\" below.\n\n## Declare Team Up if your game has it\n\nA mode played as two teams of two must say so: `{\"id\": \"online_friends\", \"players\": [2, 4],\n\"teamUp\": true}`. Optional, defaults to `false`, and declared **per mode** — a game may run a\nfour-seat online match as teams while its local mode stays free-for-all.\n\nThe portal offers the Team Up toggle for exactly the modes that declare it. It used to infer teams\nfrom the seat count instead, which meant any mode reaching four seats grew a toggle whether or not\nthe game understood `mode.teamUp` — a four-player free-for-all game was offered teams it had no\nhandling for. Seat count says how many can play; it never says how they are grouped, and only your\ngame knows the difference.\n\nDeclaring it commits you to implementing it: read `mode.teamUp`, read each participant's `team`,\nand seat partners accordingly (see [Game](/guide/sdk/game#modeteamup--two-teams-of-two)). A declaration\nwithout that handling is worse than not declaring it, because players will be offered a mode your\ngame then ignores.\n\n## Choose a sync mode for an online mode\n\nEvery `online_friends`/`online_random` mode picks one of three ways to keep its seats in sync,\ndeclared per mode as `sync` ([manifest reference](/guide/requirements/technical)):\n\n| `sync` | What your game ships | What the portal provides |\n|---|---|---|\n| `\"parallel\"` | Zero netcode — your ordinary `solo`/`vs_ai` build, unmodified. | Runs each seat's session with the *same seed*, provides the lobby/invites/chat, and shows a scoreboard built from each player's own `reportResult` score once they report. |\n| `\"turn_based\"` | [`Miniant.createTurnKit`](/guide/sdk/turn-kit) — seat rotation, full-snapshot broadcast, and reconnect recovery, so your game only writes rules and rendering. | The relay (`capabilities.relay: true` plus a `network` block, same as any relay game — see below). |\n| `\"realtime\"` | Full custom netcode over [`Miniant.net`](/guide/sdk/multiplayer) — you own message shape, pacing, and reconciliation yourself. | The relay, same as `turn_based`. **This is the default** when a mode omits `sync` — every online mode published before this field existed already behaves this way. |\n\n`sync` is optional and per mode — declare it only where the default (`\"realtime\"`) isn't what you\nwant. Rules the validator checks:\n\n- **`turn_based`/`realtime` require `capabilities.relay: true`.** Declare the relay, or declare\n `sync: \"parallel\"` on that mode explicitly instead.\n- **`parallel` requires a `solo` or `vs_ai` mode in the same manifest** — that's the base mode a\n parallel room actually launches (`solo` is preferred when both exist, so a friend match doesn't\n share the table with your `vs_ai` bots for no reason).\n- **`sync` is meaningless outside `online_friends`/`online_random`** — declaring it on any other\n mode is a validation error.\n- **`teamUp` and `sync: \"parallel\"` don't mix.** A parallel room runs each seat's own independent\n `solo`/`vs_ai` session — there's nothing for teams to mean there. Declaring both is rejected when\n the room is created, not at publish time.\n\n**Parallel saves don't collide with your solo campaign.** The portal stores a `sync: \"parallel\"`\nmatch's save in its own slot, separate from the player's ordinary `solo` progression, automatically\n— your game does nothing differently for it.\n\n## Broadcast the action, not just the outcome\n\nWhatever sync mode you chose, the other seats must be able to *watch a move happen* — not deduce\nit from the board changing. The failure pattern is always the same: the acting player's client\nanimates the throw, flick, or kick locally, and the only thing that ever crosses the relay is the\nresolved outcome — a committed snapshot, a final velocity, a new score. On every other screen the\nmatch is a slideshow: nothing moves, then the result appears.\n\nTwo rules prevent it:\n\n- **Send the action the moment the input lands** — the flick vector as the finger lifts, the kick\n as the run-up starts, the piece as it's picked up — and have every other client animate or\n replay from that message. Never wait for your own animation or physics to finish before the\n first send: whatever time your local presentation takes, every watcher inherits it on top of\n network latency, and until then they see nothing at all.\n- **The authoritative outcome is a second, separate message** — in `turn_based`, the Turn Kit's\n own snapshot. Peers reconcile to it after their replay settles; it is the correctness backstop,\n never the first sign of motion.\n\nThis is fully compatible with the Turn Kit — the kit only claims its own frame kinds, and\n[Show the action live](/guide/sdk/turn-kit) documents the mechanics. It is also what\n`capabilities.spectate` quietly depends on: a spectator receives exactly the frames the seats\nexchange, so a game that only ever transmits outcomes gives its audience a scoreboard, not a\nmatch — see [Spectating](/guide/sdk/spectating).\n\n## `capabilities.relay` and the `network` block\n\nIf your game exchanges gameplay messages between seats over `Miniant.net` (a `turn_based` or\n`realtime` mode — see \"Choose a sync mode\" above), set\n`\"capabilities\": { \"relay\": true }` in your manifest — and when you do, you must also declare a\n`network` block:\n\n```jsonc\n{\n \"capabilities\": { \"relay\": true },\n \"network\": { \"expectedMessagesPerMinute\": 60, \"maxMessageBytes\": 2048 }\n}\n```\n\nThe validator rejects `relay: true` without a matching `network` block. `expectedMessagesPerMinute`\nisn't just documentation — it sets your game's actual rate ceiling. The relay enforces a **hard\ncap of 3× your declared rate**: sustained traffic above that ceiling gets you a `throttled` event\nfirst, then a disconnect on continued abuse. A room whose aggregate traffic sustains at or above\nthat same ceiling also feeds the platform's anti-cheat engine as a `RELAY_RATE` signal — declare a\nrealistic rate for your game rather than a padded one, since padding it just raises your own\ngame's ceiling for what the platform treats as suspicious.\n\nDeclare `expectedMessagesPerMinute` honestly and keep your traffic well under it in normal play.\nThe relay's cost envelope is why fast-action, high-frequency games are a poor fit for\n`Miniant.net` today — turn-based and low-rate games (chess, cards, quiz-style) are the target\nuntil platform capacity for higher-frequency traffic is available.\n\nYour own `{snapshot: true}`-tagged broadcasts (turn-change broadcasts, `checkpoint()` calls, and\nthe ones you send answering a reconnecting peer's `syncRequest` — see \"Spectating\" below) are real\nsends from your own seat, so budget them into your declared `expectedMessagesPerMinute` the same as\nany other message you send. Fan-out to spectators themselves is free and never counts against your\nbudget or feeds the `RELAY_RATE` signal.\n\n## When a player becomes unavailable\n\nA seat going quiet mid-match — screen off, the tab backgrounded, a network drop, or the player\nswitching apps — is normal, not an error state your game needs to treat specially. For a\nrelay-backed online mode (`turn_based`/`realtime` — see \"Choose a sync mode\" above), the portal\nresolves a per-mode **availability policy** and hands it to your game as `context.mode.unavailable`;\nyour game reacts to it (directly, or for free via [Turn Kit](/guide/sdk/turn-kit)). All four of\nthose triggers are the same event to the platform — there is no liveness signal from a seat for\n`timeoutSeconds`, full stop, whatever the underlying cause.\n\n**The portal resolves and sends this policy on every `turn_based`/`realtime` seat, whether or not\nyour manifest declares `unavailable`** — an undeclared mode still gets `context.mode.unavailable`\npopulated with the implicit default (`{ policy: \"forfeit\", timeoutSeconds: 30 }`, see below).\nImplementing the detection/reporting recipe (directly, or via Turn Kit) is **required** the moment\nyour manifest declares `unavailable` on the mode, and **recommended** otherwise: a game that never\nimplements it and never declares the field simply leaves an absent player's seat stalled until the\nother players give up and leave — the platform's own room sweep eventually closes an abandoned room\nregardless, so nothing hangs forever, but nobody sees the grace/auto-pilot/forfeit experience this\nsection describes.\n\n### The policy contract\n\nDeclared per online mode in `miniant.json`:\n\n```jsonc\n{ \"id\": \"online_friends\", \"players\": [2, 4], \"unavailable\": { \"policy\": \"auto\", \"timeoutSeconds\": 20 } }\n```\n\n- **`policy`** — `\"auto\"` or `\"forfeit\"`.\n- **`timeoutSeconds`** — how long a seat can go silent before the policy acts. Bounded `[10, 120]`\n at the schema level: short enough that a real match doesn't hang forever on a dropped peer, long\n enough to ride out a brief reconnect (a phone call, a tab backgrounding on mobile) without\n punishing it as an abandonment.\n\n**Omit it and a relay-backed online mode defaults to `{ policy: \"auto\", timeoutSeconds: 30 }`.**\n\nBeing unavailable is not the same as quitting. One is a condition — a dropped signal, a locked\nphone, a backgrounded tab — and the other is a decision, made by pressing Exit. Defaulting to\nforfeit collapsed the two, turning \"your connection dropped\" into \"you quit and lost\". **Nobody\nloses a match by being absent.**\n\nThis is why **auto is mandatory for online play**: a mode with no way to drive an absent seat has no\nanswer for one that isn't forfeiting a match the player never chose to leave. The validator enforces\nit — every relay-backed online mode resolves to `auto`, and `auto` needs something able to play the\nseat. Declare it with **`capabilities.auto: true`**.\n\n**The bar is lower than \"AI\".** What the platform needs is something that keeps a match moving when\na player goes quiet — for most turn-based games a legal-move picker is entirely enough. If your game\ncan take a turn on a player's behalf, you support auto mode, and that is all this is asking.\n\n`capabilities.ai: true` is the same claim under an older name; declare either. And **you never have\nto offer Vs AI as a mode** — Vs AI isn't relevant to every game, and nothing here asks you to add a\nmode you didn't want. (Declaring one does also prove it, since a game offering Vs AI plainly has\nwhat's needed, but it is never the requirement.)\n\n`forfeit` remains declarable for a game that genuinely wants it, but it is no longer what silence\nmeans. Declaring\n`unavailable` is meaningless — and a validation error — on a mode outside\n`online_friends`/`online_random`, and on any online mode whose resolved `sync` (see \"Choose a sync\nmode\" above) is `\"parallel\"`: a parallel seat runs its own independent session with no peers to\nmonitor in the first place, so there is nothing here to react to.\n\n### What each policy means for players\n\n- **`\"auto\"`** — the game's own AI takes the missing seat over once `timeoutSeconds` elapses with no\n activity, and the match keeps moving. If the player returns, handback is instant: their next\n move is theirs again, no re-join step. A seat under `\"auto\"` is never removed from the match —\n it can go quiet and come back any number of times.\n- **`\"forfeit\"`** — opt-in only, and rarely what you want. Once `timeoutSeconds` elapses, what\n happens depends on how many seats are still occupied: with exactly **2** seats, the remaining\n player wins outright and the match ends immediately. With **3 or 4** seats, only the silent seat\n retires — permanently removed from turn rotation, the match continuing among whoever's left.\n Reach for this only if losing by absence is genuinely correct for your game; for everything else\n `auto` is the default precisely because it isn't.\n\n**A stand-in's committed turn under `\"auto\"` is authoritative for every client, including the seat\nit stood in for.** Never gate turn-state application on local uncommitted state; cancel local play\nin [Turn Kit](/guide/sdk/turn-kit)'s `onTurnOverridden` instead (see its \"One committed history per\nturn\").\n\n### Every turn needs a clock, and every player needs an Auto button\n\nThe `unavailable` policy above bounds a player's **absence** — it fires when a seat stops sending\nanything at all. It does not bound a **turn**. A player who is connected, on the screen, and simply\nnot moving keeps their client beaconing, so the policy never fires and everyone else waits as long\nas that player likes. **Nothing in the platform stops this. Your game must.**\n\nTwo pieces, and you need both:\n\n**1. A per-turn deadline.** Carry the deadline in the state you broadcast and show the remaining\ntime on every client. When it expires, the turn holder's own client plays for itself and moves on;\nno other client ever decides that a turn expired. Thirty seconds is the number Ludo and Snake Ludo\nboth use.\n\nTwo ways to carry it, both fine:\n\n- **An absolute time** (`deadlineAt`, epoch ms), which every client reads against its own clock.\n Simple, but phone clocks are often seconds apart, so the countdown each player sees is off by that\n much.\n- **The time remaining** (`turnMsLeft`) when the state was sent, which each receiver turns into a\n local deadline on arrival. It ignores clock differences, at the cost of the network delay (a few\n hundred ms). A snapshot that is served later from a cache (to a spectator or a reconnecting\n player) shows too much time left; since only the holder decides expiry, that only affects the\n ring, never the match.\n\nEither way, the holder's own clock is the one that ends the turn.\n\n**2. An Auto toggle on the local player's own seat.** A small checkable control next to that\nplayer's avatar or their own controls — Snake Ludo puts it beside the die. It gives the player a way\nto say \"play for me\" before they step away, instead of discovering after the fact that the game did\nit for them.\n\nThe toggle's contract, as both Ludo titles implement it:\n\n- **Local player only.** Never rendered for another seat and never for a spectator — it is a control\n over your own play, not a status readout about someone else's.\n- **Two ways in:** the player taps it, or the turn deadline expires and the game turns it on itself.\n- **Two ways out:** the player taps it again, **or touches anything else on the screen**. Presence\n is the handback signal — someone who is interacting is plainly back, and making them find the\n chip again to resume control is friction with no purpose.\n- **Purely local state.** Auto is not broadcast and other clients are not told. They see the seat's\n moves arrive exactly as they always would; whether a person or your AI chose them is nobody\n else's business, and a game that labels an auto-driven seat differently on other screens is\n leaking something it shouldn't.\n\nHandback lands on a **turn boundary**, never mid-turn: a tap during an auto-driven turn takes effect\nfor the next one, and is swallowed rather than also being applied as a game input. Cancelling\nhalfway through leaves the turn half-played or, worse, played twice.\n\n### A hidden page plays the same match: same pace, no sound\n\nWhen the player switches tabs or minimises the browser, Auto takes their seat and the match goes on.\nOther players and spectators are still watching it. **They must not be able to tell.** Every move\ntakes the same think time and the same animation time it takes on screen, and the hidden page makes\nno sound.\n\nThe trap is the browser itself. A hidden page renders no frames and wakes its timers about once a\nsecond, so a turn that awaits many short animation steps (a die tumble, each square of a walk, a\nfade between turns) would crawl. The tempting fix is to resolve those waits at once while hidden.\n**Don't.** The match then runs at fast-forward for every spectator, and a watcher who sees a match\nspeed up stops trusting it.\n\nDo this instead:\n\n- **Animation time is match time.** While hidden, skip the drawing but keep the duration. Don't\n await `requestAnimationFrame`, `transitionend` or an animation's `finished` promise: a hidden page\n never fires them. Wait the animation's length on a timer instead.\n- **Batch the short waits.** Add the owed animation time up and pay it with one timer once a\n second's worth has built up. The turn then takes its real length, within about a second, however\n hard the browser throttles.\n- **Publish when the move is done.** Send each move to spectators after its (paced) animation, so\n moves reach them at the pace they would see on screen.\n- **Physics steps by wall time.** If your physics runs in the frame loop, step it from a timer by\n real elapsed time while hidden, never faster.\n- **No sound.** Stop effects and music when the page is hidden or the portal sends `pause`. A sound\n helper must never resume a suspended `AudioContext`; only a visible, unpaused page may.\n- **After the phone sleeps, adopt, don't replay.** A suspended page runs nothing. Your stand-in (for\n `vs_ai` and AI-filled `online_random`) or the other players (in a room) played that time. On wake,\n adopt the board you are handed and carry on at the normal pace; never replay the missed time in a\n burst.\n\n**Check it in a real minimised window**, not by faking `document.hidden`, which doesn't throttle\ntimers. With Auto on, time the moves your game publishes while visible and then while minimised. The\nhidden gaps should match the visible ones, and the page should play no sound.\n\n### The network-headroom rule\n\nDeclaring `unavailable` on a mode requires `network.expectedMessagesPerMinute >= 30`. This isn't an\narbitrary floor — it's the validator's own reasoning: the disconnect-liveness heartbeat that lets a\nrelay-backed match detect \"a peer went unavailable\" in the first place rides on top of whatever\ntraffic budget the mode already declared, and needs headroom above it — the platform's relay limits\nare 3× the declared `expectedMessagesPerMinute`, and the heartbeat's own beacons cost roughly 12/min\nper seat, so a mode with no headroom to spare starves either the heartbeat or the game's own traffic\nthe moment a peer actually goes quiet. Raising `expectedMessagesPerMinute` to clear this floor is\noften the only manifest change a game needs to adopt `unavailable` at all — see the republishing\nappendix below for three worked examples.\n\n### Reporting the result\n\n- **A forfeit win** is reported by the surviving client as any other win, with one extra flag:\n `Miniant.reportResult({ outcome: \"win\", forfeit: true, durationMs })`. The flag carries no\n XP/rank consequence of its own — the platform's ordinary win-outcome rules already apply, and the\n match is **fully ranked**. It exists purely as an audit trail, so integrity review can query\n forfeit-win rates separately from an ordinary contested win.\n- **An `\"auto\"`-completed match** reports normally: every seat that's still connected at the end\n calls `reportResult()` from its own perspective, exactly as it would for a match nobody ever went\n quiet in (see [Scores and results](/guide/sdk/scores)'s \"Reporting results in an online session\").\n It, too, is fully ranked.\n- **The quitter's own result is a forfeit loss, not `\"abandoned\"`.** A player who explicitly Leaves\n or Exits reports `Miniant.reportResult({ outcome: \"loss\", forfeit: true, durationMs })` for their\n own seat (see [Scores and results](/guide/sdk/scores#reporting-results-is-mandatory)) — the match\n continues for the remaining seats, and the room only closes once no players are left. Quitting a\n match you're losing must not erase the loss: a forfeit win needs a matching forfeit loss, or the\n ranked data carries a win nobody lost. If the game reports nothing, the portal records the forfeit\n loss itself on the confirmed exit, so this always lands correctly either way.\n- **A player who goes unavailable *silently* — never Leaves, never Exits — and never returns**\n simply never reports; there is no client left to call `reportResult()` on their behalf. Their\n session sits `\"abandoned\"`, the one case that outcome still applies to a player's own session.\n This is a natural consequence of the existing per-seat reporting model, not special-cased\n handling for the availability policy.\n\n### Explicit Leave is the same policy event\n\nThe portal's own Leave control (mid-match) doesn't bypass this machinery — it drives it. Leaving\nends only the leaver's own session; if at least one other seat is still active, the room stays live\nand the remaining seats are notified the same way a disconnect notifies them (`peerLeft` on\n`Miniant.net`, or the Turn Kit's own availability callbacks). The one difference from a plain\ndisconnect: a plain network drop is only detected once `timeoutSeconds` of silence has passed, while\nLeave is unambiguous the instant it happens, so it skips that detection wait and puts the seat into\nthe grace state immediately. Survivors still see the same full `timeoutSeconds` grace countdown\nbefore the policy actually acts — Leave does not shorten *that* window, only the wait before it\nstarts. Only when a leave empties the room of every other active seat does the room close outright,\nsame as it always has.\n\nSee [Turn Kit](/guide/sdk/turn-kit) for the machinery a `turn_based` mode gets automatically, and\n[Multiplayer](/guide/sdk/multiplayer) for the tier-3 recipe a hand-rolled `realtime` game implements\nitself — both react to `context.mode.unavailable`, and both call\n`Miniant.reportPeerAvailability()` so the portal's own grace/auto-pilot/forfeit banner chrome can\nrender without your game building any of it.\n\n## Spectating\n\nDeclare `\"capabilities\": { \"spectate\": true }` to let a player's friend watch one of their in-play\nmatches, read-only, without joining — in any mode, not just room-based ones.\n\n**Spectate is required, not optional — every manifest must declare it.** The validator fails a\nbuild whose manifest omits `capabilities.spectate: true` or sets it `false`, because the portal's\ncompetitive surfaces are built on it: friends watch live matches, tournament matches are broadcast\nfree to the public, and the Live hub can only feature games that spectate — the spectate endpoint\nrefuses any game without the declaration. Declaring is the enforceable half; *implementing* the\nwatchable stream is yours and is checked at review, not by the validator. The cost is small where\nit matters most: a `turn_based` mode on the Turn Kit already tags its snapshots `{snapshot: true}`,\nwhich the relay caches and serves a joining spectator directly — no `syncRequest` round-trip, and no\ncode the game has to write for that case — so what's left is the read-only rendering rules in\n[Spectating](/guide/sdk/spectating) — and the same action frames you broadcast for\n[the live-view rule above](#broadcast-the-action-not-just-the-outcome) are what make the stream\nworth watching. A `solo`/`vs_ai`-only game complies by calling\n`Miniant.spectate.publishState(snapshot)` once as soon as the first board is on screen and again on\nevery meaningful change (`Miniant.spectate.publishAction()` for live motion in between) — that\npublish path is part of the requirement, implemented in the same change that ships the game, never\ndeferred. The Watch button appears as soon as the portal has observed a publish, independent of\nwhether the manifest also declares `capabilities.relay` for an unrelated online mode.\n\n**A spectatable game with a roomless mode must also ship a stand-in.** If the manifest declares\n`capabilities.spectate: true` and a `vs_ai` or `online_random` mode, it must declare\n`capabilities.standin: true` (`standin.required`, a validator error). Those matches have no game\nrelay connection of their own: the portal holds each board you publish in a server room for the\nsession, and the stand-in is the small server Worker that plays the match from that board while\nthe player's phone sleeps or the player is gone, so the match never freezes for anyone. See\n[Stand-in](/guide/sdk/spectating#stand-in-keep-a-roomless-match-playing-while-the-phone-sleeps).\nEvery mode except `solo` is a server room, `vs_ai` included, so an `online_friends` room whose\nplayers are all away is played by the stand-in too; the validator rule above covers only\n`vs_ai` and `online_random` today. The portal also lists a game in its live sections only when it\ndeclares both `spectate` and `standin`.\n\n- **A spectator's connection is receive-only by construction, not by convention your code has to\n enforce.** Any `send()`/`sendTo()` call from a spectator's connection is silently dropped\n platform-side. You don't have to build read-only enforcement yourself. Live action frames must\n always be sent as broadcasts, never `sendTo` — a spectator is never the target of a targeted send.\n- **A spectator joining a room never touches the match.** It is served the relay's cached\n `{snapshot: true}`-tagged broadcast directly, with no frame reaching any seat's game code — see\n [Spectating](/guide/sdk/spectating#snapshottrue-and-catching-up-a-spectator-mid-match). A GAME seat\n itself reconnecting into a room with another live peer is a separate case and still triggers\n `syncRequest`, unaffected by this.\n- **Every spectate visit gets an anchor seat.** `context.spectateAnchorSeat` (present only\n alongside `context.spectator === true`) names which participant's perspective to render from —\n the same seat-rotation/orientation logic you already apply for \"this seat is mine,\" substituting\n the anchor's seat for your own.\n- **`capabilities.spectate` mirrors your manifest declaration in `context.capabilities`.** If you\n didn't declare it, it's `undefined` there too — the same additive, back-compat pattern used\n elsewhere in the play context.\n- **A spectate visit never starts a local match, never runs your AI, and is never offered\n `local_multiplayer`** — a watcher only ever renders what it's handed.\n- Full mechanics — the two delivery paths (room-based via `{snapshot: true}`-tagged relay\n broadcasts and `checkpoint()`, vs. roomless via `Miniant.spectate.publishState()`/`publishAction()`\n and `createSpectatorKit`), the `syncRequest` resync flow (reconnect only), and the 16KB payload\n cap — are documented in [Spectating](/guide/sdk/spectating).\n\n## Online modes and simulated players\n\nThe available online modes are `online_friends` and `online_random`. In `online_random`,\nmatchmaking may fill a seat with a portal-owned simulated identity if it can't find a human\nopponent in time — that participant carries `simulated: true` in `context.participants`, and your\ngame must drive that seat with its own local AI logic while presenting it to the player exactly\nlike any other remote participant (same `id`/`displayName`/`avatarUrl`/`countryFlag` fields you\nwere given). **Never reveal that seat as AI** — no \"playing vs. bot\" label, no different visual\ntreatment. `simulated` is omitted for every real participant; treat its absence as `false`.\n\n## Pass & Play (`local_multiplayer`)\n\nEveryone plays on one device, taking turns. Structurally this is closer to `vs_ai` than to the\nonline modes: `context.room` is `null`, there is no relay, and you must not use `Miniant.net`. If\nyou already support a variable-size `vs_ai` game, this is that minus the AI.\n\nFive things differ from every other mode:\n\n**Every seat is a human.** `mode.playerCount` is the total number of *people*, not \"you plus some\nopponents to fill in\" — at `4` you set up four human-controlled seats and fill none of them\nyourself. Every entry in `context.participants` carries `isLocal: true`, because they all are.\n\n**The names are real, and you are expected to show them.** Before the game starts, the portal asks\nthe players what they're called and hands you the result as each participant's `displayName`, in\nseat order. These are not placeholders: somebody typed them. Render them wherever you'd normally\nshow a player's name, so a turn prompt reads \"Rafi's turn\" rather than \"Player 2's turn\". A player\nwho leaves their field blank arrives as `Player 1`/`Player 2`/… so the field is always populated —\nyou never need a fallback of your own.\n\nOne trap worth naming explicitly: `context.player` is still present in this mode, still carries\nthe signed-in account's own profile `displayName`, and its `seat` number collides with one of\nthese participants. It identifies the *account* running the session — who pays the session fee,\nwhose save this is, whose purchases work — not a seat label, and the person holding the account\nisn't necessarily one of the people playing. For anything drawn on the board — rosters, turn\nprompts, results — `participants[].displayName` is the only source. Never merge or overlay\n`player.displayName` onto the seat whose number matches `player.seat`: doing so silently replaces\na name somebody just typed into the portal's \"Who's playing?\" form with the account's profile\nname.\n\n**Teams come from `team`, never from seat numbers.** When `mode.teamUp` is set, each participant\ncarries `team: 0 | 1`. Read `mode.teamUp` rather than `room.teamUp` — `room` is `null` here, so a\ngame checking only the room would see nothing. Who partners whom is the portal's to decide; where\nthose partners *sit* is yours, so keep whatever arrangement your board uses.\n\n**Nobody is signed in as anyone else.** These are labels for people sharing a couch, not accounts:\nthere is no friend graph, no profile, no avatar, and `avatarUrl`/`countryFlag` are `null` for every\nseat. Don't build anything that assumes otherwise.\n\n**A session is never recorded (2026-09-17).** No XP, no leaderboard or stats entry, no win/loss\nstreak, no share or challenge card — there's no real per-seat identity on a shared device to record\none against, so a `local_multiplayer` session touches none of a player's record. It still costs\nTickets — 3, the same discounted rate as a roomless `vs_ai` start, priced below the normal session\nfee because it's friends playing together for fun and never enters a record (Kais, 2026-09-25) —\nand your game should still call `Miniant.reportResult()` as usual (see [Scores and results](/guide/sdk/scores)) — the portal just never publishes what it says.\n\nDeclare it like any other mode — `{\"id\": \"local_multiplayer\", \"players\": [2, 4]}` — and remember an\nundeclared mode is never offered, so nothing appears for players until you ship support for it.\n\n## Chat and player-generated content\n\nBe honest with yourself about this one: **relay chat and emotes carry no automated profanity\nfiltering and no proactive moderation today.** Moderation is reactive only, through player\nreports. If your game exposes any form of chat, emote, or other player-to-player text, your game\nis responsible for what it lets players send — there is no platform-side filter standing between\na player and what they type. Design accordingly (rate-limit it yourself, restrict it to preset\nmessages, or accept the reactive-only moderation model) rather than assuming the platform screens\ncontent for you.\n\n## Appendix: republishing an existing relay game for the availability policy\n\nIf your game already shipped a relay-backed online mode before \"When a player becomes unavailable\"\nabove existed, it's already running the implicit default (`{ policy: \"forfeit\", timeoutSeconds: 30\n}`) — nothing breaks. Adopting an explicit policy is a small, mechanical change. Three worked\nexamples, from games actually on the platform, cover the two shapes you'll run into:\n\n**A `realtime` (hand-rolled `Miniant.net`) game with its own auto-move already built.** Add\n`unavailable` to both online modes:\n\n```jsonc\n{ \"id\": \"online_friends\", \"players\": [2, 4], \"unavailable\": { \"policy\": \"auto\", \"timeoutSeconds\": 20 } },\n{ \"id\": \"online_random\", \"players\": [2, 4], \"unavailable\": { \"policy\": \"auto\", \"timeoutSeconds\": 20 } }\n```\n\nthen implement the tier-3 recipe in [Multiplayer](/guide/sdk/multiplayer#absent-players) — the 5s\nliveness beacon, away/back on visibility transitions, per-seat last-activity clocks, and calling\n`Miniant.reportPeerAvailability()` at every transition — wiring it to the auto-move logic the game\nalready has rather than building new fallback-move code. `expectedMessagesPerMinute: 60` already\nclears the network-headroom floor (`>= 30`), so no `network` change is needed here. (If your game\nalso renders its own in-room \"Play again\" UI on top of the platform's game-over-screen contract,\nreplace it with `Miniant.requestRematch()` — that's a separate, already-required change, not part of\nthis policy.)\n\n**A `realtime` game with a slower declared rate.** Same shape as above, but check the headroom rule\nfirst: at `expectedMessagesPerMinute: 40` a game already clears the `>= 30` floor with margin, so\n`unavailable: { \"policy\": \"auto\", \"timeoutSeconds\": 30 }` on both online modes is a manifest-only\nchange alongside the same beacon/`reportPeerAvailability` wiring — no `network` edit required.\n\n**A [Turn Kit](/guide/sdk/turn-kit) game with its own AI already built (e.g. a minimax player).**\n`\"forfeit\"` is available, but `\"auto\"` is the better fit whenever the game already has an AI that\ncan stand in instantly — a fast, roughly-instant game makes a forfeit ending feel abrupt for what\nmight have been one missed beat. Add:\n\n```jsonc\n{ \"id\": \"online_friends\", \"players\": [2, 2], \"sync\": \"turn_based\", \"unavailable\": { \"policy\": \"auto\", \"timeoutSeconds\": 30 } },\n{ \"id\": \"online_random\", \"players\": [2, 2], \"sync\": \"turn_based\", \"unavailable\": { \"policy\": \"auto\", \"timeoutSeconds\": 30 } }\n```\n\npass `unavailable: context.mode.unavailable` into `createTurnKit()`, and implement `onAutoTurn(seat)`\nby computing that seat's move with the existing AI and calling `endTurnAs(seat, patch?)`; implement\n`onTurnOverridden` to cancel any local in-flight play when a stand-in's turn takes over or is itself\nreplaced (see \"What each policy means for players\" above), and `onSeatAutoDriven(seat, bySeat)` to\nclose any stale \"your turn\" UI once you learn you were driven while away. At\n`expectedMessagesPerMinute: 30` this game satisfies the headroom floor exactly (the\nrule is `>= 30`, not `> 30`) — nothing to raise today, but there's no margin left, so a future\nincrease in the game's own traffic should raise `expectedMessagesPerMinute` to `40` or so at the\nsame time, before the validator has reason to reject it.\n\nIn every case: republish through the normal pipeline once the manifest and code changes are made —\ndeclaring `unavailable` alone, with no supporting code, still validates and publishes (the platform\ncan't verify game logic), but leaves players staring at a seat that never actually auto-plays or\nretires.\n"},{"slug":"requirements/game-covers","title":"Game covers","section":"Requirements","agentSummary":"Cover art for a game's listing: the five image slots plus preview video, which carry a logo versus stay clean, focal points, the live device preview, and upload limits.","checklist":[{"id":"req-covers.square-and-wide-required","text":"Upload all five image slots — 1:1 square (800×800 or larger), 16:9 without logo and 16:9 with logo (1920×1080), 9:16 without logo and 9:16 with logo (1080×1920). None of them is optional.","verify":"manual"},{"id":"req-covers.no-borders-or-promo-text","text":"Don't add borders or promotional text such as \"New\" or \"Play Now\" to any slot — the game's title/logo is the only text allowed, and only on 1:1 square, 16:9 with logo and 9:16 with logo.","verify":"manual"},{"id":"req-covers.no-copyrighted-visuals","text":"Don't use copyrighted visuals you don't have the right to use in cover art.","verify":"manual"},{"id":"req-covers.not-blurry","text":"Don't submit blurry, pixelated, or stretched cover art — each slot is cropped, never stretched, so supply the native aspect ratio.","verify":"manual"},{"id":"req-covers.set-focal-points","text":"Set a focal point on every uploaded slot in the Store Listing tab so crops keep the subject — the default is dead centre, which is usually wrong for an off-centre subject.","verify":"manual"},{"id":"req-covers.preview-video-limits","text":"Keep the preview video to at most 10 seconds and 8MB, no sound, no fast-forwarding, opening on your 1:1 square cover image as the first frame.","verify":"manual"},{"id":"req-covers.uploader-limits","text":"Stay within the enforced upload limits — 1:1 square 3MB; both 16:9 slots and both 9:16 slots 4MB each; video 8MB and 10 seconds — or the upload is rejected before review.","verify":"manual"},{"id":"req-covers.hero-slots-clean","text":"16:9 without logo and 9:16 without logo uploads carry no baked logo, title, or text — the site draws the title on top of them.","verify":"manual"},{"id":"req-covers.wide-logo-safe-zone","text":"16:9 with logo art carries the game's logo, kept clear of the bottom 28% (where Trending writes the title and Play button) and the top corners (badges).","verify":"manual"},{"id":"req-covers.device-preview-checked","text":"All three device tabs — Desktop, Tablet, Mobile — in the art panel's \"What players see\" preview were checked before submitting.","verify":"manual"}],"body":"# Game covers\n\n## The rule in one sentence\n\n**Surfaces where the site writes the game's title get art without a logo. Card surfaces that identify\nthemselves get art with a logo.** Everything below is that one rule applied to five slots: three of\nthem (1:1 square, 16:9 with logo, 9:16 with logo) are yours to brand, two of them (16:9 without\nlogo, 9:16 without logo) the site brands for you, and putting a logo on the wrong one is the single\nmost common mistake in review. Each orientation has a pair — 16:9 without logo and 16:9 with logo,\n9:16 without logo and 9:16 with logo — the same picture without and with the logo.\n\nThe without-logo pair feeds exactly three places: the home hero carousel, the game page's hero, and\nthe play screen's banner. Everything else is a card.\n\n## The five slots\n\nYour game's art is stored in named **slots** — named for the shape they hold and whether they carry\nyour logo, not for where they appear. All five are required. Slots that share a surface should still\nshare consistent visuals — same subject, palette, and treatment — so your game reads as one\nrecognizable thing everywhere it's shown.\n\n| Slot | Aspect ratio | Recommended size | Required | Logo or text baked in? | Feeds |\n|---|---|---|---|---|---|\n| 1:1 square | 1:1 | 800×800 (1024×1024 is fine) | **Yes** | Logo OK — centred, inside the inscribed circle; keep the bottom 30% and the top corners clear, the site draws a title gradient and badges there. | Every game tile and circle mark. |\n| 16:9 without logo | 16:9 | 1920×1080 | **Yes** | **No** — no logo, no text. The home hero writes the title dead-centre; the game page and play screen write it over the left and the bottom. | The home hero on laptops, landscape tablets, and ultrawides; the game-page hero on tablets and desktops; the play screen's banner on every device. The play banner is a 4:1 strip that keeps only the middle 44% of the height, so keep the subject clear of the top and bottom 28%. Leave it empty and those heroes show no art. |\n| 16:9 with logo | 16:9 | 1920×1080 | **Yes** | **Yes — this is the logo card.** Keep the logo out of the bottom 28% (Trending writes the title and Play button there) and the top corners (badges). | Trending cards, 4:3 and 2:1 cards, the share card. |\n| 9:16 without logo | 9:16 | 1080×1920 | **Yes** | **No** — no logo, no text. The hero title lands between 33% and 65% of the height. | The home hero on phones and upright tablets, and the game-page hero on phones. The phone game page keeps only the middle ~60% of the height, so keep the subject below the top 20% and set the focal point on it; keep the bottom half calm, the CTA and dots float there. |\n| 9:16 with logo | 9:16 | 1080×1920 | **Yes** | **Yes — the logo card for tall frames.** The tile draws no title, scrim, or badge over it; keep the logo inside the middle 89% of the width (the 1:2 tile trims ~5.6% per side). | The tall tiles in All Games. Leave it empty and those tiles use 9:16 without logo with the site's title on top. |\n| Preview video | any | 1080p, ≤10s, ≤8MB, no sound | No | — | Plays in place of the square image on hover. Details under [Preview video](#preview-video). |\n\nThere is no 8:3 slot. The wide heroes on the game page, the play screen, and ultrawide screens\nare all cut from 16:9 without logo.\n\n## Frame templates\n\nOne template per slot, measured on the live site. The zones are cumulative across every surface\nthat consumes the slot — art composed inside its template looks right everywhere without\nper-surface edits.\n\nHow to read them:\n\n- **Green — focus core.** Visible on every surface; put the subject here.\n- **Red — site UI writes here.** Nothing that must be read or seen whole.\n- **Amber dashes — crop lines.** Everything outside is lost on the labeled surface.\n- **Purple dashes — logo zone.** The only place baked text or a logo is allowed (only 16:9 with\n logo and 1:1 square have one).\n\n### 16:9 without logo\n\n\n\nThe home hero writes the title dead-centre on this image — keep the exact middle calm and let\nthe subject live around it. The game page and play screen crop it much wider and shorter, so\nkeep the subject inside the middle band of the height and set the focal point on it. Leave it\nempty and the laptop hero, the desktop game page, and the play banner show no art for your game.\n\n### 16:9 with logo\n\n\n\nThe logo belongs here, big and central if you like — clear of the bottom 28% and the top corners.\n\n### 9:16 without logo\n\n\n\nSubject in the top third; the title lands across the middle; the lower half is scenery.\n\n### 1:1 square\n\n\n\nA centred logo works — the tile is the game's icon. Keep the bottom 30% and the top corners\nclean, and stay inside the inscribed circle.\n\n## Where each image is shown\n\nThe portal renders art in many shapes across desktop, tablet, and mobile, and every one of\nthem draws from **exactly one slot** — the slot it was designed to be cut from. If that slot\nhas no upload, the surface shows **no art** for your game (its own gradients and title still\nrender, but the frame is empty). A square is never stretched into a hero, and a hero image is\nnever squeezed into a tile — a wrong-ratio crop looks broken, so the portal doesn't do it.\n\nThere is exactly one stand-in, the same shape as the slot it covers: an empty **9:16 with logo**\nborrows **9:16 without logo** (and the tile then writes your title on top, since 9:16 without\nlogo has none).\n\nThe allocation: tiles and circle marks cut from **1:1 square**; Trending and the other cards (4:3,\n2:1, the share card) cut from **16:9 with logo**; the tall tiles cut from **9:16 with logo**; the\nphone home hero and phone game page cut from **9:16 without logo**; the laptop and ultrawide home\nhero, the tablet and desktop game page, and the play banner cut from **16:9 without logo**. Every\nslot is required, so with all five uploaded no frame is ever empty.\n\n## Checking your art before you submit\n\nThe Store Listing tab's art panel has one preview surface: **\"What players see.\"** It renders\neach upload inside realistic mockups of the actual site — nav bar, title overlays, gradients —\nat the true proportions those surfaces use, switchable across three device tabs: **Desktop,\nTablet, Mobile.** A tablet held upright takes a different image than the same tablet on its\nside, and a mockup is the only way to catch a logo sitting under where the home hero's title\nwill land before a reviewer catches it for you.\n\nCheck all three device tabs before submitting. The preview also shows exactly which frames\nwill be **empty** for players — each frame renders only its allocated slot, so a missing\nupload shows as a labelled hole here rather than a surprise on the live site.\n\n## Focal points\n\nEvery uploaded slot carries a **focal point** — the spot in that image the crop is anchored\nto, as a percentage from the top-left. Set it in the Store Listing tab by clicking the subject\nof each image; the live preview updates immediately so you can confirm the crop before you\nsubmit. A slot with no focal point crops from the centre, which is usually wrong for a subject\nthat isn't already centred — 16:9 without logo and 9:16 without logo are the two that most often\nneed a nudge, since their crops are the tallest and widest relative to a typical square source.\n\n## Restrictions\n\n- No borders.\n- No promotional text — no \"New\", \"Updated\", \"Play Now\", or similar copy on any slot.\n- No logo or title art on 16:9 without logo or 9:16 without logo. The site draws the title on\n these two; baking your own in produces two overlapping titles in production.\n- The game's logo belongs on 16:9 with logo and 9:16 with logo, and optionally on 1:1 square.\n Nowhere else.\n- No icons or store logos.\n- No copyrighted visuals you don't have the right to use.\n- Nothing blurry, pixelated, or stretched. Supply each slot at its native aspect ratio; the\n uploader does not resize or pad.\n\n## Craft guidance\n\n- Don't just screenshot the game — players can already preview gameplay elsewhere; the\n cover's job is to sell the game at a glance, not to document it.\n- Keep it simple and readable at small sizes — covers get shown small far more often than\n large, and the square slot is shown smallest of all.\n- Put the game's logo on the 16:9 with logo image, and keep it clear of the bottom 28% and top\n corners so it survives every surface that slot feeds.\n- Use a font that suits the game's tone for the logo art.\n- For 16:9 without logo and 9:16 without logo, compose for a title the site adds afterward, not a\n finished poster: leave genuine negative space where each surface's guide above says the copy\n lands, and treat the rest of the frame as where your subject can live.\n- Keep all five slots visually consistent — same subject,\n palette, and treatment — even though only the with-logo slots carry your logo.\n\n## Preview video\n\n- **10 seconds maximum, 8MB maximum** — both enforced by the uploader.\n- 1080p, 16:9; MP4 or WebM.\n- No sound.\n- No fast-forwarding.\n- Avoid black-screen or logo transitions, black bars, a visible mouse cursor, \"Play Now\" or\n other promotional text, and app/social icons.\n- Open on your 1:1 square cover image as the first frame, so the tile transitions seamlessly into\n the preview instead of cutting.\n\n## Uploading and review\n\nThe uploader is the **Store Listing** tab on your game's page in the developer portal,\nalongside the search tags players find the game by. It's independent of the build pipeline —\nyou can change cover art without requesting a new publish.\n\nLimits the uploader enforces, per slot:\n\n| Slot | Types | Max size | Max length |\n|---|---|---|---|\n| 1:1 square | PNG, JPEG, WebP, AVIF | 3MB | — |\n| 16:9 without logo | PNG, JPEG, WebP, AVIF | 4MB | — |\n| 16:9 with logo | PNG, JPEG, WebP, AVIF | 4MB | — |\n| 9:16 without logo | PNG, JPEG, WebP, AVIF | 4MB | — |\n| 9:16 with logo | PNG, JPEG, WebP, AVIF | 4MB | — |\n| Preview video | MP4, WebM | 8MB | 10 seconds |\n\nA file over its limit is rejected client-side before anything is sent; the video's length is\nread from the file's own metadata the same way. Each upload is a submission: it goes into the\nreview queue, and the live listing keeps showing the previous art until a reviewer approves it.\nYou can withdraw a pending submission from the same tab. Aspect ratios are not machine-checked\n— they are what the reviewer holds you to by eye, which is exactly what the live \"What players\nsee\" preview in the tab lets you confirm for yourself first.\n"},{"slug":"requirements/quality-guidelines","title":"Quality guidelines","section":"Requirements","agentSummary":"Optional best-practice guidance for onboarding, fun, aesthetic consistency, and naming. Nothing here blocks a review on its own.","checklist":[],"body":"# Quality guidelines\n\n**Everything on this page is optional best practice, not a requirement.** Nothing here will\nfail a review on its own — this is a collection of things that tend to make games better, not\na checklist a reviewer scores you against.\n\n## Onboarding\n\n- Land the player in gameplay, not a menu maze.\n- Keep any tutorial skippable.\n- Teach only the core mechanic — don't front-load every rule before the player has touched\n anything.\n- Prefer visuals over text where you can.\n- Label buttons clearly.\n- Never use deceptive delays or oversized buttons to nudge a player into something they\n didn't choose.\n\n## General principles\n\n- Clear, reachable goals.\n- Easy to learn.\n- Consistent and intuitive controls throughout.\n\n## What makes a game fun\n\n- Responds quickly to input.\n- Well-paced difficulty — not a flat wall, not a flat cakewalk.\n- Comfortable layout and audio.\n- No long repetitive stretches.\n- If you offer both solo and multiplayer, make solo play as prominent as playing with\n friends — or clearly say when solo isn't available, rather than leaving it ambiguous.\n\n## Uniqueness and naming\n\nA distinctive name helps players find you and avoids confusion with other games on the\nportal. A generic name like \"Chess\" doesn't identify your game; something like \"Super Chess\"\ndoes.\n\nNever use a name or asset whose IP you don't hold — \"Scrabble,\" for example, belongs to its\nrights holder, and using it invites a takedown regardless of how you built the game.\n\n## Aesthetic consistency\n\n- Consistent resolution and art style throughout — don't mix realistic and cartoon styles,\n or high- and low-resolution assets, in the same game.\n- Consistent audio levels across effects and music.\n- Your game's name and imagery should honestly reflect what the game actually is.\n\n## Restricted keys\n\nAdapt key bindings to the player's keyboard layout rather than making them rebind manually —\non AZERTY keyboards (France and others), the WASD movement cluster sits at ZQSD, and a game\nthat hardcodes WASD is unplayable there without a workaround.\n\nAvoid keys the browser owns outright: `Escape` exits fullscreen, and `Ctrl`/`Cmd`+`W` closes\nthe tab. Binding gameplay to either one means the browser will win the keypress before your\ngame ever sees it.\n"},{"slug":"publishing","title":"Publishing your game","section":"Publishing","agentSummary":"How to prepare a repo, connect it, and pass automated build validation plus human review to publish a game.","checklist":[{"id":"pub.validator-passes-locally","text":"Run the local miniant-validate.mjs validator against your actual build output before every publish request and fix every error it reports, treating warnings as review-visible.","verify":"manual"},{"id":"pub.manifest-present","text":"Ensure miniant.json exists in your build output next to index.html, or at your repo root.","verify":"validator:manifest.schema"},{"id":"pub.entry-and-references-resolve","text":"Confirm your entry file and everything it references (script, link, img, audio, video paths) exists in the build output with matching case.","verify":"validator:html.references-resolve"},{"id":"pub.no-secrets-in-output","text":"Keep secrets out of the build output — no .env files, private key or cert files, and no .git or .ssh directories.","verify":"validator:files.no-secrets"},{"id":"pub.lfs-objects-pushed","text":"Push your real Git LFS objects before publishing — an unresolved pointer file fails validation.","verify":"validator:files.no-lfs-pointers"},{"id":"pub.no-service-worker","text":"Don't register a service worker anywhere in the build — it fights the platform's instant-rollback model.","verify":"validator:sw.none"},{"id":"pub.lockfile-committed","text":"Commit a lockfile (package-lock.json, pnpm-lock.yaml, or yarn.lock) so your build is reproducible.","verify":"manual"},{"id":"pub.godot-web-export-preset","text":"For Godot, commit export_presets.cfg with an HTML5 export preset named exactly Web.","verify":"manual"},{"id":"pub.questionnaire-every-publish","text":"Complete the anti-cheat questionnaire honestly before every publish request — a false declaration is grounds for a ban.","verify":"manual"},{"id":"pub.bounds-match-game","text":"Declared score/duration bounds in the questionnaire must match what the game can actually produce — out-of-bounds results are rejected.","verify":"manual"},{"id":"pub.clean-console-playthrough","text":"No console errors during a full playthrough; the reviewer sees your console and a smoke-test screenshot that waits for Miniant.ready().","verify":"manual"}],"body":"# Publishing your game\n\nThis is what you need before you connect your repository, plus what happens after you request a\npublish.\n\n## Sign in\n\nGo to **[publish.miniant.games](https://publish.miniant.games)**. Enter your email, then enter the\ncode sent to your inbox (check spam if it doesn't arrive within a minute or two). You'll only see\ngames the Miniant team has invited you to publish — if \"My Games\" is empty, ask them to send the\ninvite to the email you signed in with.\n\n## Prepare your repository\n\nYour repo must build to a static web bundle. Which convention applies depends on your engine:\n\n**Phaser / plain JavaScript**\n- A `package.json` with a `build` script that outputs static files to `./dist` (or a different\n directory you name when you connect your repo — see \"Custom build output directory\" below).\n- npm, pnpm, and yarn are all auto-detected from your lockfile.\n- Commit your lockfile (`package-lock.json`, `pnpm-lock.yaml`, or `yarn.lock`). A build without one\n still runs, but every build card shows a reproducibility warning — your dependency versions\n aren't pinned, so a build today can silently differ from a build next month.\n\n**Godot**\n- An HTML5 export preset named exactly **`Web`** (Project → Export in the Godot editor). This\n writes to `export_presets.cfg` at your repo root — **commit that file**. If it's missing, or has\n no preset named exactly `Web`, the build fails before it ever runs.\n- Built against Godot `4.2.2`.\n\n**Prebuilt web export (any engine)** — Construct, GameMaker, Cocos, and similar tools with an\n\"export to HTML5\" step.\n- Export your project to a static web build from your engine's own export feature, and commit\n the exported directory to your repo.\n- Point the **build output directory** at that committed folder when you connect your repo (see\n \"Custom build output directory\" below) — it defaults to `./dist`, which is unlikely to match\n where your engine's exporter writes.\n- SDK integration is still the plain-JavaScript script tag from your exported HTML shell (see\n [`/guide/sdk/engines`](/guide/sdk/engines)).\n\n**Unity (WebGL export)**\n- Same prebuilt path as above: there's no Unity toolchain in this pipeline, so you build the\n WebGL export yourself in your own Unity Editor and commit the export directory.\n- Download the Unity wrapper from [`/tools/miniant-unity-sdk.zip`](/tools/miniant-unity-sdk.zip)\n and follow its integration steps — full details in [`/guide/sdk/engines`](/guide/sdk/engines).\n- The wrapper is new and hasn't been compiled inside Unity by us; smoke-test everything you use\n against the real portal before depending on it in production, and report issues you hit.\n\nPrebuilt and Unity builds skip compilation entirely — nothing is installed or run from source.\nThe pipeline only verifies your committed output directory exists and is non-empty, ensures\n`miniant.json` is present, runs a blocking malware scan, runs a non-blocking headless smoke\ntest, then zips and uploads it. Preflight hard-fails the publish request if the resolved output\ndirectory (subdirectory joined with output directory) doesn't exist at your target commit, or is\nempty — so for these two engines especially, make sure the real exported files are committed,\nnot an empty placeholder folder.\n\nYour build must run cleanly and non-interactively in a fresh checkout — the pipeline runs it in an\nempty GitHub Actions runner with no manual steps.\n\n**Monorepo? Your game lives in a subdirectory.**\nIf your repo isn't just the game — a monorepo, or the game sits under something like `apps/game`\nalongside other packages — set the **project subdirectory** when you connect your repo (see\n\"Connect your repo and request a publish\" below). The build runs rooted there instead of at the\nrepo root.\n\n**Build output goes somewhere other than `./dist`?**\nSet the **build output directory** when you connect your repo (see \"Connect your repo and request\na publish\" below). It defaults to `./dist`; point it at wherever your build script actually writes\n(`build/`, `public/`, `www/`, etc.).\n\n**Large assets tracked with Git LFS**\nThe builder checks out your repo with Git LFS enabled, so textures, audio, and other large binaries\ntracked in LFS resolve to real files during the build — no extra configuration needed on your end.\nWhat it can't fix: an LFS *pointer* file that never resolves to real content, usually because the\nactual objects were never pushed. That fails validation (see \"Why your build might fail\" below)\nrather than silently shipping a tiny text stub where your asset should be. Before requesting a\npublish, confirm your LFS objects are actually pushed — `git lfs push --all`, or just check that\nthe files in a fresh clone look like real assets rather than ~130-byte text pointers.\n\n## Generate a GitHub token\n\nThe connect-repo form needs a token that can read your repository. Create a **fine-grained\npersonal access token**:\n\n1. GitHub → Settings → Developer settings → Personal access tokens → Fine-grained tokens →\n Generate new token.\n2. Repository access: **Only select repositories** → your game's repo.\n3. Permissions: **Contents → Read-only**. Nothing else is needed.\n\nThis token is stored encrypted server-side and used only to check your repo's latest commit and\ncheck it out inside the build sandbox — it's never shown again after you save it, and Miniant\nstaff can't read it back out either.\n\n## Before you request a publish\n\nMost first-publish failures are avoidable. A local validator runs the same manifest and structural\nchecks the platform runs, in seconds — it skips a handful of server-only scans (the sandboxed\nmalware scan, the payment-SDK signal scan) that only run\nonce your build actually reaches the pipeline. Download it once from the developer portal and run\nit with Node (18+; no install, no dependencies — it's a single self-contained file):\n\n```sh\ncurl -fsSLO https://publish.miniant.games/tools/miniant-validate.mjs\nnode miniant-validate.mjs ./dist # or your custom build output directory\nnode miniant-validate.mjs ./dist --json # machine-readable output\n```\n\nIf your AI agent has the `miniantgames-mcp` server installed, its `validate_build` tool runs this\nsame validator; see the [introduction](/guide#test-before-you-submit).\n\n**Run it before every publish request.** Then check:\n\n- [ ] The validator passes locally, against your actual build output directory.\n- [ ] `miniant.json` exists — either inside your build output next to `index.html`, or at your repo\n root. (If it's only at the repo root, the builder copies it into the output for you and notes\n this on the build card. If it's in neither place, the build fails immediately — see the\n [manifest reference](/guide/requirements/technical).)\n- [ ] Your `entry` file, and everything it references (`<script src>`, `<link href>`, `<img src>`,\n any other relative path), actually exists in the build output, with matching case.\n- [ ] No secrets in the build output: no `.env*` files, no private key or certificate files, no\n `.git`/`.ssh` directories. Anything in your build output gets published to a public URL.\n- [ ] A lockfile (`package-lock.json`, `pnpm-lock.yaml`, or `yarn.lock`) is committed.\n- [ ] Godot only: `export_presets.cfg` is committed, with a preset named exactly `Web`.\n- [ ] If any assets are in Git LFS: the real objects are pushed, not just pointer files — a\n half-committed LFS asset fails validation.\n- [ ] No service worker gets registered anywhere in the build (see \"Common gotchas: it worked\n locally\" below) — it's not fatal, but it works against you.\n\n## Connect your repo and request a publish\n\nYour game's page in the developer portal is split into four tabs — **Build & Publish** (the one it\nopens on), **Store Listing**, **Settings**, and **Activity**. A first publish starts in Settings,\nbecause there is nothing to build until a repository is connected:\n\n1. **Settings → Connect your repository** — paste the repo URL, pick your branch and engine, paste\n the token from step 3. If your game lives in a subdirectory (monorepo), set the **project\n subdirectory**; if your build writes somewhere other than `./dist`, set the **build output\n directory** (see \"Prepare your repository\" above). Both fields stay editable from the same tab\n afterwards, so a build script that moves later doesn't mean reconnecting.\n2. **Build & Publish → Request publish** — the button only appears once a repository is connected,\n which is why step 1 comes first. This builds whatever commit is at the head of your connected\n branch — so if that commit is already the live version, or already has a build queued or waiting\n for review, the request is refused instead of building the same code twice. Push your changes\n first, then request. This queues a build. Status moves through:\n `pending → building → validating → awaiting_approval`\n - *building*: your `build` script (or Godot export) is running.\n - *validating*: the build output is scanned and checked before anyone reviews it (see the\n [manifest reference](/guide/requirements/technical) and \"Why your build might fail\" below).\n - *awaiting_approval*: a Miniant team member reviews and approves it — this is a manual step,\n so it may take a little time. See \"What the reviewer sees\" below for what they see.\n3. Once approved, your game goes live at `<your-game-slug>.miniant.games`.\n\nIf a build fails, the reason shows on the build card at the top of **Build & Publish** — run the\nchecklist in \"Before you request a publish\" above, fix your repo, and request publish again. A\nbuild the pipeline never actually starts fails on its own within about fifteen minutes rather than\nsitting in *building* indefinitely, so you always end up with a reason to act on.\n\n## Where everything else lives\n\n- **Build & Publish** — the current build card, the Request publish button, past builds, and the\n deployed versions of your game.\n- **Store Listing** — your game page copy (see \"Your game page copy\" below), your cover art and\n preview video, plus the search tags players find the game by (see\n [Game covers](/guide/requirements/game-covers) for the spec and today's upload limits).\n- **Settings** — the repository connection. Once connected, \"Edit build configuration\" changes the\n project subdirectory and build output directory in place; changing the repo, branch, engine, or\n access token means disconnecting and reconnecting (your existing builds and deployments survive\n that).\n- **Activity** — the full audit trail for the game, badged with its entry count.\n- **Promote** — request a paid placement for the game; see \"Promote your game\" below.\n- **Analytics** — traffic and player-behavior numbers for the game; see \"Analytics\" below.\n\n## Your game page copy\n\nYour game's page on Miniant is built from four sections you write in the **Store Listing** tab:\n\n- **About** — what the game is and what a round of it feels like. At least two paragraphs.\n- **How to play** — the rules in order, as a player meets them. At least four short steps.\n- **Tips and strategy** — advice that changes how someone plays, not the rules again. At least three.\n- **Frequently asked questions** — real questions, answered honestly, including when the honest\n answer is \"not yet\". At least three.\n\nThis is not marketing copy and it is not optional decoration. A game page carrying a title, a\nthumbnail and an iframe reads as an empty page to a search engine and to the ad network that\nreviews this site — which means it does not rank, and it puts every other game's page at risk too.\nNobody at Miniant can write it for you: we would be guessing at your game's rules, and a wrong\ndescription is worse for a player than none.\n\n**If it is incomplete when you request a publish, the build carries a warning and the reviewing\nadmin may reject it on that ground.** It will not block the build from running, so a crash fix is\nnever held up by a missing FAQ — but a build that arrives with an empty listing is a build somebody\nhas a reason to send back.\n\nThe editor shows you exactly what is still missing while you type, using the same check the\nreviewer sees. You can save an unfinished draft at any point; nothing is lost between sittings.\n\n### Write it somewhere else and paste it in\n\nYou do not have to type into the individual boxes. Write the whole listing wherever you normally\nwrite — a text file, a doc, a chat message — and paste it into the **Paste it all at once** box at\nthe top of the Store Listing tab. Press **Fill the fields from this** and it reads your text into\nthe fields below, where you can check it before saving. Nothing is saved until you press Save.\n\nThe format is ordinary Markdown headings. Two rules carry the whole thing:\n\n- **`##` starts a section.** Use it for About, How to play, Tips and FAQ.\n- **`###` starts one tip or one question** inside those sections. The line after it is the body or\n the answer.\n\nEverything else is plain text. Leave a blank line between paragraphs in About, and write your steps\nas a numbered or bulleted list.\n\n```\n## About\nWhat the game is, and what a round of it feels like to play.\n\nA second paragraph: what makes it different from other games like it.\n\n## How to play\n1. The first thing a player does.\n2. The next thing.\n3. And so on, in the order they meet it.\n4. How the game ends or how you win.\n\n## Tips\n### The takeaway, in a few words\nWhy it works, in a sentence or two.\n\n### Another tip\nWhy that one works.\n\n### A third tip\nWhy that one works.\n\n## FAQ\n### A question a player would actually ask?\nA complete answer — it is shown on its own in search results.\n\n### Another question?\nAnother complete answer.\n\n### A third question?\nA third complete answer.\n```\n\nThe **Copy template** button puts exactly that skeleton on your clipboard, so you can fill it in\noffline and paste the finished thing back.\n\nThe reader is deliberately forgiving, because nobody should have to think about syntax to write a\nparagraph about their own game:\n\n- `## About`, `About:`, `**About**` and a bare `About` on its own line are all the same heading.\n- `# About` works too — one hash or two, it makes no difference.\n- \"How to play\", \"HowToPlay\", \"Instructions\" and \"Controls\" all mean the same section. So do \"FAQ\",\n \"FAQs\", \"Questions\" and \"Frequently asked questions\".\n- Steps can be numbered `1.`, bulleted with `-` or `*`, or just one per line.\n- In the FAQ, a line ending in a question mark is treated as a question even without the `###`.\n\nWhat it will not do is guess. Text before the first heading is ignored and it tells you so; a\n`##` heading it does not recognise ends the section above it rather than being swallowed\ninto it; and a\npaste with no headings at all is refused with an explanation rather than dumped into About as one\nenormous paragraph. After every paste it reports what it read — \"Read 3 paragraphs, 5 steps, 4 tips\nand 4 questions\" — along with anything it skipped. Read that line before you save.\n\nBoth routes end in the same place. The paste box only fills the fields; the fields are what get\nsaved, and what a player sees on your game's page is the same either way.\n\nA few things that will not get you past it, because the check looks for them:\n\n- One good paragraph pasted into all four sections. Every field needs its own writing.\n- Steps like \"Play the game.\" A step has to say something a player can act on.\n- \"Yes.\" as an FAQ answer. Answers are shown on their own in search results, so they have to stand\n alone.\n\nIf you want a sense of the target, open any of the games Miniant built itself and read the sections\nunder the hero — those are the reference, and they run 450–700 words.\n\n## Promote your game\n\nThe **Promote** tab on your game's page is how you ask Miniant to feature it — there is no\nself-serve checkout for this yet, every placement is booked by the Miniant team after a request.\nThree kinds of slot exist:\n\n- **Sponsored hero** — a featured slot in the home page's hero rotation.\n- **Promoted trending** — a \"Promoted\" pill on a tile at the head of the trending rail (one of at\n most two slots).\n- **Launch package** — a bundled 7-day hero + trending placement plus a \"New\" pill on your game's\n detail page, for a new release.\n\nSubmit a request from the Promote tab (pick the surface, add a note) and Miniant reviews it. Your\nrequest history on the same tab shows each request's status — `pending`, `accepted`, or `declined`\n— and any review note. **Acceptance is not the same as booking**: an accepted request means Miniant\nagreed to place your game, but the placement itself (dates, surface) is booked separately once\ndetails are worked out. Every live and past placement for your game is shown on the same tab.\n\nEvery placement the portal shows a player is labelled — \"Sponsored\" on the hero, \"Promoted\" on a\ntrending tile, \"New\" on a launch — the same way a real ad would be; nothing about promotion is\nhidden from players.\n\n**No price is shown to developers in the product.** Placement is currently priced and negotiated\noff-platform — if you're interested, reach out to the Miniant team directly rather than looking for\na rate card here.\n\n## Analytics\n\nThe **Analytics** tab on your game's page has two tiers:\n\n- **Free (everyone)** — a rolling overview: sessions per day, unique players per day, and median\n session duration, for up to the last 30 days.\n- **Pro (granted by the Miniant team)** — adds a player-retention curve (D1/D3/D7, over the last\n 14 daily cohorts), a session funnel (started → reported a result → won), and category benchmarks\n (how your game's median duration, sessions-per-player, and D1 compare against other published\n games in the same category — aggregate numbers only, never a named competitor).\n\nFree-tier developers see a locked \"Ask us about Pro analytics\" panel where the Pro content would\nbe — Pro is not self-serve; it's a tier the Miniant team switches on for a developer account. Reach\nout if you'd like it enabled.\n\n## Earnings\n\nEvery game earns from the portal's own ad placements around it, plus any `Miniant.ads` calls of its\nown (see [Video ads](/guide/sdk/video-ads)). The top-level **Earnings** page (in the portal's main\nnavigation, not on a per-game tab) is where you see what that's worth:\n\n- A monthly statement per game, showing billable impressions, your 70% ad share, your 70% share of\n the Tickets spent on your [store items](/guide/sdk/store-items), and the total.\n- A lifetime total and an unpaid balance across every game you publish.\n- A **$50 payout threshold** — an unpaid balance below $50 stays accruing; once it crosses the\n threshold it's eligible for payout.\n- A payout-details form (payout method + details) you fill in once, so Miniant knows where to send\n a payout.\n\nPayouts are **manual and out-of-band** — the Miniant team pays you outside the platform (bank\ntransfer, PayPal, or similar) and then marks the statement paid on their side, at which point your\nEarnings page reflects it with a payout reference. There is no in-app \"cash out\" button, for you or\nfor players — this mirrors the platform-wide rule that players never cash out either, just applied\nto developer earnings instead of player Tickets. See [Monetization](/guide/publishing/monetization) for the\nfull rev-share terms.\n\n## What you can't do from here\n\nOnly Miniant owners can approve, reject, or roll back a build — the developer portal is\nread-only for that part of the pipeline. You can always see build and deployment history\n(**Build & Publish**) and the full activity log (**Activity**) for games you're invited to.\n\n## Why your build might fail\n\n**Hard failures** — the build cannot proceed until these are fixed:\n\n| What you'll see | What it means | How to fix |\n|---|---|---|\n| Missing `miniant.json` in build output or repo root | Neither location has a manifest | Add `miniant.json` to your build output or repo root (see the [manifest reference](/guide/requirements/technical)) |\n| `\"miniant.json\" is invalid: ...` | The manifest doesn't match the required shape | Fix the named field per the [manifest reference](/guide/requirements/technical) — e.g. a mode's `players` range, or a missing `network` block when `relay: true` |\n| `declares entry \"X\", which does not exist in the archive` | Your `entry` filename doesn't match a real file, or the case differs | Fix the filename/case in `miniant.json`, or make sure the build actually emits that file |\n| A static reference (`<script src>`, `<link href>`, `<img src>`, etc.) doesn't resolve in the build output | Your entry HTML points at a file, by literal relative path, that isn't actually in the output | Fix the path or case, or make sure the asset is actually copied into your build output |\n| Sensitive files detected (`.env*`, key/cert files, `.git`/`.ssh` directories, or private-key content found in a small text file) | Something that looks like a secret is in your build output | Remove it from the output (check your build script and `.gitignore`), and rotate any credential that may have leaked — these would be published to a public URL |\n| Unresolved Git LFS pointer file | An asset tracked in LFS never had its real content pushed | `git lfs push --all`, then request publish again |\n| Native-executable file extension present | A file like `.exe`, `.dll`, `.so`, `.dylib`, etc. is in your build output | Remove it — only web-runtime files belong in a build output |\n| Zip-bomb characteristics detected | Your build output is anomalously compressible or deeply nested | Check for an accidentally-bundled archive or duplicated data from your build script |\n| Archive over the size limit | Your build output exceeds the artifact size cap | Trim assets, or check you're not shipping unused source or debug files |\n| Malware scan flagged a file | The scan found something it treats as malicious | Remove or replace the flagged file; contact Miniant if you believe it's a false positive |\n| Archive is empty / build did not produce output | Your build script didn't write anything to the configured output directory | Confirm the build output directory (see \"Prepare your repository\" above) matches where your build script actually writes |\n\n**Warnings** — shown to the reviewer at approval, but they don't block the build:\n\n| Warning | What it means |\n|---|---|\n| No lockfile found | Your build isn't pinned to exact dependency versions — see \"Prepare your repository\" above |\n| Service worker registration detected | Discouraged — see \"Common gotchas: it worked locally\" below |\n| External payment-SDK signal detected (e.g. a Stripe/Razorpay/PayPal/Paddle script, or an inline `stripe.confirmCardPayment`/`razorpay.open`/`data-checkout` marker) | Your build references a known external payment integration point. External payment flows are banned by platform policy — all commerce must go through Miniant's own tickets/SKUs (see [what you must not build](/guide/requirements/technical) and [In-game purchases](/guide/sdk/purchases)). This never auto-rejects your build (a tier-2 game legitimately talks to its own server), but the reviewer sees exactly what matched and where, and will ask about it |\n\n## What the reviewer sees\n\nAfter validation passes, a headless browser loads your game with a minimal SDK-handshake harness\nand waits up to 30 seconds for `Miniant.ready()`. Whatever happens during that window — console\noutput, plus a screenshot of the running game — is shown to the human reviewer alongside your build\nbefore they approve or reject it. This never fails the build by itself; it exists so the reviewer\n(and you — the same information appears on your build card) can see what a player is about to see,\nnot just that validation passed.\n\nPractically: make sure your game reaches a visible first frame and calls `Miniant.ready()` (see the\n[SDK reference](/guide/sdk)) promptly, and keep an eye on your own console output — a wall of\nerrors in the reviewer's view doesn't fail your build automatically, but it doesn't help your case\neither.\n\nThe reviewer also confirms the **handoff** during that same window: the portal's own splash should\ncover the game right up until `Miniant.ready()` fires, and then hand off directly to a screen the\nplayer can actually use — a menu, a start screen, or gameplay itself. If the screenshot instead\nshows a loading bar, a percentage, a spinner, or a boot cover your game drew itself, that fails\nreview even though validation passed — see [No game-owned loading\nUI](/guide/requirements/technical#no-game-owned-loading-ui). If your game has a large asset\ndownload, the reviewer expects to see it calling `Miniant.setLoadProgress()`\n([reference](/guide/sdk/game#miniantsetloadprogressfraction)) rather than just sitting on a blank\nor frozen frame for most of that 30-second window.\n\n## Common gotchas: it worked locally\n\nYour own machine is not `<your-slug>.miniant.games`. The usual gap between \"plays fine locally\" and\n\"broke once hosted\":\n\n| Works locally, breaks hosted | Why | Fix |\n|---|---|---|\n| API calls or asset URLs hardcoded to `http://localhost:PORT` | That address only exists on your machine | Use relative paths, or a real production endpoint |\n| Assets 404 only after publish | A GitHub Pages–style base path (e.g. a bundler `base: '/repo-name/'` config) got baked into the build | Miniant doesn't serve your game from a subpath — set the base path to `/` (or drop the GitHub Pages–specific config) for the Miniant build |\n| An asset loads for you but 404s once hosted | Case mismatch between the reference and the actual filename — macOS's default filesystem is case-insensitive, so `Player.png` vs. `player.png` never shows up locally | Match case exactly between code/HTML references and actual filenames |\n| Players see an old version after you publish an update | A service worker cached the previous build, or HTML still points at unhashed asset filenames that browsers cached as immutable | Don't register a service worker in your Miniant build (warned at validation). Content-hash JS/CSS filenames in your build so a fresh HTML entry can point at new URLs — the host revalidates HTML on every load. The publish pipeline now warns (non-blocking) when your HTML references a JS/CSS/WASM filename that doesn't look content-hashed |\n\n## Anti-cheat questionnaire — required before every publish request\n\nRequesting a publish opens a short questionnaire on the **Build & Publish** tab — it's prefilled\nfrom your last submission, so you only have to change what's changed. Miniant is **rules-blind**:\nit can enforce the bounds you declare (score range, session length, scoring rate) but it can never\njudge whether a specific move was legal. Your answers do two things: **score direction** (higher\nis better, lower is better, or no score) picks the kind of leaderboard your game gets, and the\n**Limits** below flag a result that falls outside them as suspicious.\n\nThe questionnaire asks two things: **how a result is verified** (the client reports its own\nresult, deterministic replay, or your own server validates it) and **score direction**, plus one\nattestation tick. How a result is verified does not change what your game is eligible for: every\ngame's results count toward leaderboards, tournaments and rewards the same way. A game whose\nclient reports its own result shows a \"self-reported\" note on its leaderboard, and only a game\nwhose own server validates results may import progress. Your\nattestation is recorded under the email you are signed in with.\n\nAn optional, collapsed **Limits** section lets you declare a bounded score (min and max), a session\nlength range in seconds (fill in both ends or neither), and a maximum score per minute. Anything\nyou leave empty is simply not checked. Everything else the platform needs comes from\n`miniant.json`: the multiplayer message rate is `network.expectedMessagesPerMinute`, and whether\nthe game sells anything is read from `store.skus`. A Miniant reviewer sees your answers — and any\nchanges from your last approved submission — on the approve screen. Answer honestly: a false\ndeclaration is grounds for the game being banned.\n\n"},{"slug":"publishing/monetization","title":"Monetization","section":"Publishing","agentSummary":"Developer-facing monetization: ad and store-item revenue share (70/30, monthly statements), paid promotion, analytics tiers. Business terms only.","checklist":[],"body":"# Monetization\n\nThe developer-facing summary of how money flows between your game and Miniant — terms and process,\nnot integration. For the SDK surfaces that feed this, see [Video ads](/guide/sdk/video-ads) and\n[Cosmetics](/guide/sdk/cosmetics). For the day-to-day tabs, see\n[Publishing your game](/guide/publishing)'s Promote, Analytics, and Earnings sections. Developer\ncosmetics aren't a separate system worth its own section here: a cosmetic you sell is just an\n`entitlement`-kind SKU like any other durable unlock — see [Cosmetics](/guide/sdk/cosmetics) for\nthe full contract.\n\n## Terms\n\n**Ad revenue share.** Every ad served around your game is recorded — the portal's own placements\n(rematch, XP doubler, spend waiver, matchmaking wait) and any `Miniant.ads` call of\nyour own (see [Video ads](/guide/sdk/video-ads)). Only `\"shown\"`/`\"rewarded\"` outcomes from a real\nad network are billable — house cross-promo earns nothing, since no advertiser paid for it.\n\n- **Split: 70/30 in your favour** of billable revenue.\n- **Statements are monthly**, drafted on the 1st for the previous month, lifecycle\n draft → final → paid; only a `final` statement can be marked paid.\n- **Payout threshold: $50**, accrued across every game you publish, on ad share and item share together.\n- **Payouts are manual, out-of-band** (bank/PayPal) — no in-app cash-out, same as players. See\n [Earnings](/guide/publishing#earnings) for where you view statements and enter payout details.\n\n**Store item revenue share.** Items you declare for your game (see [Store items](/guide/sdk/store-items))\nearn the same **70/30 in your favour**, paid from a monthly pool:\n\n- **Ticket value** = net Ticket-pack revenue for the month ÷ all Tickets spent that month.\n- **Your share** = 70% × the Tickets spent on your items × Ticket value.\n- **Purchases by your game's own publishers don't count.**\n- It appears on the same monthly statement as the ad share, as a separate column, and a draft month\n is labelled *estimate* until the month's pack revenue is final.\n\n**Paid promotion** — a sponsored hero slot, a \"Promoted\" trending tile, or a launch package — has\nno self-serve checkout: you request from your game's Promote tab, Miniant reviews\n(accepted/declined, with a note), and an accepted request is booked separately. Every placement a\nplayer sees is labelled — \"Sponsored\", \"Promoted\", or \"New\". Pricing is negotiated off-platform.\nSee [Publishing your game](/guide/publishing#promote-your-game) for the request flow.\n\n**Analytics** has two tiers. Free: sessions/day, unique players/day, median session duration, last\n30 days. Pro (granted by the Miniant team, not self-serve): adds retention, a session funnel, and\ncategory benchmarks. See [Publishing your game](/guide/publishing#analytics) for what each tier\nshows.\n"},{"slug":"sdk","title":"SDK reference","section":"SDK","agentSummary":"Overview and full call reference of @mag/game-sdk, the only channel a game has to talk to the portal: identity, results, saves, relay, wallet, ads, and spectating.","checklist":[{"id":"sdk.script-tag-only","text":"Load the SDK only via the official script tag from www.miniant.games; never vendor it or npm-install it.","verify":"validator:sdk.script-tag"},{"id":"sdk.init-mandatory","text":"Call Miniant.init({ sdkVersion: 1 }) as the first SDK call and use the PlayContext it resolves with.","verify":"validator:sdk.init-called"},{"id":"sdk.ready-mandatory","text":"Call Miniant.ready() once your first frame is drawn, not before.","verify":"validator:sdk.ready-called"},{"id":"sdk.report-result-once","text":"Call Miniant.reportResult() exactly once per session, at game end, on every end path.","verify":"validator:sdk.report-result-called"},{"id":"sdk.terminate-stop","text":"Stop simulating and stop reporting the moment the terminate event fires.","verify":"validator:sdk.terminate-handled"},{"id":"sdk.iframe-css","text":"Set html, body { height: 100%; margin: 0; } so your background fills the iframe viewport.","verify":"manual"},{"id":"sdk.report-progress-60s","text":"Send Miniant.reportProgress() heartbeats whenever a session can run past 60 seconds.","verify":"manual"}],"body":"# SDK reference\n\n## The SDK — how your game talks to the portal\n\nYour build never runs standalone. It is loaded inside a portal-owned iframe at\n`<your-slug>.miniant.games`, and the **only** sanctioned way to talk back to the portal — find out\nwho's playing, report a result, send a progress heartbeat — is `@mag/game-sdk`. There is no other\nintegration surface: a game that never calls `Miniant.init()` never receives a play session, and a\ngame that never calls `Miniant.reportResult()` never has its result counted for anything.\n\n**Common pitfall — your iframe is your game's entire viewport.** Set `html, body { height: 100%; margin: 0; }`\nin your own stylesheet. Without it, `body` is only as tall as its visible content, and any background\ncolor or image you apply there only paints that short strip instead of filling the frame — leaving\nblank space or the portal's own background showing through around your game. This isn't a Miniant\nAPI, just plain CSS every game embedded this way needs.\n\nHow to get it:\n- **UMD script tag** — the way to integrate today, for any build (bundler or not, including a\n Godot HTML5 export): `<script src=\"https://www.miniant.games/sdk/v1.js\"></script>`, which sets a\n global `window.Miniant`. Commit to loading it from that URL; don't vendor a copy into your\n repo, so SDK fixes reach your game without a republish. `MiniAnt` remains a permanently\n supported alias for `Miniant`, so builds already published under the old spelling keep working.\n On `miniant.games` the script only starts inside the portal: the game must be framed by\n miniant.games and `Miniant.init()` must complete the portal handshake. For development it also\n runs, unframed, on `http(s)://localhost:<port>` and `*.localhost:<port>` (the port is required),\n loopback and private LAN addresses (`192.168.x.x`, `10.x.x.x`). Anywhere else (another host, an\n app WebView, a `file://` page) it shows a \"Play this game on Miniant\" screen and `Miniant.init()`\n never settles, so test on one of those hosts rather than a public preview URL. Inside the portal,\n a handshake that fails shows a \"Couldn't connect to Miniant\" screen instead of rejecting.\n- **npm / ESM** (`import { Miniant } from \"@mag/game-sdk\"`) — **not yet available**: the package\n isn't published to a public registry yet. Use the UMD build until it is; this guide will be\n updated when that changes.\n\nAll communication is `postMessage` between your iframe and the portal shell that frames it, and\nevery message is origin-checked on both sides. Your iframe's origin is a real, checkable string\n(never `null`) — the portal's sandboxing model guarantees this by construction.\n\n### Quick start\n\n```html\n<script src=\"https://www.miniant.games/sdk/v1.js\"></script>\n<script>\n (async () => {\n const context = await Miniant.init({ sdkVersion: 1 });\n\n // context.mode, context.participants, context.player tell you everything about this play\n // session. Do not build your own mode picker, friend list, or lobby — the portal already\n // decided all of it (see what you must not build).\n\n await Miniant.ready(); // first frame is drawn; portal hides its loading state, session clock starts\n\n // ... gameplay ...\n\n await Miniant.reportProgress({ checkpoint: \"level_2\", score: 340, tick: 1024 });\n\n // ... game ends ...\n\n const { accepted, verdict } = await Miniant.reportResult({\n outcome: \"win\",\n score: 1200,\n durationMs: 45_000,\n });\n })();\n</script>\n```\n\n(With a bundler, the same calls work identically off the `window.Miniant` global — the ESM\n`import` form arrives when the package is published to npm, see \"How to get it\" above.)\n\n### Calls you can make\n\n| Call | Required? | What it does |\n|---|---|---|\n| `await Miniant.init({ sdkVersion: 1 })` | **Mandatory** | Resolves with the `PlayContext` (see [Game](/guide/sdk/game)). Rejects after 10s if your game wasn't opened through the portal. |\n| `Miniant.ready()` | **Mandatory** | Call once your first frame is drawn. The portal hides its loading state and the play session's clock starts here. See [Game](/guide/sdk/game). |\n| `Miniant.reportResult({ outcome, score?, durationMs, perParticipant?, detail? })` | **Mandatory at game end** | `outcome` is one of `\"win\" \\| \"loss\" \\| \"draw\" \\| \"abandoned\" \\| \"completed\"`. The portal checks it against your declared bounds and returns `{ accepted, verdict }`. Call it exactly once per session — a duplicate call is rejected with `SESSION_ALREADY_SUBMITTED`. See [Scores and results](/guide/sdk/scores). |\n| `Miniant.reportProgress({ checkpoint, score?, tick? })` | **Required if a session can run past 60s** | A periodic heartbeat. Feeds the monotonicity and score-rate checks — a long session with zero progress reports is itself a signal, so don't skip this even though nothing \"requires\" a specific interval. See [Scores and results](/guide/sdk/scores). |\n| `Miniant.on(\"pause\" \\| \"resume\" \\| \"terminate\", cb)` | **Mandatory to handle `\"terminate\"`** | The portal can terminate your session — maintenance, a ban, or expiry — at any time, including mid-play. Your game must stop simulating and stop reporting the moment `\"terminate\"` fires. See [Game](/guide/sdk/game). |\n| `Miniant.exit()` | Optional | Asks the portal to leave. The portal owns the exit UI regardless of whether you call this. See [Game](/guide/sdk/game). |\n| `Miniant.requestRematch(options?)` | Mandatory **iff** your game has a definite ending | The game-over screen's \"Rematch\" button, in **every** mode — not only room-backed. The portal, never your game, decides what the rematch is (a successor room, or a fresh normally-charged session) and never restarts locally. Valid only once `reportResult()` has resolved. **Exception:** `progression: \"levels\"` — the next level is free for the rest of the sitting and starts without a reload or splash; plain `requestRematch()` is enough, `{ inPlace: true }` is optional (2026-09-25). See [Game](/guide/sdk/game#the-game-over-screen)'s \"The game-over screen\" and [Scores and results](/guide/sdk/scores#reporting-results-is-mandatory) for the levels contract. |\n| `Miniant.state.save(blob, meta?)` / `Miniant.state.load()` / `Miniant.state.onSaveRequest(cb)` | Optional | `save`/`load` persist up to 64KB of JSON, scoped to `(your game, this player, this session's mode)`; `meta?` is an optional `{ label?, progressPct? }` second argument to `save()` for the portal's own \"Resume — {label}\" card. `onSaveRequest(cb)` registers a callback the portal calls when it wants your current state persisted right now (returns an unsubscribe fn, same convention as `on()`). See [Data](/guide/sdk/data). |\n| `Miniant.net.*` | Mandatory **iff** `capabilities.relay` is `true` in your manifest | `connect()`, `send(payload, {reliable})`, `sendTo(peerId, payload)`, `on(\"message\" \\| \"peerJoined\" \\| \"peerLeft\" \\| \"roomClosed\" \\| \"throttled\" \\| \"spectatorCount\" \\| \"syncRequest\" \\| \"spectatorMessage\" \\| \"connected\" \\| \"disconnected\")`, `isConnected()`, `close()`. The payload is opaque to Miniant — it's never inspected. `on(\"syncRequest\", cb)` is mandatory for a hand-rolled `realtime` game (a `turn_based` game using `createTurnKit` gets it answered automatically). **Live** — see [Multiplayer relay](/guide/sdk/multiplayer) for the full contract. |\n| `Miniant.createTurnKit({ seats, mySeat, teams?, getSnapshot, applySnapshot, onTurnStart?, onPeerAbandoned?, unavailable? })` | Mandatory **iff** your mode declares `sync: \"turn_based\"` | Layers seat rotation, full-snapshot broadcast, and reconnect recovery on top of `Miniant.net` — gated on `capabilities.relay` the same way `net.*` is. Pass `unavailable: context.mode.unavailable` and the kit also handles auto-takeover/forfeit for you — omit it and none of that machinery runs. See [Turn Kit](/guide/sdk/turn-kit) for the full contract. |\n| `Miniant.net.deck(name, { kit?, onViolation? })` | Optional; use it whenever an online room game has hidden cards | A sealed deck held by the relay: server shuffle, each seat receives only its own ids, a play of a card the seat does not hold is refused. Gated on `capabilities.relay`. See [Sealed deck](/guide/sdk/sealed-deck). |\n| `Miniant.reportPeerAvailability({ seat, state, deadlineMs? })` | Required **iff** your manifest declares `unavailable` on the mode **and** you're not using `createTurnKit` (which calls this for you automatically); recommended for any relay-backed online mode even when `unavailable` isn't declared | Reports one peer-availability transition (`\"grace\"` \\| `\"returned\"` \\| `\"auto\"` \\| `\"retired\"` \\| `\"forfeit\"`) so the portal's own grace/auto-pilot/forfeit banner chrome can render. Gated on `capabilities.relay`. See [Multiplayer](/guide/sdk/multiplayer#absent-players) for the full recipe a hand-rolled game implements. |\n| `Miniant.wallet.getBalance()` | Mandatory **iff** `capabilities.wallet` is `true` | Read-only current ticket balance. See [Purchases](/guide/sdk/purchases). |\n| `Miniant.wallet.purchase({ skuId, quantity? })` / `.getEntitlements()` / `.consume({ skuId, units, idempotencyKey })` | Mandatory **iff** you sell anything | Buy, read, and burn manifest-declared SKUs — see [Purchases](/guide/sdk/purchases) for the full contract. **You can never charge a player an amount your own code makes up**: `purchase()` only ever buys a SKU the portal itself has reviewed and priced, and the portal renders its own confirm dialog from that registry data, never from anything your game passes at runtime. |\n| — | — | `getEntitlements()` only ever answers for the **local player**. To learn what an OPPONENT owns (e.g. to render their golden board), read `context.participants[].entitlements` instead — a static array of owned entitlement SKU ids per seat, present from `init()` onward with no relay round-trip. See [Cosmetics](/guide/sdk/cosmetics). |\n| `Miniant.wallet.on(\"entitlements_changed\", cb)` | Optional | Fires after any successful purchase (yours or, in principle, a refund) — re-call `getEntitlements()` to refresh. `\"balanceChanged\"` is not implemented yet. See [Purchases](/guide/sdk/purchases). |\n| `Miniant.wallet.openStore(sku?)` | Not implemented yet | Reserved for Phase 6 (buying tickets with real money) — throws `NOT_IMPLEMENTED` even with `capabilities.wallet` declared. See [Purchases](/guide/sdk/purchases). |\n| `Miniant.ads.requestInterstitial(reason?)` / `Miniant.ads.requestRewarded()` | Optional | Ask the portal for an ad; the portal decides. Interstitials are spaced at least 3 minutes apart and never shown in a player's first session, so handle `\"skipped\"` / `\"unavailable\"` without stalling — the portal pauses and resumes your game around a shown ad. `requestRewarded()` resolving `\"rewarded\"` means \"double the XP / quest progress you award on the results screen\"; it never grants Tickets. No capability gate. See [Ads](/guide/sdk/video-ads). |\n| `Miniant.spectate.publishState(snapshot)` | Mandatory **iff** `capabilities.spectate` is `true` **and** your session is roomless (solo/vs-AI) | Pushes a JSON-serializable state snapshot (≤16KB serialized) for any watchers to render. Throws `CapabilityNotGrantedError` immediately if `capabilities.spectate` isn't declared — checked before the room/roomless branch, so this holds even in a room session, where a *granted* call is instead a safe, documented no-op. See [Spectating](/guide/sdk/spectating) for the full contract, including why you can call it unconditionally from one shared code path once the capability is declared. |\n\nAny of the capability-gated calls (`net.*`, `wallet.*`) throws `CapabilityNotGrantedError` immediately, before even reaching the \"not implemented yet\" state, if your manifest didn't declare that capability — check `capabilities` in `miniant.json` ([manifest reference](/guide/requirements/technical)) first.\n\n## In this section\n\n- [Game](/guide/sdk/game) — the session lifecycle: `init()`, `ready()`, pause/resume/terminate, `exit()`, and the full `PlayContext` shape.\n- [User](/guide/sdk/user) — player identity as carried inside `PlayContext`, and what the SDK does not offer.\n- [Data](/guide/sdk/data) — saving and loading a player's progress.\n- [Scores and results](/guide/sdk/scores) — reporting results and progress to the portal.\n- [Purchases](/guide/sdk/purchases) — selling SKUs through the portal's wallet.\n- [Cosmetics](/guide/sdk/cosmetics) — the two cosmetic systems: entitlement SKUs you sell, and the portal's own catalog.\n- [Receipts](/guide/sdk/receipts) — verifying purchases from your own backend.\n- [Multiplayer](/guide/sdk/multiplayer) — the relay for room-based online play.\n- [Turn Kit](/guide/sdk/turn-kit) — a seat-rotation helper for a `sync: \"turn_based\"` online mode, layered on `Miniant.net`.\n- [Spectating](/guide/sdk/spectating) — letting a friend watch a match, read-only.\n- [Video ads](/guide/sdk/video-ads) — showing video ads in your game.\n- [Banners](/guide/sdk/banners) — banner ad placement in your game.\n- [Leaderboards](/guide/sdk/leaderboards) — how scores surface on the portal's own leaderboards.\n- [Engines](/guide/sdk/engines) — using the SDK from popular game engines.\n"},{"slug":"sdk/game","title":"Game","section":"SDK","agentSummary":"Session lifecycle: init() resolves PlayContext, ready() at first interactive screen starts the clock, setLoadProgress() reports load, pause/resume/terminate, exit()/requestRematch(), game-over screen.","checklist":[{"id":"game.init-first-call","text":"Call Miniant.init() as your game's first SDK call and drive setup entirely from the PlayContext it resolves with.","verify":"validator:sdk.init-called"},{"id":"game.seats-match-playercount","text":"Set your board up for exactly context.mode.playerCount seats; never assume the maximum seat count.","verify":"manual"},{"id":"game.ready-after-first-frame","text":"Call Miniant.ready() once your game is fully loaded and its first interactive screen is painted — not merely once a first frame is drawn — starting the session clock.","verify":"validator:sdk.ready-called"},{"id":"game.no-own-loading-ui","text":"Never show your own loading bar, spinner, percentage, or boot cover — the portal's splash covers the entire load, and your game must hand off directly to a playable screen.","verify":"manual"},{"id":"game.load-progress-reported","text":"If your game has a large asset download, report loading progress with Miniant.setLoadProgress() so the portal's splash can wait for you instead of giving up.","verify":"validator:sdk.load-progress-declared"},{"id":"game.terminate-stop-immediately","text":"Stop simulating and stop reporting the instant the terminate event fires, especially for platform-initiated reasons.","verify":"validator:sdk.terminate-handled"},{"id":"game.rematch-only-after-report","text":"Call Miniant.requestRematch() from your Rematch CTA in every mode, only after reportResult() resolved, and never restart the match locally.","verify":"manual"},{"id":"game.gameover-two-ctas","text":"For a game with a definite ending, render your own game-over screen with exactly two CTAs labelled Rematch and Exit.","verify":"manual"},{"id":"game.no-local-restart-on-rematch","text":"Never restart a match locally when the player taps Rematch — each round is its own portal session, and a game that loops rounds inside one session fails review.","verify":"manual"},{"id":"game.respect-settings","text":"Read context.settings.sound (effects), music (background music; a game with music stops it live when it turns off) and vibration at init, and apply live updates from the settings_changed event.","verify":"manual"},{"id":"game.safe-area-insets","text":"Keep interactive controls out of context.ui.safeAreaInsets.","verify":"manual"},{"id":"game.avatar-anchors","text":"Required with an online mode: call Miniant.ui.setAvatarAnchors() with each seat's avatar centre in percentages (0-100) of your frame, and again on every layout change.","verify":"validator:sdk.avatar-anchors"},{"id":"game.no-own-banner-chrome","text":"Show away/auto-pilot state in your own seat UI if your game needs it — the portal no longer shows availability banners over the game; it only announces a final forfeit or departure.","verify":"manual"},{"id":"game.sdk-script-tag-only","text":"Load the SDK only via the official script tag https://www.miniant.games/sdk/v1.js; never vendor it or npm-install it.","verify":"validator:sdk.script-tag"},{"id":"game.iframe-css","text":"Set html, body { height: 100%; margin: 0 } so your background fills the iframe viewport.","verify":"manual"}],"body":"# Game\n\nYour game's play session has a lifecycle the portal drives from end to end: it opens your game,\nhands you a `PlayContext` describing the session, waits for you to call `Miniant.ready()`, and — eventually —\nends the session, whether your game called `exit()` or the portal terminated it. This page covers\nthat lifecycle and the full shape of the `PlayContext` object. For player identity carried inside\n`PlayContext`, see [User](/guide/sdk/user). For reporting results and progress during and after a\nsession, see [Scores and results](/guide/sdk/scores).\n\n## `Miniant.init({ sdkVersion: 1 })`\n\n**Mandatory.** This is the first call every game makes. It resolves with the `PlayContext` below —\nmode, participants, player identity, capabilities, everything the portal decided about this\nsession. It rejects after 10s if your game wasn't opened through the portal (for example, opened\ndirectly by URL instead of inside the portal-owned iframe).\n\n## `PlayContext` — what `init()` hands you\n\nThis is the whole of \"the portal decided everything, now just play\":\n\n```ts\n{\n protocolVersion: 1,\n game: { slug, version },\n mode: { id: \"online_friends\", variant: \"blitz\" | null,\n playerCount: 4 | null, // how many seats to set up — see below\n teamUp: true | undefined, // two teams of two — see below\n params: {} },\n spectator: true | undefined, // see \"Spectating\" below — undefined for a real seat\n spectateAnchorSeat: number | undefined, // see \"Spectating\" below — present only alongside spectator: true\n player: { id, displayName, avatarUrl, isGuest, locale, seat, countryFlag },\n participants: [{ id, displayName, avatarUrl, seat, isLocal, isFriend, countryFlag,\n simulated: true | undefined, // `simulated` — see `online_random` note below\n team: 0 | 1 | undefined }], // only when mode.teamUp — see below\n room: { roomId, hostSeat, seatCount, teamUp: true | undefined,\n relayUrl, relayToken, maxMessagesPerMinute } | null,\n session: { sessionId, seed, startedAt, expiresAt },\n capabilities: { relay, wallet, saveState, spectate: boolean | undefined }, // mirrors your manifest\n entry: { paid: false, entryFee: null }, // portal charged it, if any — you never charge\n ui: { showExitButton, safeAreaInsets }, // see \"Leave room for the portal's own chrome\" below\n settings: { sound, vibration, music } | undefined // see \"Respect the player's sound, music and vibration\" below\n}\n```\n\nThis block is exhaustive — every field either side of the SDK actually sends is listed above.\n`capabilities.spectate` mirrors what you declared in `miniant.json` ([manifest reference](/guide/requirements/technical)):\nif your manifest didn't declare it, it's `undefined` here too — the same additive, back-compat\npattern `spectator` and `simulated` already use elsewhere in this shape.\n\n`displayName` (on `player` and every entry in `participants`) is the player's portal profile\nidentity — their chosen display name, falling back to their unique portal username. Render it\nas-is: never derive a name yourself, never ask the player who they are, and never persist it in\nsaved state (players can change it; re-read it from `init()` each session). See\n[User](/guide/sdk/user) for the full identity rules.\n\n`countryFlag` (on `player` and every entry in `participants`) is optional — an ISO 3166-1 alpha-2\ncountry code, or `null`. `null` means the player either chose not to share their country flag, or\nhasn't set a country at all — never treat it as an error. Render a flag only if your game already\nsupports flag rendering; do nothing otherwise. Never guess a player's country any other way\n(IP geolocation included) if `countryFlag` is `null` — that consent decision is the player's, made\nin their portal profile, and the portal will never hand you a value it hasn't been told to share.\n\n`room` is populated for `online_friends`/`online_random` sessions (`relayUrl`/`relayToken` are what\n`Miniant.net.connect()` uses under the hood — see [Multiplayer](/guide/sdk/multiplayer)) and `null`\nfor every other mode. Solo, vs-AI, and local-multiplayer sessions always hand you a single local\nparticipant.\n\nIn `online_random`, an entry in `participants` may carry `simulated: true`. This means the portal\ncould not find a human opponent and filled the seat with a portal-owned AI identity — your game\nmust drive that seat with its own local AI logic while presenting it to the player exactly like\nany other remote participant, using the `id`/`displayName`/`avatarUrl`/`countryFlag` you were\ngiven. **Never reveal that seat as AI** (no \"playing vs. bot\" label, no different visual treatment)\n— from the player's perspective it is a normal online opponent. `simulated` is omitted for every\nreal participant, which is every participant you've seen before this field existed — treat its\nabsence as `false`.\n\n### `mode.playerCount` — how many seats to set up\n\nThe player chooses this in the mode picker before your game loads, from the counts your manifest\ndeclares (`players` / `playerCounts` — see the [manifest reference](/guide/requirements/technical)).\nSet your board up for exactly this many seats.\n\n- **`vs_ai`** — this is **total** seats and you fill the ones the human doesn't. `4` means one\n player against three AI opponents.\n- **`online_friends` / `online_random`** — it agrees with `room.seatCount`. Either is fine to read.\n- **`null`** — either the mode declares a single possible count (nothing to choose), or the session\n predates this field. Fall back to your own default, exactly as you did before it existed.\n\nNever assume the maximum. A game that always seats four will seat four for a player who asked for\ntwo, and the portal has already told the other players how many to expect.\n\n### `participants[].team` — who is on whose side\n\nPresent on every participant when `mode.teamUp` is set: `0` or `1`, two per side. Absent for a\nfree-for-all session.\n\n**This tells you WHO partners whom, never WHERE they sit.** The portal owns the membership half\nbecause only it knows who was invited — a player who teamed up with a friend must actually get that\nfriend as their partner, and in Online Random a pre-formed pair must land on the same side. Seating\nstays yours, because the arrangement is game-specific: Ludo seats partners diagonally, Carrom pairs\nacross the ends.\n\nSo read `team` to learn the sides, then place them however your board works. **Do not infer teams\nfrom seat numbers** — the portal makes no promise about seat order. (Today the portal happens to\ncompute `team` by seat parity — seats 0/2 on team 0, seats 1/3 on team 1 — but that's an\nimplementation detail behind the field, not a contract: read `team`, never derive it yourself.) A\n`sync: \"turn_based\"` game using [Turn Kit](/guide/sdk/turn-kit) gets a `kit.teamOf(seat)` helper\nbuilt from exactly this field, rather than reading `team` off `participants` directly.\n\n### `mode.teamUp` — two teams of two\n\n`true` when this session is played as two teams of two rather than free-for-all. Only ever set at\nfour seats. Absent or `false` is free-for-all, which is every session before this existed.\n\n**You only ever receive this if your manifest declares it.** Team Up is opt-in per mode\n(`\"teamUp\": true`, see [Multiplayer](/guide/requirements/multiplayer)) — the portal offers the\ntoggle for declaring modes only, so a game that never declares it will never see `mode.teamUp` set\nand needs none of the handling below.\n\n**Read `mode.teamUp`, not `room.teamUp`.** `room` is `null` for `vs_ai` and for an `online_random`\nsession the portal filled with AI, and both of those can be team games — so a game reading only the\nroom field sees nothing for exactly the modes teams were added to support. (`room: null` only means\nyour game opens no relay connection of its own. The match still lives in a server room: the portal\nholds every board you pass to `Miniant.spectate.publishState`, and hands it back on a reload.) `room.teamUp` still\nmirrors this for room sessions and is safe to keep reading, but it can never be the whole answer.\n\nTeam Up appears in four places, and your game handles all four the same way — read `mode.teamUp`,\nread each participant's `team`, seat them your way:\n\n- **Play with Friends** — the host picks Team Up for a private four-seat lobby.\n- **Vs AI** — you and an AI partner against two AI opponents. `room` is `null`; you create every\n seat the human doesn't hold, and `team` tells you which of them is your partner.\n- **Online Random** — matched into a four-seat team game. You may have queued alone or as a\n pre-formed pair with a friend; either way the portal guarantees a friend you queued with is on\n your side. Seats it could not fill with humans carry `simulated: true` and are AI — and as\n always, never reveal that to the player.\n- **Pass & Play** — four people on one device, two per side. `room` is `null` here too, so\n `mode.teamUp` is again the only field that can answer.\n\nReport the result as your game normally does. The portal doesn't model teams in scoring — if a\nteam win means both partners won, say so through `reportResult`'s `perParticipant`.\n\n## `Miniant.ready()`\n\n**Mandatory.** Call this once your game is fully loaded and its first **interactive** screen is\npainted — not once your first frame is drawn. Those are different moments, and the gap between\nthem is exactly what this contract exists to close. A menu, a start screen, or gameplay itself all\ncount as interactive and are fine to call `ready()` from. A loading bar, a percentage, a spinner,\nan \"assets downloading\" message, or a boot/splash cover of your own does **not** — see [Technical\nrequirements](/guide/requirements/technical#no-game-owned-loading-ui) for the full rule against\nbuilding any of those.\n\nFrom the moment the play shell mounts until you call `ready()`, the portal's own branded splash\nfully covers your game. The portal's splash is the *only* loading screen a player is allowed to\nsee. Calling `ready()` at your first frame instead of your first playable one hands the player\nstraight from that splash onto your own boot cover or loading bar — the player still sees a\nloading screen, it's just yours instead of the portal's, and the whole point of the portal owning\nthis moment is that every game's load looks like the same platform.\n\n**Get the order right:** hide your own cover, let a frame paint, *then* call `ready()`. Calling it\nbefore your cover is actually gone flashes your own loading UI in the gap between the portal's\nsplash retiring and your game's real screen appearing — which looks exactly like the bug this rule\nexists to prevent, even though you technically called `ready()` at the right moment.\n\nThe play session's clock starts the instant you call this, so don't call it before the player\nactually has something to do — a menu they can tap into counts, a spinner they're staring at\ndoesn't.\n\nIf your game has a large asset download and might take a while to reach that first interactive\nscreen, see `Miniant.setLoadProgress()` next — it keeps the portal's splash honest about how much\nlonger that's going to take, instead of the portal giving up on you.\n\n## `Miniant.setLoadProgress(fraction)`\n\nOptional. Reports how far your own asset loading has gotten, at any point before you call\n`ready()` — `fraction` is a number from `0` to `1`. Fire-and-forget: it returns nothing, never\nthrows, and is safe to call at high frequency straight from your asset loader's own progress\ncallback.\n\nCalling it does two things:\n\n- Turns the portal's splash from an indeterminate spinner into a determinate progress bar\n showing your real download progress. **The portal owns the wording on the bar** — there's no\n game-supplied label. That's deliberate: it's what keeps every game's load looking like the same\n platform, exactly like the \"no game-owned loading UI\" rule above.\n- Pushes back the portal's give-up failsafe. Without any calls, the portal gives up waiting for\n `ready()` after 12 seconds and retires its splash anyway — a heavy title on a cold CDN can lose\n its splash mid-download. Each **new** progress value re-arms that 12-second wait, proving your\n game is still actively downloading rather than stuck, so the portal keeps waiting for you — up to\n a hard ceiling of 30 seconds after which the splash retires regardless, matching the 30-second\n window the reviewer's own smoke test already gives `ready()` (see\n [Publishing](/guide/publishing#what-the-reviewer-sees)). Calling `setLoadProgress()` again with a\n value you've already reported doesn't buy any extra time — a loader pinned at one percentage\n isn't making progress, so repeating it is treated the same as not calling at all.\n\nSkip it entirely and your game behaves exactly as it does today: an indeterminate bar and the\n12-second failsafe — fine for a game with little to load. **Strongly recommended if your game has\na large asset download:** without it, a legitimately slow but healthy load is indistinguishable\nfrom a hung one, and the portal abandons it at 12 seconds regardless of whether you're still\nmaking progress.\n\nA `Promise.all` over your asset list, counting completions as they land, is the most portable way\nto wire this up — it works the same regardless of engine:\n\n```js\nlet loaded = 0;\nconst urls = [/* your list of asset URLs */];\n\nawait Promise.all(\n urls.map((url) =>\n fetch(url).then((response) => {\n loaded += 1;\n Miniant.setLoadProgress(loaded / urls.length);\n return response;\n }),\n ),\n);\n```\n\nIf you're loading through three.js's own loaders, wire it straight to a shared\n`LoadingManager.onProgress` instead of tracking completions yourself:\n\n```js\nconst manager = new THREE.LoadingManager();\nmanager.onProgress = (_url, loaded, total) => {\n Miniant.setLoadProgress(loaded / total);\n};\nmanager.onLoad = () => {\n hideOwnCover(); // if you have one — see \"Get the order right\" above\n requestAnimationFrame(() => Miniant.ready());\n};\n\nconst loader = new THREE.GLTFLoader(manager);\n```\n\n## Pause, resume, and terminate — `Miniant.on(cb)`\n\n`Miniant.on(\"pause\" | \"resume\" | \"terminate\" | \"settings_changed\", cb)` — **mandatory to handle\n`\"terminate\"`.** The\nportal can pause or resume your game (for example, the player switching tabs), and can terminate\nyour session outright at any time, including mid-play. Your game must stop simulating **and** stop\nreporting the moment `\"terminate\"` fires; a game that keeps ticking or keeps calling\n`reportProgress()`/`reportResult()` after termination is not handling this event correctly.\n\nYour `\"terminate\"` callback receives `{ reason?: string }`. The reason tells you *why*:\n\n- **`\"player_exit\"`** — the player deliberately ended the session through the portal's own Exit\n control (or your own game called `Miniant.exit()`). **This ends the game**, and it is recorded as\n a **forfeit loss** (`outcome: \"loss\", forfeit: true`) for that player, never `\"abandoned\"`\n (2026-09-17) — report it yourself before you exit if you can (see [Scores and\n results](/guide/sdk/scores#reporting-results-is-mandatory)); the portal records it centrally on\n the confirmed exit even if your game reports nothing. For a **match-style** session (`vs_ai`,\n `local_multiplayer`, any `online_*` mode, or any room-backed session) the portal clears whatever it\n had saved for this player/game/mode the moment this fires — a conformant match game must not expect\n its run snapshot to survive a deliberate exit. A **solo / levels** progression save is different:\n it survives a deliberate exit, and the player resumes it next session. Stop simulating\n immediately: a match-based game (turn-based board/card games, most `Miniant.net` titles) should\n treat it like any other seat leaving — broadcast a resignation message to the remaining seats (see\n \"Absent players\" in [Multiplayer](/guide/sdk/multiplayer)) — while a progress-based game just\n stops the current run. You don't need to call `save()` here; the portal isn't asking for one.\n- **Any other or unknown reason** — always platform-initiated (a ban, scheduled maintenance, session\n expiry) and never something the player chose. Stop immediately, and if you declare\n `capabilities.saveState`, **flush your latest snapshot first** — the player didn't ask for this,\n so don't let it cost them progress they'd otherwise keep. Treat every reason you don't specifically\n recognize this way, not only the ones named here; new platform-initiated reasons can be added\n without a guide update.\n\n### Leaving is not one thing: exit, step away, and going quiet\n\nThree different things can take a player off your screen, and only one of them ends the game. Your\ngame sees each of them differently, so it is worth being precise about which is which.\n\n| | What the player did | What your game sees | What it means |\n|---|---|---|---|\n| **Exit** | Pressed the portal's Exit control, or your game called `Miniant.exit()` | `terminate` with `\"player_exit\"` | **Ends the session, recorded as a forfeit loss.** A match-style session's save is cleared; a solo/levels save survives. A match game should broadcast a resignation. |\n| **Step away** | Navigated back, closed the tab, switched app | **Nothing directly** — the frames simply stop | The seat is still theirs. The portal keeps the session alive and offers them a way back. |\n| **Went quiet** | Lost signal, battery died, tab froze | Nothing directly | Indistinguishable from stepping away, and treated identically. |\n\nA step-away deliberately looks the same to your game as a crash, and that is the point: your game\nshould not behave differently based on whether someone *meant* to walk off. What handles both is the\n[`unavailable` policy](/guide/requirements/multiplayer#when-a-player-becomes-unavailable) — auto-pilot\nor forfeit, on the clock the mode declares. **How long a player can be gone is entirely that\npolicy's `timeoutSeconds`**, so a `\"forfeit\"` mode with the default 30 seconds gives them thirty\nseconds, and an `\"auto\"` mode lets them come back whenever.\n\nOnly `player_exit` is a decision. The other two are conditions.\n\n### Knowing when a player is coming back: `context.resumed`\n\n`PlayContext.resumed` is `true` when this player is **returning to a session that was already\nrunning** — a mid-match refresh, a return after stepping away, a reconnect after a dead battery —\nrather than starting a new one.\n\nThis has always been possible; it was simply never announced. A reload has always put the player\nback into the same live session, and your game had no way to tell that apart from a first start, so\nit would re-run its opening: reset the score, re-deal, replay the countdown, over a match already in\nprogress.\n\n**On `resumed: true`, do not re-run start-of-match logic.** No resetting scores, no re-dealing, no\ncountdown. Restore from the state you are handed — your own save via `state.load()`, or for a\nrelay-backed match the snapshot that arrives from your peers — and carry on from there.\n\nTwo things it is *not*:\n\n- It is not \"the player pressed Resume in the picker\". That is a fact about how the session was\n **created**; this is a fact about the current **load**. A plain Start that the player reloads once\n arrives with `resumed: true`.\n- It is not a reconnect signal for your relay socket. `Miniant.net`'s own\n [`connected`/`disconnected` events](/guide/sdk/multiplayer) tell you about the socket; this tells\n you about the session.\n\nAbsent on a portal build that predates this field, which reads as `false` — a first start, exactly\nas before. A game that ignores it behaves as it always has.\n\nBefore `\"terminate\"` fires for a **platform-initiated** reason, the portal also calls\n`Miniant.state.onSaveRequest` with `reason: \"terminate\"` and waits a brief grace window for your\nreturned blob to land through the normal `save()` path before it pushes this event — see\n[Data](/guide/sdk/data)'s `onSaveRequest` section for the full API. **The portal no longer sends\n`onSaveRequest` with `reason: \"exit\"`** — a deliberate exit never asks for a flush: a match-style\nsession's save is cleared and a solo/levels save is simply kept as it already stands, so there is\nnothing to save for it. A game that has registered `onSaveRequest` gets a real chance\nto persist its precise final state ahead of a platform-initiated termination, rather than whatever\nwas last explicitly saved. It's still only a brief window, not a guarantee — the same honest limits\napply as any other `onSaveRequest` push (a mobile freeze or a near-cap save can still miss it) — so\nkeeping your saved state reasonably current on your own cadence remains your fallback, and it's\nstill worth keeping an independent periodic autosave regardless of whether you've registered\n`onSaveRequest`.\n\nThis is also your hook for a clean online shutdown regardless of reason: broadcasting a resignation\nmessage to the remaining seats before the session actually ends, rather than just going silent — see\n[Multiplayer](/guide/sdk/multiplayer)'s \"Absent players\" for the room-side half of this.\n\n## `Miniant.exit()`\n\nOptional. Asks the portal to leave the game. The portal owns the exit UI regardless of whether you\ncall this — calling it doesn't grant your game any control over how the exit is presented; it's\npurely a request to leave.\n\n## `Miniant.requestRematch()`\n\nMandatory **iff** your game has a definite ending (see \"The game-over screen\" below) — valid in\n**every** mode, not only a room-backed one. This is how your own game-over screen's \"Rematch\"\nbutton (see below) reaches the portal: the portal, never your game, decides what the rematch\nactually is — a successor room for a room-backed session (`online_friends` / `online_random` with\n`context.room` non-null), or a brand-new, normally-charged session for every other mode (solo,\n`vs_ai`, local pass-and-play, or a `sync: \"parallel\"` seat, whose `PlayContext.room` is always\n`null`). One Ticket fee buys exactly one round — a rematch is never free and never a local restart.\n\n**Exception, `progression: \"levels\"` (2026-09-17, updated 2026-09-25, Kais):** \"Next level\" and\n\"Retry\" are free for as long as the player stays in the same sitting, and plain\n`requestRematch()` needs nothing else — the portal starts the next level without a reload or\nsplash screen. `{ inPlace: true }` is optional polish. See\n[Scores and results](/guide/sdk/scores#reporting-results-is-mandatory) for the full levels\ncontract, including how the free sitting ends and what anti-cheat bound it changes.\n\n**Valid once:**\n\n- `reportResult()` has already resolved for this session. There's nothing to rematch before your\n game has reported an outcome.\n\nCalling it before that rejects the returned promise with an error — that's your integration's bug\nto fix, not a state your game needs to handle gracefully:\n\n```js\nawait Miniant.reportResult({ outcome: \"win\", durationMs });\n\n// later, when the player taps your own game-over screen's \"Rematch\" button:\nawait Miniant.requestRematch();\n```\n\n## The game-over screen\n\nProduct rule, updated 2026-08-23 (\"rematch = one fee, one round, every mode\"): **a game with a\ndefinite end state — a win/loss, or a final score — owns its own game-over screen.** The portal\nused to auto-open its own \"Rematch?\" modal on top of whatever your game rendered the moment\n`reportResult()` landed; outside a `sync: \"parallel\"` room, it no longer does. Rendering nothing of\nyour own there used to work by accident (the portal's modal covered for you); it doesn't anymore —\na game with a definite ending that skips this now leaves the player looking at a dead screen with\nno way to continue. (A `sync: \"parallel\"` room is the one exception — see the note at the end of\nthis section.)\n\n**If your game has a definite ending, your game-over screen must show exactly two uniform primary\nCTAs, labelled exactly:**\n\n- **\"Rematch\"**\n- **\"Exit\"**\n\nSame two labels, every game — that consistency is the whole point: a player should never have to\nrelearn what ends a match depending on which game they're in.\n\n**\"Rematch\" must call `await Miniant.requestRematch()` in EVERY mode** — online room, online\nrandom, `vs_ai`, solo, and local pass-and-play alike. **\"Exit\" always calls `Miniant.exit()`.**\nThere is no mode where \"Rematch\" restarts the match inside your own game: each round is its own\nportal session and costs the player's Ticket fee (or shows them the watch-to-play gate if they\ncan't pay it) exactly like starting any other session. **A game that loops rounds inside one\nsession on \"Rematch\" — a local restart instead of calling `requestRematch()` — fails review.**\n\n```js\nrematchButton.onclick = async () => {\n await Miniant.requestRematch(); // same call, every mode — the portal decides what happens next\n};\nexitButton.onclick = () => {\n Miniant.exit();\n};\n```\n\nThe portal always reloads your game fresh for the new round — whether that's a successor room (any\nroom-backed session, including a `sync: \"parallel\"` room — see the note at the end of this section)\nor a brand-new charged session (solo, `vs_ai`, local pass-and-play) — so `reportResult()` staying\ncall-once per session is automatic: a rematch never shares a session with the run that preceded it,\nand never needs a second report crammed into the same one.\n\n**Games with no definite end state at all — endless/high-score modes with no final screen — are\noutside this rule entirely.** Keep reporting exactly as you already do\n(`reportProgress()`/`reportResult()` per [Scores and results](/guide/sdk/scores)); there is no\ngame-over screen to build and nothing here changes for you.\n\n**`progression: \"levels\"` is a different case (2026-09-17, Kais), not the same exemption.** Every\nlevel still ends definitely — cleared or failed — so it still gets a \"Next level\"/\"Retry\" screen\nthat calls `requestRematch()`. What's different is the fee and the transition: the next level is\nfree and starts without a reload or splash screen, for as long as the player's sitting continues\n(a new sitting after closing pays the normal fee again, same as any other session). Plain\n`requestRematch()` is enough; `{ inPlace: true }` is optional. See [Scores and results](/guide/sdk/scores#reporting-results-is-mandatory) for the\nfull contract, the code example, and the anti-cheat bounds that shift for levels games.\n\nA `sync: \"parallel\"` seat's own `PlayContext.room` is always `null` (see [Choose a sync\nmode](/guide/requirements/multiplayer#choose-a-sync-mode-for-an-online-mode)), so call\n`requestRematch()` exactly as you would in a solo session — the missing `context.room` changes\nnothing about when or how you call it. The portal still routes that call to the room's\n**successor**, the same rematch its own scoreboard panel offers there, not to a fresh session: a\nparallel room is the one case that keeps the portal's own scoreboard-and-Rematch panel, so you'll\nsee it alongside (or instead of) your own game-over screen there.\n\n## Respect the player's sound, music and vibration\n\n`context.settings` carries `{ sound, vibration, music }` — the player's own choices, made in the\nportal's in-game settings sheet. **Honouring them is your job, not the portal's.** Your game runs in\na cross-origin iframe: the portal physically cannot mute your audio or intercept your\n`navigator.vibrate()` calls, so a game that ignores these keeps making noise after the player has\nexplicitly asked it to stop.\n\nThere are two audio switches, and the player controls them separately:\n\n- `sound` — sound effects (taps, hits, wins, UI blips).\n- `music` — background music and ambience loops.\n\nA game with no music only needs `sound`. A game that does not read `music` at all should keep\ntreating `sound` as all of its audio, which is exactly how it behaved before `music` existed. A game\nwith music must not start the loop when `music` is `false` at init, and must stop or fade it out\nwhen `music` turns `false` live (the `settings_changed` event), then bring it back when it turns\n`true` again.\n\nRead them at init, and subscribe so a mid-game change takes effect immediately — a player who mutes\nduring a match expects silence now, not next session:\n\n```js\nconst context = await Miniant.init();\napplyEffects(context.settings?.sound ?? true);\napplyMusic(context.settings?.music ?? true);\napplyHaptics(context.settings?.vibration ?? true);\n\nMiniant.on(\"settings_changed\", ({ sound, vibration, music }) => {\n applyEffects(sound);\n applyMusic(music);\n applyHaptics(vibration);\n});\n```\n\n`settings` is optional and absent on portal builds that predate it — treat a missing value as\n**everything enabled**, which is what the fallbacks above do. The SDK always hands you a complete\nobject: a missing `music` (from a portal build that only knew `sound` and `vibration`) arrives as\n`true`.\n\n`miniant-validate` and the upload check report four audio checks, all non-blocking: a miss never\nstops a publish, and an admin reviews them at approval. `audio.sfx-present` passes when the build\nhas an audio file (or a `data:audio/` URI) and code that plays it. `audio.sound-setting-honoured`\npasses when the game listens for `settings_changed` and reads `sound`. `audio.bgm-present` passes\nwhen something loops (`loop = true`, `loop: true` or an `<audio loop>` tag; an ambience loop counts,\nthe admin judges by ear), and `audio.music-setting-honoured` passes when the game also reads `music`.\n\n## The portal only announces a final forfeit or departure\n\nFor a relay-backed online mode that declares `unavailable` ([manifest\nreference](/guide/requirements/technical), [the policy\ncontract](/guide/requirements/multiplayer#when-a-player-becomes-unavailable)), the portal no longer\nshows live away/auto-pilot chrome over your game — it only announces a final state: a \"{name} left —\nyou win\" banner for a forfeit, or a \"{name} left — the game continues\" notice once a departed seat\nis later replaced, both driven entirely by the `Miniant.reportPeerAvailability()` calls your game\n(or [Turn Kit](/guide/sdk/turn-kit), automatically) makes. **Show away/auto-pilot state in your own\nseat UI if your game needs it** — the portal has nothing covering that window any more, so a player\nwatching an away or auto-driven seat only sees what your board itself renders for it.\n\nWhat stays yours: everything happening **on the board itself**. Freezing or highlighting the\nunavailable seat's pieces, playing the auto-driven move with your own animation, and your own\ngame-over/victory screen (including the `forfeit: true` win case — see [Multiplayer\nrequirements](/guide/requirements/multiplayer#when-a-player-becomes-unavailable)'s \"Reporting the\nresult\") are all in-board UX only your game can render. The portal's banners are peripheral chrome\naround your board, not a replacement for what happens on it.\n\n## Leave room for the portal's own chrome\n\n`context.ui.safeAreaInsets` is `{ top, right, bottom, left }`, in CSS pixels, and it reports space\ninside your viewport that the portal itself draws over. **Keep your own interactive controls out of\nit.** It is not only about device notches: the portal floats a small social dock (chat and emotes)\nabove the bottom edge of the game area in any session that has them, so `bottom` is typically around\n56px there and `0` in a solo session with no dock.\n\n```js\nconst { bottom } = context.ui.safeAreaInsets;\nlayoutHud({ bottomPadding: bottom });\n```\n\nWhat is **not** reserved, deliberately, so you get to use the space:\n\n- The portal's top chrome bar sits outside your viewport entirely while docked, and auto-hides in\n fullscreen.\n- Panels the player opens (game info, settings, chat) are transient and dismissed with one tap.\n- Emote bursts drift over the board but never accept input.\n\nThe player can drag the dock elsewhere, so treat these insets as the resting layout rather than a\nguarantee about every pixel for the whole session. Decorative background art under the dock is\nfine — it is buttons and readouts that need to move.\n\n## Tell the portal where your players are — `Miniant.ui.setAvatarAnchors()`\n\nEmotes and chat messages float over your game, and should rise from **that player's avatar**. The\nportal can't see inside your iframe to find them, so without anchors it guesses (local player\nbottom-left, opponents across the top) — which fits some layouts and badly misses others.\n\n```js\nMiniant.ui.setAvatarAnchors([\n { seat: 0, x: 18, y: 82 }, // you, bottom-left\n { seat: 1, x: 82, y: 18 }, // opponent, top-right\n]);\n```\n\n- `x`/`y` are **percentages of your own frame**, `0`–`100`, and mark the avatar's **centre**.\n Not pixels: divide a `getBoundingClientRect()` centre by `innerWidth`/`innerHeight` and multiply\n by 100. Pixel values are clamped to 100, so every emote rises from the bottom-right corner.\n- `x` runs left → right; **`y` runs top → bottom**, like CSS. Upside-down emotes are a flipped `y`.\n- `seat` is the player's real seat from `context.participants`, not a screen slot. Send one\n anchor for every seat in the match.\n- Anchor the **avatar** itself, not its name card or a marker near it.\n\n**Call it again on every layout change** — resize, orientation flip, seat rotation, player joining.\nThe portal keeps the last set you sent and cannot tell that your board moved.\n\n**Required for any game with an online mode.** `miniant-validate` and the upload check fail a build\nthat declares `online_friends` or `online_random` and never calls it. The check only sees that the\ncall exists, so test it: send an emote in an online match and watch where it rises.\n\n## The wire protocol is internal\n\nThe full wire protocol underneath this SDK (envelope shape, message types, the host-side security\nmodel) is internal — you never need it, and there's no public reference for it beyond this guide.\n"},{"slug":"sdk/user","title":"User","section":"SDK","agentSummary":"Player identity as carried in PlayContext (player and participants): displayName, countryFlag, cosmetics, entitlements, isGuest, and what the SDK deliberately does not offer.","checklist":[{"id":"user.render-displayname-as-is","text":"Render displayName as-is: never derive a name yourself, never ask the player who they are, and never persist it in saved state.","verify":"manual"},{"id":"user.no-geolocation-fallback","text":"Never guess a player's country, including via IP geolocation, when countryFlag is null.","verify":"manual"},{"id":"user.cosmetics-unknown-id-fallback","text":"Always render your game's own default appearance for any cosmetics id you don't recognize.","verify":"manual"},{"id":"user.no-frame-avatar-rendering","text":"Never render the frame or avatar cosmetic slots yourself; the portal draws those in its own chrome.","verify":"manual"},{"id":"user.no-signin-ui","text":"Never build a sign-in or login screen, or any third-party login; the portal owns authentication entirely.","verify":"manual"},{"id":"user.no-friends-list","text":"Never build a friends list, invites, chat, or any other social feature inside the game.","verify":"manual"}],"body":"# User\n\nThere is no separate user module in the SDK. Player identity arrives as part of the `PlayContext`\nobject `Miniant.init()` resolves with — there's no live user object, no subscription, and nothing\nto fetch separately. See [Game](/guide/sdk/game) for the full `PlayContext` shape and the rest of\nthe session lifecycle.\n\n## Shape\n\n```ts\nplayer: { id, displayName, avatarUrl, isGuest, locale, seat, countryFlag, cosmetics, entitlements }\n```\n\nEvery entry in `participants` (the other seats in this session) carries the same shape, plus\n`isLocal`, `isFriend`, and an optional `simulated`. `simulated: true` marks a seat the portal\nfilled with a portal-owned AI identity because it couldn't find a human opponent (only possible in\n`online_random`) — see [Game](/guide/sdk/game) for the full rule, including that you must never\nreveal that seat as AI to the player.\n\n## `displayName`\n\nThe player's portal profile identity — their chosen display name, falling back to their unique\nportal username. Render it as-is: never derive a name yourself, never ask the player who they are,\nand never persist it in your own saved state. Players can change their display name at any time,\nso a copy you saved last session can go stale — re-read it from `init()` every session instead.\n\n**`player` names the account; `participants` name the seats.** Anything rendered on a seat —\nroster rows, turn prompts, results — reads `participants[].displayName`, in every mode. The\ndistinction matters most in Pass & Play, where the portal collects each seat's name up front and\ndelivers it through `participants`, while `player.displayName` stays the signed-in account's\nprofile name. When both describe the same seat number, the participant entry wins for anything\non-screen — merging `player` over your roster replaces a name somebody just typed with the\naccount name. See [Multiplayer](/guide/requirements/multiplayer) for the full Pass & Play\ncontract.\n\n## `countryFlag`\n\nOptional — an ISO 3166-1 alpha-2 country code, or `null`. `null` means the player either chose not\nto share their country flag, or hasn't set a country at all — never treat it as an error. Render a\nflag only if your game already supports flag rendering; do nothing otherwise. Never guess a\nplayer's country any other way, including IP geolocation, if `countryFlag` is `null` — that\nconsent decision belongs to the player, made in their own portal profile.\n\n## `cosmetics`\n\nAn object of the equipped-cosmetic slots the portal knows about, each optional:\n\n```ts\ncosmetics: { boardTheme?, diceSkin?, tokenSkin?, striker?, frame?, avatar?, emotePack? }\n```\n\nEvery value, when present, is an **id string from the portal's own cosmetics catalog** — never a\nURL, never an asset, just an identifier like `\"obsidian_gold\"` or `\"gilded_dice\"`. A slot with\nnothing equipped is simply absent from the object (never `null` or an empty string), and the whole\nobject itself can be empty (`{}`) for a player who owns no cosmetics, a guest, or a bot-filled seat\n— never assume it's populated.\n\nMapping an id to something on screen is entirely your game's own job: only YOUR game knows what a\n`boardTheme` or `diceSkin` even means for its own board/pieces, so the portal hands you the bare id\nand nothing more. Maintain your own id → asset table for whichever slots your game actually uses,\nand **always render your game's own default appearance for any id you don't recognize** — a new\ncatalog item, a slot your game doesn't support, or simply a typo upstream must never break\nrendering or show a blank/broken asset. Treat an unrecognized id exactly like an absent one.\n\n`frame` and `avatar` are the two exceptions: the portal renders those itself, in its own chrome\n(the player list, spectator panel, leaderboard, profile pages, and so on) — never inside your\ngame's iframe. You'll see them come through on `player.cosmetics`/`participants[].cosmetics` like\nevery other slot, but there's nothing for your game to draw for them; ignore both unless your game\nhas its own in-board seat portraits it wants to keep visually consistent with the portal's chrome.\n\n## `entitlements`\n\nAn optional `string[]` of *entitlement-kind* SKU ids (`store.skus`, `type: \"entitlement\"`) this\nseat owns — your own sellable cosmetics/unlocks, not the portal's catalog above. Present on every\nparticipant, including your own, so you can render an opponent's owned skin without a relay\nround-trip. See [Cosmetics](/guide/sdk/cosmetics) for the full contract, including how it differs\nfrom `wallet.getEntitlements()` (which only ever answers for the local player).\n\n## `isGuest`\n\nMarks the player as unregistered. Guests deliberately have no wallet — that's part of what converts\na guest into a registered player, and it's an intentional product decision, not a gap to route\naround. Saved progress is different: a guest's `state.save()` now persists in that one browser (see\n[Data](/guide/sdk/data)), so guests are not without saved progress the way they're without a\nwallet — what registering actually buys a player is **cross-device** progress (the portal\nauto-uploads a guest's local save the moment they sign in) and the wallet, not saved progress\nitself. See [Account integration](/guide/requirements/account-integration) for the full product\nrules around this split, and [Data](/guide/sdk/data) for exactly how saves behave for a guest.\n\n## What the SDK does not offer\n\n- **No live user object or subscription.** Identity is a snapshot handed to you once, inside\n `PlayContext`, when `init()` resolves. There is no ongoing \"user changed\" event to listen for.\n- **No login/logout API.** The portal owns authentication entirely. Your game never builds a\n sign-in screen, never adds a third-party login, and never asks the player who they are.\n- **No access to the player's friends list from inside a game.** Whatever friend-based matching or\n invites the portal offers happens in the portal's own chrome, before your game is ever loaded.\n"},{"slug":"sdk/data","title":"Data","section":"SDK","agentSummary":"Saving and loading player progress via Miniant.state.save/load/onSaveRequest: one slot per game, player, and mode, save-granularity rules, and guest vs signed-in behavior.","checklist":[{"id":"data.load-on-every-start","text":"Call Miniant.state.load() unconditionally at the start of every session, never gated on which button the player pressed.","verify":"validator:save.load-on-start"},{"id":"data.levels-start-from-best","text":"For a levels game, open at the higher of context.progress.bestLevel and your own save; never restart a player at level 1 because you could not read your own older blob.","verify":"validator:save.levels-read-best-level"},{"id":"data.level-snapshot-at-start","text":"In level or checkpoint games, snapshot progress, score, rewards and position at level start. Only values that work solely against the player (lives lost, a cooldown, a consumed item) save live.","verify":"manual"},{"id":"data.no-entitlements-in-save","text":"Never persist wallet entitlements inside your saved blob; always re-read them live from getEntitlements().","verify":"manual"},{"id":"data.save-never-buys-rank","text":"Treat saved progress as progress only; it must never confer Tickets, rank, or a leaderboard position.","verify":"manual"},{"id":"data.keep-own-autosave","text":"Keep your own periodic autosave cadence regardless of whether you register onSaveRequest.","verify":"manual"},{"id":"data.no-own-signin-prompt","text":"Never build your own sign-in prompt around saves; the portal shows its own the first time a guest saves.","verify":"manual"}],"body":"# Data\n\n`Miniant.state.save(blob, meta?)` / `Miniant.state.load()` persist saved progress — one slot per\n`(your game, this player, the session's mode)`, last-write-wins (a second `save()` overwrites the\nfirst, there is no history). It requires `capabilities.saveState` in your manifest ([manifest\nreference](/guide/requirements/technical)). The `mode` half of the slot key is resolved\nautomatically from the session — you never pass one yourself; a player's \"blitz\" and \"classic\"\nruns land in separate slots without your game doing anything differently.\n\nFor a signed-in player the slot lives server-side and follows them to any device. For a guest it\nlives in that one browser instead — see \"Guests and signed-in players\" below.\n\nThe 64KB cap is enforced server-side, not just by the SDK's own client-side check — an oversize\n`blob` rejects with a size error regardless of what your client code does or doesn't check first,\nso don't rely on the client-side cap alone. The same 64KB cap applies to a guest's local save too.\n\n`meta` is an optional second argument — `{ label?: string, progressPct?: number }` — purely cosmetic\nslot metadata the portal can use to render its own \"Resume — {label}\" card in a mode picker. Omit it\nentirely and `save()` behaves exactly as it always has. **It's only honored for a signed-in\nplayer right now** — passed for a guest, it's accepted (never an error) but not stored, so don't\nrely on it surfacing anywhere until the player signs in.\n\n**`load()` resolves with your blob directly — there is no wrapper.**\n\n```ts\nconst saved = await Miniant.state.load();\n// `saved` IS your blob (whatever you last passed to `save()`), or `null` if nothing is saved yet.\n```\n\n`saved` is never a `{ blob }`-shaped object — that wrapper exists only on the internal wire between\nthe portal shell and the SDK, and is unwrapped for you before `load()` ever resolves. *(This guide\npreviously documented `load()` as returning `{ blob: null }` — that wording was wrong, not the SDK;\nif your game reads `.blob` off the resolved value, drop the `.blob` and use the value directly.)*\n\n## Save granularity depends on your game's shape\n\n**This is a publishing requirement, checked at manual review — not a recommendation.** See\n[Account integration](/guide/requirements/account-integration) for the normative statement; this\nsection is the how-to.\n\n### Level/checkpoint games — snapshot at the start, not live mid-level state\n\nThe `blob` you pass to `save()` must be a snapshot taken at the moment a level or checkpoint\n*begins*, never the player's live in-progress state. Score, lives, and any other run-state in the\nblob must reflect the values they had when that level started, not wherever the player happens to\nbe when they quit or the tab backgrounds. A player who leaves partway through a level resumes,\nnext session, at the **start** of that level or checkpoint — never their exact mid-level position.\nThis is deliberate, not a gap: it is the anti-abuse guard against a player grinding half a level\nfor points, quitting, and banking them anyway. It applies equally to a save you make on your own\ncadence and to a blob you return from `onSaveRequest` below.\n\n**Against-the-player values may be saved live.** Progress, score, rewards and position snapshot at\nlevel start. Values that can only work against the player — lives lost, a lockout or cooldown\ntimestamp, a consumed paid item — may be saved live, on every change. The test: if a player could\ngain from quitting and resuming at that moment, it must snapshot. Resuming mid-level returns to the\nlevel's start with equal-or-fewer lives (or other against-the-player values), never more.\n\nPass `meta.label` to name the resumable unit you just saved — `\"Level 3\"`, `\"Chapter 2 —\nCheckpoint 1\"` — because the portal renders it verbatim in its own \"Resume — {label}\" card. Get the\nlabel right and that's exactly what the player sees.\n\n### Levels games — the portal knows the level, so your save doesn't have to\n\nFor a `progression: \"levels\"` game, the context your game receives at boot carries a floor:\n\n```js\nconst floor = Miniant.context.progress?.bestLevel ?? 0; // highest level WE have accepted for this player\nconst saved = await Miniant.state.load(); // your blob, or null\nconst level = Math.max(floor, saved?.level ?? 0); // open here\n```\n\n`bestLevel` is the highest level this player has ever had *accepted* for your game — resolved from\nthe results you reported to us, not from anything in your blob, and never raised by a result our\nintegrity checks flagged. It is the level **cleared**; which level that makes the next one is your\ngame's business, since only you know whether your levels are a line, a branch or a map.\n\n**Take the higher of the two, always.** Your blob still owns everything else — score, lives,\nunlocks, checkpoint state — and a blob you can read is richer than a bare number, so this is a\nfloor, never a replacement.\n\nThe reason this field exists is worth stating plainly, because it is the single most common way a\ngame loses its players' progress. Your save blob is opaque to us: we store it, we never parse it.\nSo when you ship a build whose loader rejects a blob an older build wrote — the ordinary\n\n```js\nif (blob.v !== BLOB_VERSION) return null; // \"foreign shape, start fresh\"\n```\n\n— a player who reached level 10 restarts at level 1, while their actual progress sits intact in our\ndatabase, unreadable by the only code that knows what it means. The level is the one part of\nprogression we know independently, so it does not have to survive your save format to survive your\ndeploy. Read the floor and that whole class of bug stops mattering.\n\nTwo further habits worth keeping, neither of which the floor replaces: **migrate your old blobs\nrather than discarding them** whenever you can (a version bump is a reason to translate, not to\nthrow away), and **never rename a mode or a variant casually** — the save slot key is\n`\"<modeId>\"`/`\"<modeId>:<variant>\"`, so renaming one in a new manifest orphans every save written\nunder the old name.\n\n`bestLevel` is resolved once, when your game boots, and deliberately not refreshed afterwards for\nthe life of that boot. Mid-run you know your own level better than we do; this exists for the boot\ndecision alone. It is `null` for a player who has never cleared a level, and absent entirely on an\nolder host, both meaning the same thing: no floor, use your blob.\n\nWhat \"the life of that boot\" means depends on how the portal continues you into the next level. An\nin-place \"Next level\" chain (`{ inPlace: true }`) never re-boots your game at all — same instance,\nsame `onInit` call — so the floor it handed you at the start simply never refreshes for as long as\nthat chain runs. A plain `requestRematch()` call on a `\"levels\"` game, by contrast, gets a genuinely\nnew boot: the portal preloads the next level in a fresh, hidden instance and swaps it in, and that\nnew instance's own `onInit` carries the floor as of the last result we accepted before the swap —\nnot whatever was true when the FIRST level in the chain originally booted.\n\n### Solo match games with no level structure — resume the exact position\n\nA solo match with no level/checkpoint structure — an offline Ludo or Chess match against the AI —\nhas no partial-level score to bank, so the anti-farming rationale above doesn't apply. For this\nshape, `save()` should snapshot the **exact position** instead: the match itself is the unit, and\nthere is no earlier checkpoint to roll back to. Resume it exactly where the player left off.\n\nIf your game has no level or checkpoint structure at all *and* no meaningful mid-match position\nworth resuming — a single continuous match with no natural resume point — don't declare\n`saveState` at all. Ending the match when the player leaves is correct for that shape of game.\n\n### Call `state.load()` on every session start\n\nCall `state.load()` at the start of every session — the portal decides what comes back, so your\ngame never needs to branch on which button the player pressed. What you get back depends on your\n`mode`: for a match-style session (`vs_ai`, `local_multiplayer`, or any `online_*` mode — a match\nagainst an opponent, nothing partial to bank) pressing Start deliberately begins a fresh game, and\n`load()` returns `null` even when a save exists for that slot; only the picker's own Resume button\nreturns it. For a solo, level-based session, Start begins a fresh run from your player's last\nrecorded level, and `load()` still returns your saved progress either way — Resume changes nothing\nfor this shape. Either way, call `load()` unconditionally: a level game that only loads on some\n\"this is a resume\" condition will dump a returning player back at level 1 even on a Resume.\n\n## Guests and signed-in players\n\nSaves work for both, but from two different backends. For a signed-in player, `save()`/`load()`\nwork exactly as documented above, server-side. For a guest (`context.player.isGuest === true`),\n`save()` persists to that one browser instead — same 64KB cap, same last-write-wins semantics, but\ntoday a guest's slot is per-game only, not per-mode: switching modes as a guest overwrites the same\nlocal save rather than landing in a separate slot the way a signed-in player's would. `load()`\nreturns whatever that browser last saved for this game, or `null` if nothing was — never an error,\nsame \"no save exists yet\" convention either way.\n\nThe moment a guest signs in, the portal automatically uploads that browser's saved progress onto the\nnew account — this is entirely outside your game's control, best-effort (a failed upload just leaves\nthe local copy to retry on the next sign-in), and newest-wins if the account already holds a save for\nthat slot. Your game never sees this happen; it just means `load()` can start returning a blob a\nsigned-in player never explicitly saved on this device. See [Account\nintegration](/guide/requirements/account-integration) for the product decision behind the guest/\nsigned-in split.\n\nYour game never needs to branch on `isGuest` to call `save()`/`load()` — both resolve the same way\nregardless of which backend is behind them. **Never build your own sign-in prompt around this** —\nthe portal shows its own prompt the first time a guest saves, and yours would either duplicate or\ncontradict it.\n\n## `Miniant.state.onSaveRequest(cb)` — reacting to a portal-initiated save\n\n```ts\nconst unsubscribe = Miniant.state.onSaveRequest(({ reason }) => {\n // Return your current state to have it saved through the normal save path — the same as\n // calling save() yourself. Return (or resolve to) `undefined` to skip — e.g. nothing has\n // changed since your last save.\n return currentSaveBlob();\n});\n```\n\nRegisters a callback the portal invokes whenever it wants your game to persist its state right now,\ninstead of your game having to guess or poll for the right moment. `reason` is `\"hidden\"` (the tab\nwas backgrounded, or the page is being torn down) or `\"terminate\"` — sent right before the portal\nends your session for a platform-initiated reason (a staff ban, the game being pulled), with a brief\ngrace window for your returned blob to land before the `\"terminate\"` lifecycle event itself fires;\nsee [Game](/guide/sdk/game)'s \"Pause, resume, and terminate\" section for that event. Treat any reason\nyou don't specifically branch on the same way you already treat an unrecognized `\"terminate\"` reason\nthere — new ones can ship without a guide update.\n\n**The portal no longer sends `onSaveRequest` with `reason: \"exit\"`.** A deliberate exit — the\nplayer confirming the exit dialog, or your own game calling `Miniant.exit()` — never asks for a\nflush (see [Game](/guide/sdk/game)'s `\"player_exit\"` bullet). A match-style session's save (`vs_ai`,\n`local_multiplayer`, `online_*`, any room-backed session) is cleared instead; a solo/levels save\nsurvives the exit as it already stands. Either way there is nothing to save for it. `\"exit\"` remains a defined value in the protocol's reason type for wire\nback-compat only; the current portal never sends it.\n\nWhatever you return here for `\"hidden\"`/`\"terminate\"` is exactly what `state.load()` hands back\nnext session — held to the same save-granularity rule as every other save: a level/checkpoint game\nsnapshots the start of the current level, never the player's exact position (apart from the\nagainst-the-player values it may keep live); a solo match game with\nno level structure snapshots the exact position. See \"Save granularity depends on your game's\nshape\" above.\n\nReturning anything other than `undefined` sends it through the exact same path `state.save()` itself\nuses — no separate API to learn, and it's subject to the same 64KB cap and guest/signed-in routing\ndescribed above. `onSaveRequest` returns an unsubscribe function, the same convention `Miniant.on()`\nuses. Registering it costs nothing if you never act on it, and a game that never registers it\nbehaves exactly as it did before this existed.\n\n### Honest limits — keep your own autosave too\n\nTwo things this push does not solve, only reduce:\n\n- A mobile OS can freeze or kill a backgrounded tab before an async `onSaveRequest` round trip\n completes. There's no signal faster than `visibilitychange`/`pagehide` for this, and neither\n guarantees your save lands before the tab is gone.\n- The browser's own `fetch(..., { keepalive: true })` body cap sits right around this slot's own\n 64KB limit — a near-cap save flushed during page teardown can be silently dropped by the browser\n before it ever leaves the page.\n\n`visibilitychange` firing earlier, while the page is merely backgrounded rather than being torn\ndown, is what actually carries most of the weight in practice — but neither case is eliminated.\n**Keep your own periodic autosave regardless of whether you register `onSaveRequest`.** A game that\ncalls `save()` on its own cadence — after each turn, every few seconds, on any meaningful state\nchange — is materially better protected against a mobile freeze than one relying on the portal's\npush alone. The push closes the gap between autosaves; it doesn't replace having them.\n\n## Entitlements do not belong in saved state\n\nNever persist anything derived from `wallet.getEntitlements()` (an owned SKU, an unlocked-by-purchase\nmode) inside your saved `blob` — entitlements can change portal-side (e.g. a refund) with no way to\nalso rewrite your saved state, so that data must always be re-read live from `getEntitlements()`,\nnever trusted from `state.load()` alone. See [Purchases](/guide/sdk/purchases) for the full\nrehydration rule.\n\n## Saved progress never buys rank\n\nWhatever `save()` persists — level, checkpoints, unlocks — is progress, and only ever progress. It\nnever confers Tickets, rank, a leaderboard position, or tournament eligibility, regardless of what\nthe blob claims or what state the player reached on any other platform. See [Account\nintegration](/guide/requirements/account-integration) for the full policy.\n"},{"slug":"sdk/scores","title":"Scores and results","section":"SDK","agentSummary":"Reporting session outcomes and progress via reportResult()/reportProgress(), the only channel the portal uses to record results and derive its own leaderboards.","checklist":[{"id":"scores.report-result-once","text":"Call Miniant.reportResult() exactly once per session, at game end, on every end path including the player's own exit (a forfeit loss, never \"abandoned\").","verify":"validator:sdk.report-result-called"},{"id":"scores.report-progress-if-long","text":"Send Miniant.reportProgress() heartbeats whenever a session can run past 60 seconds.","verify":"manual"},{"id":"scores.each-seat-reports-own-outcome","text":"In an online session, report from each seat's own client independently; never infer or report another seat's outcome.","verify":"manual"},{"id":"scores.no-leaderboard-api","text":"Never try to post to or read a named leaderboard directly; only reportResult/reportProgress exist for this.","verify":"manual"},{"id":"scores.level-reported","text":"For a \"levels\" progression game, add detail: { level } to a completed level's reportResult() payload — one session is one level, and the portal can't tell them apart without it.","verify":"validator:sdk.level-reported"},{"id":"scores.level-in-place","text":"Optional polish for a \"levels\" game: requestRematch({ inPlace: true }) and a labeled save. Neither is required — a plain requestRematch() or just reporting the next level is already seamless.","verify":"manual"}],"body":"# Scores and results\n\nReporting is how the portal finds out what happened in a play session — it is entirely SDK-driven,\nthere is no other channel. Results are what the portal records for a play session; scores you\nreport surface on the portal's own leaderboards. **There is no leaderboard SDK API** — your game\ncannot post to a named leaderboard or read standings directly. See\n[Leaderboards](/guide/sdk/leaderboards) for how the portal surfaces scores instead.\n\n## `Miniant.reportResult({ outcome, score?, durationMs, perParticipant?, detail? })`\n\n**Mandatory at game end.** `outcome` is one of `\"win\" | \"loss\" | \"draw\" | \"abandoned\" | \"completed\"`.\nThe portal checks it against your declared bounds and returns `{ accepted, verdict }`.\n\nCall it exactly once per session — a duplicate call is rejected with `SESSION_ALREADY_SUBMITTED`.\nYour declared score bounds and session length come from the anti-cheat questionnaire you fill in\nbefore every publish request (see [Publishing your game](/guide/publishing)); the portal checks\n`reportResult()` against those bounds, which is why an honest, accurate questionnaire matters as\nmuch as the call itself.\n\n**Score direction is declared in that questionnaire, not in `miniant.json`.** Its required\n`scoreDirection` answer is one of `higher_better`, `lower_better` or `no_score`, set once per game\n(not per mode):\n\n- `higher_better`: points; the leaderboard ranks the highest score first.\n- `lower_better`: times, strokes, moves; the leaderboard ranks the lowest score first. Report the\n score in one consistent unit (a race time in milliseconds, say) and declare `scoreBounds` in that\n unit. Because a lower number is better, any `score` you send through `reportProgress()` must never\n go up during a session; a rising one is flagged as `PROGRESS_MONOTONICITY`. If your progress\n heartbeats have no meaningful running score (a race in progress), omit `score` from them.\n- `no_score`: outcome only; the leaderboard ranks by points earned from outcomes.\n\nA mode that reports no `score` (say a multiplayer race that only reports `win`/`loss`) is fine\nunder any direction; the direction only governs the modes that do send one.\n\n`perParticipant` is optional: an array of `{ id, score?, outcome? }` entries, one per participant\nyou want to report a result for beyond your own seat — `id` matches a `context.participants[].id`\nvalue, and `score`/`outcome` follow the same shapes as the top-level `score`/`outcome` fields\n(`outcome` is the same `\"win\" | \"loss\" | \"draw\" | \"abandoned\" | \"completed\"` enum). `detail` is an\nopaque `Record<string, unknown>` for anything else you want attached to the record.\n\n## Reporting results is mandatory\n\n**Every session ends with exactly one `reportResult()` call — no exceptions.** A win, loss, draw, or\ncompleted run reports the matching `outcome`. A player who **LEAVES** mid-session — Exit, a quit\nbutton, back — before a result exists is a **loss with forfeit**, never `\"abandoned\"` (2026-09-17):\nreport `Miniant.reportResult({ outcome: \"loss\", forfeit: true, durationMs, ... })` before you exit.\n`\"abandoned\"` is reserved for ends the player didn't choose — a disconnect, a room closing, session\nexpiry, a load failure, the platform terminating the session — never for the player's own exit. A\nquit earns 0 XP but still counts as played and as a loss (win rate, streaks); the survivor of a\nforfeit reports a normal win.\n\n**Closing the tab is not a leave (2026-11-10).** Only the explicit Leave forfeits. A closed or hidden\ntab keeps its seat: the server stand-in plays the match to its normal end and the result is recorded\nas a normal result, never a forfeit. (This replaces the 2026-09-18 rule that a closed tab forfeited\nafter about 2 minutes.) No game change is needed. Pass & Play (`local_multiplayer`) is never\nrecorded either way, win or forfeit.\n\nIf your integration misses this entirely, the SDK itself now backstops it outside online modes:\ncalling `exit()` with no prior `reportResult()` call auto-reports `outcome: \"loss\", forfeit: true`\n(not `\"abandoned\"`, as of 2026-09-17) and logs a `console.error` so the gap shows up in your own dev\nconsole — that exists to catch what your integration missed, not as a substitute for reporting the\nreal outcome yourself. In `online_friends`/`online_random` the SDK still never reports on your\nbehalf (a finished match's result closes the shared room — a quitter's own forfeit loss never does,\nthe match plays on for the seats still in it), so an online game must report its own outcome. Either way, no game change is strictly required for the forfeit-loss rule to take effect —\nthe portal itself records the forfeit loss on a confirmed Exit even when a game reports nothing, and\nconverts a game-reported `\"abandoned\"` landing around the same moment as that confirmed exit into a\nforfeit loss too. Reporting it yourself is still the correct integration, not a workaround.\n\n**For a `\"levels\"` progression game, one session is one level.** Clearing a level reports\n`Miniant.reportResult({ outcome: \"completed\", score, durationMs, detail: { level } })`; a failed\nlevel reports `outcome: \"loss\"` with the same `detail: { level }` (still not `\"abandoned\"` — a\nfailed attempt is a normal loss; a player who *leaves* mid-level is a different case, a forfeit loss\nthat ends the sitting, see below).\n\n**Nothing else is required for a seamless \"Next level\"/\"Retry\" (2026-09-25).** Report the level as\nabove and call the same plain `await Miniant.requestRematch()` that [The game-over\nscreen](/guide/sdk/game#the-game-over-screen) describes for every other mode — the portal recognizes\nthe `\"levels\"` progression and does the rest itself: it starts the next (free) session in a hidden\ncopy of your game and swaps it in once ready, with no Miniant splash and no page reload. Your game\njust boots and opens its own save the way it always does. If your integration never calls\n`requestRematch()` at all and simply reports the next level on the same session instead, the portal\nstarts that next session itself and records the level there — either shape gets the seamless\ntransition. Next level and Retry are free either way — **one Ticket fee per sitting, unlimited\nlevels, until the player closes the game.** Closing and resuming later starts a new sitting and costs\nthe normal fee again, same as any other resume. A guest's chained levels never count against their\ndaily free-session allowance; only the first session of the sitting does.\n\n**Levels XP = progress only (2026-09-18).** A session earns XP only when its completed level is\nhigher than that player's own best recorded level for this game. A retry, a replay of an\nalready-cleared level, a failed level, and a completed level that carries no `detail.level` all earn\n0 XP — every one of them is still recorded, still counts as played, still feeds result-health, and\n(on a milestone level) still gets its Share offer. This exists because levels are now free for the\nwhole sitting (above): a flat per-level XP grant would be unlimited. A `\"levels\"` game that never\nsends `detail.level` at all now earns its players **no XP, ever** — one more reason to send it.\n\n`inPlace: true` and a level-labeled save are optional polish, never required — the code below is one\nway to take them, not the baseline contract above:\n\n```js\nawait Miniant.reportResult({ outcome: \"completed\", score, durationMs, detail: { level } });\n\nconst rematch = await Miniant.requestRematch({ inPlace: true });\nif (rematch.continued) {\n // No reload, no splash — reset your own state and start the next level yourself.\n // The portal doesn't know your level numbering; keep your own counter and use\n // rematch.session.seed if you seed randomness from it.\n startLevel(++currentLevel, rematch.session.seed);\n}\n// `continued: false` means the portal is navigating to a new session instead (an old host, a\n// game not opted in, or an expired session) — do nothing further, the page is reloading.\n```\n\n`{ inPlace: true }` goes a step further than the default seamless swap above — no reload at all,\nrather than none the player sees. Pair it with `Miniant.state.save(blob, { label: \"Level N\" })` — see\n[Data](/guide/sdk/data#levelcheckpoint-games-snapshot-at-the-start-not-live-mid-level-state) for the\nlabel contract — so the picker's Resume button reads \"Resume Level N\" instead of falling back to the\nplayer's highest cleared level + 1. Both are worth doing for the extra polish; neither changes\nwhether the level flow itself is seamless.\n\nThe portal shows no interstitial between levels on its own. Call\n`Miniant.ads.requestInterstitial(\"level_end\")` yourself if you want one between levels (see [Video\nads](/guide/sdk/video-ads)). The post-match Share offer is also gated for levels games: it only\nappears on milestone levels — every 10th (10, 20, 30…) — not on every completed level.\n\nA player who leaves mid-level still reports a forfeit loss (`outcome: \"loss\", forfeit: true`), same\nas leaving any other session (see \"Reporting results is mandatory\" above) — and it ends the sitting.\nIf a `\"levels\"` game's completed result never carries `detail.level`, the SDK logs a\n`console.error` — the result is still sent, but the portal has no way to tell which level it was\nfor, so (2026-09-18) it earns 0 XP as well, the same as a retry.\n\n**Levels get their own anti-cheat bounds too (2026-09-17).** The publish-time questionnaire's\nshortest/longest session (see [Publishing your game](/guide/publishing)) are read as\nshortest/longest single *level* for a `\"levels\"` game: the shortest bound is **not** applied per\nlevel (a level can legitimately clear in a couple of seconds), but the longest still is. The\nscore-rate cap (`maxScorePerMinute`) is inert below a 30-second level — too little play to\nextrapolate a rate from without a false positive. Result submission is also allowed a higher rate\nfor levels games: 60 calls per 5 minutes, against 20 for a continuous game, since a fast human\nclearing levels back-to-back legitimately produces more `reportResult()` calls than any one\ncontinuous session would.\n\n`miniant-validate` enforces both halves of the reporting rule as build-failing **errors**:\n`sdk.report-result-called` (no `.reportResult(...)` call found anywhere in the build) and\n`sdk.level-reported` (a `\"levels\"` game whose `.reportResult(...)` payload never carries a `level`).\nBeyond the build, the portal also watches production traffic — a game whose last 20 sessions end\nwithout a reported result more than half the time is flagged to staff for review.\n\n## `Miniant.reportProgress({ checkpoint, score?, tick? })`\n\n**Required if a session can run past 60s.** A periodic heartbeat. It feeds the portal's\nmonotonicity and score-rate checks — a long session with zero progress reports is itself an\nanti-cheat signal, so don't skip it even though no specific interval is mandated. Call it whenever\nyour game reaches a natural checkpoint (a level, a round, a meaningful score change) rather than on\na fixed timer if your game doesn't have one.\n\n`checkpoint` is a **string label**, not a counter — `\"level_2\"`, `\"boss_intro\"`, `\"round_3\"`. The\nportal stores it verbatim for the record and never orders, compares or sums it, so use whatever\nnames the moments in your own game.\n\n## Reporting results in an online (multi-seat) session\n\nThere's no server-side arbiter deciding who won — **each participant's own client calls\n`reportResult()` independently, from that seat's own perspective.** A game ending in a win for seat\n0 and a loss for seat 1 means seat 0's client reports `outcome: \"win\"` and seat 1's client reports\n`outcome: \"loss\"`, each from its own session; Miniant never infers one seat's outcome from another's.\nThis applies the same way regardless of `sync` ([Choose a sync\nmode](/guide/requirements/multiplayer#choose-a-sync-mode-for-an-online-mode)): a `turn_based`/\n`realtime` match's seats agree on the board over the relay, but still each report their own result\nonce it ends; a `parallel` match's seats never share a board at all, so `score` is the only signal\nthe portal has, and it's what drives that match's own scoreboard (see [Multiplayer\nrequirements](/guide/requirements/multiplayer)).\n\n## How this fits together\n\n- **`reportResult()`** closes out the session and is what the portal actually records as the\n outcome of this play.\n- **`reportProgress()`** keeps the portal informed while the session is still running, primarily so\n a long session that never finishes (abandoned, crashed, disconnected) still has a plausible trail\n behind it instead of going dark from the first frame to nothing.\n- **Neither call is a leaderboard API.** Reporting a score through either call is not the same as\n posting to a board — see [Leaderboards](/guide/sdk/leaderboards) for what the portal does with\n reported scores and what remains outside the SDK's surface.\n"},{"slug":"sdk/purchases","title":"In-game purchases","section":"SDK","agentSummary":"In-game purchases go only through Miniant's own declared SKU/ticket economy via Miniant.wallet; games never set prices, render store UI, or move currency directly.","checklist":[{"id":"buy.declare-wallet-capability","text":"Declare capabilities.wallet: true in the manifest before calling any wallet.* method.","verify":"validator:manifest.schema"},{"id":"buy.no-custom-pricing","text":"Never compute a price in game code; every price lives in the manifest store.skus or pricing.actions block.","verify":"manual"},{"id":"buy.no-store-ui","text":"Never render a custom store/confirm UI. Only an ENTITLEMENT wallet.purchase() gets the portal's dialog; consumables and every requestSpend debit instantly — show the price on your own control.","verify":"manual"},{"id":"buy.rehydrate-entitlements","text":"Re-derive any saved state referencing an owned item from getEntitlements() on load rather than trusting state.load() alone, when both wallet and saveState are declared.","verify":"manual"},{"id":"buy.stable-idempotency-key","text":"Use a stable idempotencyKey per real conversion event when calling wallet.consume(), not a fresh value per call.","verify":"manual"}],"body":"# In-game purchases\n\nIf your game sells anything at all — a gem pack, an undo, a skin, \"remove ads\" — it sells through\nMiniant's own ticket economy, declared in your manifest and enforced by the portal. Your game never\nsets a price, never renders a store UI, and never moves currency directly; it asks the portal to\nbuy/read/burn a SKU it already declared, and the portal does the rest (see\n[what you must not build](/guide/requirements/technical), point 2). For server-side verification of\na completed purchase, see [Receipt verification](/guide/sdk/receipts).\n\n## Other wallet calls\n\n`Miniant.wallet.getBalance()` is mandatory **iff** `capabilities.wallet` is `true`, and returns the\nplayer's read-only current ticket balance — it never lets your game set or adjust it directly.\n\nTwo pieces of the wallet surface are **not implemented yet**, and this guide won't pretend otherwise:\n\n- `Miniant.wallet.openStore(sku?)` is reserved for a later phase (buying tickets with real money) —\n it throws `NOT_IMPLEMENTED` even when `capabilities.wallet` is declared.\n- A `\"balanceChanged\"` event does not exist. `wallet.on(\"entitlements_changed\", cb)` (below) is the\n only wallet event currently implemented.\n\n## The `store.skus` manifest block\n\nAdd a `store` block to `miniant.json`, alongside `capabilities` (requires `capabilities.wallet: true`\n— a `store` block without it fails validation):\n\n```jsonc\n{\n \"capabilities\": { \"wallet\": true, \"relay\": false, \"saveState\": false },\n \"store\": {\n \"skus\": [\n { \"id\": \"undo\", \"type\": \"consumable\", \"title\": \"Undo move\", \"priceTickets\": 1 },\n { \"id\": \"gems_100\", \"type\": \"consumable\", \"title\": \"100 Gems\", \"priceTickets\": 50 },\n { \"id\": \"gold_dice\", \"type\": \"entitlement\", \"title\": \"Golden Dice skin\", \"priceTickets\": 200,\n \"maxPerPlayer\": 1 },\n { \"id\": \"premium\", \"type\": \"entitlement\", \"title\": \"Premium unlock\", \"priceUSD\": 0.99 }\n ]\n }\n}\n```\n\n**The ticket–USD peg, stated plainly: USD 1 = 100 tickets, equivalently 1 ticket = $0.01 (one US\ncent).** Price each SKU with whichever field matches how you think about it — never both:\n- `priceTickets` (integer, `1`–`10000`) — the canonical unit everything is stored/ledgered in.\n- `priceUSD` — a convenience for porting an existing USD price list mechanically (`0.99` → `99`\n tickets). Must be a **whole number of cents**; `0.999` is rejected outright, because sub-cent\n pricing doesn't exist at this peg. Converts to `priceTickets` at parse/materialization time —\n the portal's runtime and ledger only ever see the integer ticket price, never your `priceUSD`.\n\nOther rules the validator enforces:\n- `id` must match `^[a-z0-9_]{1,40}$` and be unique within your manifest.\n- Up to **200 SKUs per game**.\n- `type: \"consumable\"` — bought, then burned by your own game via `consume()` (a gem pack, an\n undo, a boost). The portal tracks a units-purchased/units-consumed balance; it never knows what\n a \"gem\" is, only that this SKU was bought and later burned.\n- `type: \"entitlement\"` — bought once, owned durably, forever, restorable on any device (a skin,\n \"remove ads\", a premium mode). Defaults `maxPerPlayer` to `1` if you don't set it explicitly —\n set a higher `maxPerPlayer` only if you genuinely want a player able to own more than one\n (rare for an entitlement; more common for a consumable pack with a purchase cap).\n\n**SKUs go live only when the build that declares them is approved and deployed** — exactly like\neverything else in `miniant.json` ([manifest reference](/guide/requirements/technical)). A reviewer\nsees a diff of exactly what's new, changed, or removed between the awaiting build and what's\ncurrently live, prices included. A roll-back to an earlier build re-materializes *that* build's\nSKUs — the live SKU catalog can never drift from the live code, in either direction.\n\n## `Miniant.wallet.purchase({ skuId, quantity? })`\n\n```ts\nconst result = await Miniant.wallet.purchase({ skuId: \"gems_100\", quantity: 2 });\n// { status: \"completed\", receipt: { receiptId, skuId, quantity, ticketsPaid, signedReceipt? } }\n// { status: \"declined\", code: \"insufficient_funds\" | \"declined\" | \"guest_not_allowed\" |\n// \"unavailable\" | \"sku_not_found\" | \"already_owned\" | \"sku_not_registered\" }\n```\n\n- `quantity` defaults to `1`, capped at `100`, and is **consumables-only** — an entitlement SKU\n must be bought with quantity 1 (omit the field, or pass `1` explicitly).\n- **An `entitlement` purchase gets the portal's own Confirm/Cancel dialog** — reviewed title and\n registered price × quantity (e.g. \"Golden Dice skin — 200 tickets ($2.00)?\"), pulled from the\n SKU registry your build declared, never from any string your game passes at call time. **A\n `consumable` purchase debits instantly with no dialog** (2026-08-27 ruling: an undo or extra\n life is a fast in-game action, not a considered purchase — your own control must show the price\n before the tap, e.g. \"Undo · [Ticket mark] 2\", see the Ticket mark note below). Either way the pricing invariant holds: **a game can never\n charge a player an amount its own code makes up** — `purchase()` only ever buys a\n manifest-declared, portal-reviewed, portal-priced SKU.\n- Never rejects for a normal decline (insufficient funds, the player cancelling, a guest session,\n already owning a `maxPerPlayer`-capped entitlement) — branch on `result.status`, no try/catch\n needed for \"the player said no\" or \"they already own it.\"\n- `receipt.signedReceipt` is present when the platform has a signing key configured (it usually\n does) and is for your own backend, not your client-side game code — see\n [Receipt verification](/guide/sdk/receipts) if you run a developer server that needs to verify a\n purchase independently of your own client.\n\n## `Miniant.wallet.getEntitlements()`\n\n```ts\nconst { entitlements } = await Miniant.wallet.getEntitlements();\n// [{ skuId: \"gold_dice\", kind: \"entitlement\" }, { skuId: \"gems_100\", kind: \"consumable\", unitsRemaining: 3 }]\n```\n\nCall this on boot to discover what this player owns — on *any* device, since ownership lives\nportal-side, not in your own save data. `unitsRemaining` is only meaningful for `kind:\n\"consumable\"`; an entitlement's ownership is the array entry's mere presence.\n\n**Rehydration rule:** if your game declares both `capabilities.wallet` and `capabilities.saveState`,\nany state that references an owned item (an equipped skin, an unlocked mode) must be re-derived\nfrom `getEntitlements()` on load, never trusted from `state.load()` alone. Saved state can go\nstale (a refund revokes an entitlement portal-side with no way to also rewrite your saved blob);\n`getEntitlements()` is always the live, authoritative answer. This is checked in review for any\ngame declaring both capabilities.\n\n## `Miniant.wallet.consume({ skuId, units, idempotencyKey })`\n\n```ts\nconst result = await Miniant.wallet.consume({ skuId: \"gems_100\", units: 20, idempotencyKey: \"convert-to-gold-1\" });\n// { ok: true, unitsRemaining: 30 }\n// { ok: false, code: \"...\", unitsRemaining: 50 }\n```\n\nBurns already-purchased consumable units — this is how a portal-side \"100 Gems\" purchase becomes\nyour game's own internal gem balance, or is spent one-for-one on in-game actions, whichever model\nyou use. Never touches the wallet's ticket balance; it only decrements the SKU's own\nunits-purchased/units-consumed counter, and only ever downward, never below zero.\n\n**`idempotencyKey` is yours to set, not the SDK's** — you own consume-idempotency, since only your\ngame knows whether a given purchase-to-internal-currency conversion already happened (e.g. after a\nnetwork retry or a reload mid-conversion). A repeated call with the same key burns nothing a second\ntime and just returns the current state. Use a key that's stable for \"this exact conversion event\"\n(e.g. `\"${receiptId}-initial-grant\"`), not a fresh random value per call, or every retry will look\nlike a brand-new burn.\n\n## `wallet.on(\"entitlements_changed\", cb)`\n\nFires with no arguments after any successful purchase. A conformant listener re-calls\n`getEntitlements()` to fetch the new state rather than trying to infer what changed from the event\nitself — treat it purely as an invalidation signal.\n\n## Migrating an existing `requestSpend` call\n\n`Miniant.wallet.requestSpend({ amount, reason })` still works, but only as sugar over a registered\nSKU now — it is honored **only** when your manifest declares a consumable SKU with `id === reason`\nand `priceTickets === amount`. Any other pair now declines with `sku_not_registered` instead of\ndebiting an arbitrary, unreviewed amount. Migrating is a manifest-only change, no game code touched.\n\n**`requestSpend` never opens a confirm dialog, even when it resolves against a `skus` entry.**\nThe portal debits the moment `requestSpend` matches a registered SKU at the right price, exactly\nthe way a declared `pricing.actions` price debits instantly (see below): no Confirm/Cancel step,\nno \"are you sure.\" There is no way to opt a consumable into a dialog — a consumable is instant\nvia `wallet.purchase()` too; only an `entitlement` purchase ever shows the portal's confirm\ndialog. Show the price on your own control instead.\n\n```ts\n// Game code — unchanged either way:\nawait Miniant.wallet.requestSpend({ amount: 1, reason: \"undo\" });\n```\n\n```jsonc\n// miniant.json — add this SKU, nothing else:\n{\n \"capabilities\": { \"wallet\": true, \"relay\": false, \"saveState\": false },\n \"store\": { \"skus\": [\n { \"id\": \"undo\", \"type\": \"consumable\", \"title\": \"Undo move\", \"priceTickets\": 1 }\n ]}\n}\n```\n\nNew integrations should call `wallet.purchase({ skuId: \"undo\", quantity: 1 })` directly instead of\n`requestSpend` — the back-compat shim exists for games that already shipped `requestSpend`, not as\na second way to design a new integration.\n\n## Declared action prices (undo, extra life)\n\nThe SKU registry above models a deliberate, store-UI purchase — a player taps \"Buy,\" picks a price\nthey already know. Some actions don't fit that shape: an undo that gets pricier the more you lean\non it in the same turn, an extra life whose cost escalates the longer a run goes. For those, declare\na `pricing` block instead of (or alongside) `store`:\n\n```jsonc\n{\n \"capabilities\": { \"wallet\": true, \"relay\": false, \"saveState\": false },\n \"pricing\": {\n \"actions\": {\n \"undo\": { \"tiers\": [2, 8, 15], \"maxPerTurn\": 3 }\n }\n }\n}\n```\n\nEach entry in `actions` is keyed by an action id (same `^[a-z0-9_]{1,40}$` shape a SKU `id` uses)\nand declares:\n- `tiers` — 1 to 10 integers, `1`–`10000` tickets each, **non-decreasing**. `[2, 8, 15]` means the\n first undo this turn costs 2, the second costs 8, the third costs 15 — a later use of the same\n action can never be cheaper than an earlier one.\n- `maxPerTurn` (optional) — caps how many priced uses of the action a single turn allows.\n\nLike `store`, `pricing` requires `capabilities.wallet: true` and only takes effect once the build\nthat declares it is approved and deployed.\n\n**Your game calls the same `requestSpend`, nothing new to learn:**\n\n```ts\n// First undo this turn:\nawait Miniant.wallet.requestSpend({ amount: 2, reason: \"undo\" });\n// Second undo, same turn:\nawait Miniant.wallet.requestSpend({ amount: 8, reason: \"undo\" });\n```\n\nAn `amount` is accepted only when it's one of `pricing.actions[\"undo\"].tiers` exactly — the portal\nvalidates this server-side (declining `sku_not_registered` otherwise, the same code an unregistered\nSKU gets), so there's no way to request a price your manifest didn't declare. The legacy SKU-shim\npath (`store.skus` with a matching `id`/`priceTickets`) is checked first and still works unchanged\nif you already have one; `pricing` is only consulted when no SKU matches.\n\n**Escalation order and `maxPerTurn` are entirely your game's own responsibility.** The portal has no\nnotion of \"which use this is\" or \"how long the turn has been going\" — it only ever checks that the\n`amount` you send is *some* declared tier for that action, not that it's the *right* one for this\nparticular use. Your own code decides the first undo this turn should ask for tier 0's price, tracks\nthat it already spent one, and asks for tier 1's price on the second — the portal simply won't debit\nanything outside the declared list.\n\n**This does not open a confirm dialog, same as every `requestSpend` call.** The portal debits a\ndeclared action price **immediately** the moment `requestSpend` resolves the tiers check — no\nConfirm/Cancel step, no \"are you sure.\" That's deliberate: an undo (or any other action-priced\nspend) is a fast, repeated, in-the-moment control, not a considered purchase decision, and a\nconfirm dialog on every use would make the control unusable. The trade-off is that **your own UI\nis now the only place the player sees the price before it's charged** — the portal never shows it\none. Your undo control must display the price itself before the tap (e.g. \"Undo · [Ticket mark] 2\"), reading\nthe next tier from your own escalation tracking (the same tracking `maxPerTurn` already requires\nyou to keep, above). Call `Miniant.wallet.getBalance()` to disable the control when the player\nclearly can't afford the next tier, and handle an `insufficient_funds` decline gracefully either\nway (the portal still shows its own translucent low-balance notice on that decline, but your\ncontrol should not be left in a state that implies the spend went through).\n\n**The Ticket mark.** Show every Ticket price with the Miniant Ticket mark, the same one the portal\nuses, leading the number like a currency sign: the mark, then \"2\". Load it from\n`https://www.miniant.games/brand/ticket-mark.png` (122×96, transparent; it is served\ncross-origin-readable, so it loads inside your game frame) and draw it about 1em wide beside the\nnumber. Never an emoji, a line icon or a ticket of your own.\n\n**The rule, stated once and generally: only an `entitlement` purchase ever opens a confirm\ndialog.** Everything a game spends mid-play is instant — `requestSpend` against a `skus` entry\n(see [Migrating an existing `requestSpend` call](#migrating-an-existing-requestspend-call) above),\n`requestSpend` against a declared `pricing.actions` entry here, and `wallet.purchase()` of a\n`consumable` SKU all debit the moment the portal validates the amount/price, with nothing shown\nin between. Only `wallet.purchase()` of an `entitlement` (a permanent unlock — bought rarely and\ndeliberately) gets the reviewed title/price confirm dialog described earlier in this guide.\n"},{"slug":"sdk/cosmetics","title":"Cosmetics","section":"SDK","agentSummary":"Cosmetics come from two separate systems, developer-sold entitlement SKUs and the portal's own cosmetic catalog; this page covers rendering and ownership rules for both.","checklist":[{"id":"cosmetics.no-gameplay-gate","text":"Never gate gameplay, levels, or an advantage behind a cosmetic on either the SKU or portal-catalog system.","verify":"manual"},{"id":"cosmetics.default-for-unknown","text":"Render your game's own default appearance for any unrecognized or unowned cosmetic/entitlement id, never an error or broken asset.","verify":"manual"},{"id":"cosmetics.frame-avatar-portal-only","text":"Never render the frame or avatar cosmetic slots inside your own game; those are drawn only by the portal's own chrome.","verify":"manual"}],"body":"# Cosmetics — yours and the portal's\n\nMiniant has two entirely separate cosmetic systems, and it's worth keeping them straight before you\nbuild anything: **yours** (SKUs you declare and sell, art you supply) and **the portal's** (a\ncatalog the portal itself owns, prices, and sells — you just render the ids it hands you). Nothing\nstops a game from using both.\n\n## Yours: entitlement SKUs\n\nA cosmetic you sell — a board skin, a dice reskin, an \"unlock the gold set\" bundle — is just an\n`entitlement`-kind SKU in your `store.skus` manifest block, exactly like any other durable unlock.\nSee [In-game purchases](/guide/sdk/purchases) for the full `store.skus` shape, the `priceTickets`/\n`priceUSD` fields, and `Miniant.wallet.purchase()`. The short version for a cosmetic specifically:\n\n```jsonc\n{\n \"capabilities\": { \"wallet\": true, \"relay\": false, \"saveState\": false },\n \"store\": {\n \"skus\": [\n { \"id\": \"gold_dice\", \"type\": \"entitlement\", \"title\": \"Golden Dice skin\", \"priceTickets\": 200,\n \"maxPerPlayer\": 1 }\n ]\n }\n}\n```\n\nTo make an item players equip into a named slot of your game (a cue, a jersey) and earn 70% of\nthe Tickets spent on it, see [Store items](/guide/sdk/store-items).\n\nYou supply the art and the id; the portal prices it, reviews it, and sells it through its own\nConfirm/Cancel wallet dialog — your game never renders a store UI or sets a price for it (see\n[Declared action prices](/guide/sdk/purchases#declared-action-prices-undo-extra-life) for the one\nother place prices can come from, and why a cosmetic is never one of them).\n\n**Reading ownership — two calls, two different seats:**\n\n- `Miniant.wallet.getEntitlements()` — the **local player's own** owned SKUs (consumables and\n entitlements both). Call it on boot to discover what this player owns, on any device — see\n [In-game purchases](/guide/sdk/purchases#miniantwalletgetentitlements) for the full contract and\n the rehydration rule if you also declare `capabilities.saveState`.\n- `context.participants[].entitlements` — every **other seat's** owned *entitlement-kind* SKU ids\n (a bare `string[]`, e.g. `[\"gold_dice\"]`), present on `PlayContext` from `init()` onward with no\n relay round-trip. This is how you render an opponent's golden board without ever asking them or\n the relay — you already have the answer at session start. It's present for every participant,\n including your own entry (the same value `getEntitlements()` would give you, filtered to\n entitlement kind and flattened to bare ids) — so one code path can read any seat's ownership the\n same way, local or remote.\n\n```ts\nconst context = await Miniant.init({ sdkVersion: 1 });\nfor (const p of context.participants) {\n const ownsGoldDice = p.entitlements?.includes(\"gold_dice\") ?? false;\n // render p's board however your game likes, gold_dice or default\n}\n```\n\n**Consumables never appear in `participants[].entitlements`** — a units-metered burnable (a gem\npack, an undo) was never \"just owned\" the way a cosmetic is, so it's filtered out entirely, same\nrule `wallet.getEntitlements()`'s own `kind: \"entitlement\"` vs `kind: \"consumable\"` split follows.\nAn id you don't recognize, or an id absent from the list at all (unowned, a guest, a bot-filled\nseat, or simply an older portal build that predates this field), all mean the same thing: **render\nyour game's own default appearance.** Never treat an absent or unrecognized entitlement as an\nerror, and never block a seat from playing over one — a cosmetic decorates, it never gates.\n\n## The portal's: `cosmetics` on `player`/`participants`\n\nSeparately, every seat also carries `cosmetics` — ids from the **portal's own** cosmetic catalog,\npriced, sold, and equipped entirely through the portal's own wallet dialog and equip UI. Your game\nnever sells these, never prices them, and never even knows they exist until they show up on\n`PlayContext`:\n\n```ts\nplayer: { id, displayName, avatarUrl, isGuest, locale, seat, countryFlag, cosmetics, entitlements }\n```\n\n```ts\ncosmetics: { boardTheme?, diceSkin?, tokenSkin?, striker?, frame?, avatar?, emotePack? }\n```\n\nSee [User](/guide/sdk/user#cosmetics) for the full field-by-field breakdown — the short version:\n`boardTheme`/`diceSkin`/`tokenSkin`/`striker`/`emotePack` are **optional hints your game MAY\nhonour** (maintain your own id → asset table, and always fall back to your default appearance for\nanything you don't recognize); `frame` and `avatar` are rendered by the **portal's own chrome**\n(player list, spectator panel, leaderboard, profile pages) and never inside your iframe at all —\nignore both unless your game draws its own seat portraits it wants to keep visually consistent with\nthe portal's.\n\n## Yours vs. the portal's, side by side\n\n| | Yours (`store.skus`, `type: \"entitlement\"`) | The portal's (`cosmetics`) |\n|---|---|---|\n| Who supplies the art | You | The portal |\n| Who prices it | You (`priceTickets`/`priceUSD`) | The portal |\n| Who sells it | The portal's wallet dialog, from your manifest | The portal's own store UI |\n| Where you see ownership | `wallet.getEntitlements()` (yours), `participants[].entitlements` (everyone's) | `player.cosmetics` / `participants[].cosmetics` |\n| Slots | None — a flat SKU id | `boardTheme`, `diceSkin`, `tokenSkin`, `striker`, `frame`, `avatar`, `emotePack` |\n| Rendered by | Your game, entirely | Your game (most slots) or the portal's own chrome (`frame`, `avatar`) |\n\n## Rules that apply to both\n\n- **Never set a price in game code**, for either system — a SKU's price lives in your manifest\n (reviewed and materialized at build-approval time), and the portal cosmetic catalog's prices are\n entirely the portal's own. Your code only ever reads ids and ownership, never writes a number.\n- **Never gate gameplay behind a cosmetic.** Both systems exist to decorate, not to unlock rules,\n levels, or an advantage — a player who owns nothing must be able to play your game exactly like\n one who owns everything. Reviewers check for this the same way they check any other purchase\n flow (see [what you must not build](/guide/requirements/technical)).\n- **Unknown or unowned always means \"render your default.\"** Never block, error, or show a broken\n asset for an id you don't recognize, on either system — a new catalog item ships far more often\n than your game's own asset table does.\n\nSee [In-game purchases](/guide/sdk/purchases) for the full SKU/pricing contract, and\n[Declared action prices](/guide/sdk/purchases#declared-action-prices-undo-extra-life) for the\nescalating-cost `pricing.actions` mechanism — cosmetics never use it (a cosmetic's price never\nescalates with use), but it's the other place a `store`-less price can legitimately come from.\n"},{"slug":"sdk/store-items","title":"Store items","section":"SDK","agentSummary":"Store items: declare slots and cosmetic SKUs in miniant.json, read the equipped id from player.cosmetics, admin reviews each item, you earn 70% of the Tickets spent from a monthly pool.","checklist":[{"id":"store-items.slot-ids-match-reads","text":"Each store.slots id is exactly the key your game reads from player.cosmetics (self) or participants[].cosmetics (everyone).","verify":"manual"},{"id":"store-items.price-in-band","text":"Every item's priceTickets is 100 to 1500 in steps of 50.","verify":"manual"},{"id":"store-items.preview-in-build","text":"Every item's preview is an image inside the build, 300 KB or less (webp recommended).","verify":"manual"},{"id":"store-items.unknown-id-default","text":"An equipped id your game does not recognise renders your default look, never an error.","verify":"manual"}],"body":"# Store items\n\nA **store item** is a cosmetic you make and the portal sells: a cue, a board theme, a jersey. You\ndeclare the changeable parts of your game (**slots**) and the items that fill them in\n`miniant.json`. They ship with a build, Miniant reviews each item, and the portal sells the ones\nthat pass. This is the same `store` block as [In-game purchases](/guide/sdk/purchases); an item is\nan `entitlement` SKU with a `cosmetic` block. For the portal's own catalog (frames, avatars, board\nthemes you do not sell) see [Cosmetics](/guide/sdk/cosmetics).\n\n## How items reach the store\n\nThere is no separate upload. Items travel inside your build:\n\n1. Put each item's preview image in your build, e.g. `store/gold_cue.webp`.\n2. Declare your slots and items in `miniant.json` (below).\n3. Make the game draw each item when it is equipped (below).\n4. Run `miniant-validate`, then publish a build the usual way ([Publishing your\n game](/guide/publishing)).\n5. Miniant reviews the build and each item with it. Approved items go on sale in the portal store\n when the build goes live; your game's **Store Items** tab shows each one's state.\n\nTo add, change or remove an item, publish a new build. Players buy items in the portal store, never\ninside your game, so a game that sells only store items does not need `capabilities.wallet`.\n\n## Declare slots\n\n```jsonc\n{\n \"store\": {\n \"slots\": [\n { \"id\": \"cue\", \"label\": \"Cue\", \"visibility\": \"everyone\" },\n { \"id\": \"boardTheme\", \"label\": \"Board theme\", \"visibility\": \"self\" }\n ]\n }\n}\n```\n\n- `id` is the key your game reads from the player's cosmetics: camelCase, 2 to 32 characters\n (`^[a-z][a-zA-Z0-9]{1,31}$`). Up to 16 slots.\n- `label` is what the store shows (up to 40 characters).\n- `visibility` is `\"self\"` when only the owner needs to see the item (a board theme), or\n `\"everyone\"` when opponents and spectators see it too (a cue, a jersey).\n\n## Declare items\n\n```jsonc\n{\n \"store\": {\n \"skus\": [\n {\n \"id\": \"gold_cue\",\n \"type\": \"entitlement\",\n \"title\": \"Gold cue\",\n \"priceTickets\": 400,\n \"cosmetic\": { \"slot\": \"cue\", \"preview\": \"store/gold_cue.webp\" }\n }\n ]\n }\n}\n```\n\n- `cosmetic.slot` must be a slot you declared.\n- `title` is at most 20 characters, and no single word in it is longer than 12 characters, so the\n name fits two lines on the smallest store tile.\n- `type` must be `\"entitlement\"` and the price must be `priceTickets` (not `priceUSD`): 100 to 1500\n in steps of 50. The band is fixed by the portal.\n- `cosmetic.preview` is a relative path to a `.webp`, `.png` or `.jpg` **inside your build** (no\n leading `/`, no `..`). Keep it to 300 KB or less; webp is recommended. The portal shows it in the\n store.\n\n## Read the equipped item\n\nThe portal hands your game what each seat has equipped. Read it from the slot id:\n\n```ts\nconst context = await Miniant.init({ sdkVersion: 1 });\n\n// \"self\" slot: read the local player's own item.\nconst boardTheme = context.player.cosmetics.boardTheme;\n\n// \"everyone\" slot: read every seat's item.\nfor (const p of context.participants) {\n const cue = p.cosmetics.cue; // e.g. \"gold_cue\", or undefined\n}\n```\n\nAn id you do not recognise, or no id at all, means **render your default look**. Never error and\nnever gate gameplay behind an item. See [User](/guide/sdk/user#cosmetics) for the full `cosmetics`\nfield.\n\n## Review\n\nEach item is reviewed with the build it ships in. An admin can approve the build and pull\nindividual items; a pulled item is not sold, and a later rollback or recovery never brings it\nback. The **Store Items** tab of your game shows every item you declared and whether it is live,\nrejected or no longer on sale.\n\n## What you earn\n\nYou earn **70%** of the Tickets players spend on your items, same split as ads\n([Monetization](/guide/publishing/monetization)). It is paid from a monthly pool:\n\n```\nTicket value = net Ticket-pack revenue for the month ÷ all Tickets spent that month\nYour share = 70% × (Tickets spent on your items) × Ticket value\n```\n\nPurchases by your game's own publishers do not count. The figure lands on your monthly statement\nas the item share, next to the ad share; both count toward the $50 payout threshold. A draft month\nis marked *estimate* until the month's pack revenue is final. See [Publishing your\ngame](/guide/publishing#earnings) for the Earnings page.\n"},{"slug":"sdk/receipts","title":"Receipt verification","section":"SDK","agentSummary":"Tier-2 developer servers can verify a completed purchase's signedReceipt offline via ECDSA P-256, and check live revocation via the receipts/verify endpoint.","checklist":[{"id":"receipts.check-game-slug","text":"Reject a verified receipt whose gameSlug does not match your own game's slug, both offline and via the verify endpoint.","verify":"manual"},{"id":"receipts.cache-ttl","text":"Put a TTL of at most about an hour on any cached entitlement derived from a receipt, and re-check rather than caching forever.","verify":"manual"},{"id":"receipts.no-shared-secret","text":"Never require or send an API key, account, or shared secret when calling the receipts verify endpoint; the signed token is the authorization.","verify":"manual"}],"body":"# Receipt verification\n\nIf you run your own backend (`authorityModel: \"developer_server_validates\"`, tier 2 — see the\nanti-cheat questionnaire in [Publishing your game](/guide/publishing)), you can verify a\n[purchase](/guide/sdk/purchases) independently of your own game client, two ways: offline, and with\na live revocation check.\n\n## `signedReceipt` — verify offline\n\nEvery completed purchase gets a `signedReceipt` — a compact token over\n`{ receiptId, subjectId, gameSlug, skuId, quantity, ticketsPaid, issuedAt }`, signed **ECDSA P-256**\nwith a single platform keypair. Your game's client receives it in the `purchase()` result and\nforwards it to your own server (however you already get data there); your server verifies it with\nno Miniant account, credential, or shared secret of any kind — just the platform's public key,\nwhich is free to fetch:\n\n```js\n// Node 20+ / any runtime with a global crypto.subtle. Fetch once, cache indefinitely (the key\n// itself changes only on a deliberate platform-side rotation, which would be a breaking change we'd\n// announce).\nconst { jwk } = await fetch(\"https://www.miniant.games/api/publisher/receipts/public-key\").then((r) => r.json());\nconst publicKey = await crypto.subtle.importKey(\"jwk\", jwk, { name: \"ECDSA\", namedCurve: \"P-256\" }, false, [\"verify\"]);\n\nfunction base64UrlDecode(value) {\n const padded = value.replace(/-/g, \"+\").replace(/_/g, \"/\").padEnd(Math.ceil(value.length / 4) * 4, \"=\");\n return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));\n}\n\nasync function verifyReceiptOffline(signedReceipt, yourOwnGameSlug) {\n const [body, signature] = signedReceipt.split(\".\");\n const ok = await crypto.subtle.verify(\n { name: \"ECDSA\", hash: \"SHA-256\" },\n publicKey,\n base64UrlDecode(signature),\n new TextEncoder().encode(body),\n );\n if (!ok) return { valid: false, reason: \"bad_signature\" };\n\n const payload = JSON.parse(new TextDecoder().decode(base64UrlDecode(body)));\n // payload: { receiptId, subjectId, gameSlug, skuId, quantity, ticketsPaid, issuedAt }\n\n // YOUR responsibility, not the platform's: a valid signature only proves the platform signed\n // this receipt for *some* game. Reject anything that isn't yours.\n if (payload.gameSlug !== yourOwnGameSlug) return { valid: false, reason: \"wrong_game\" };\n\n return { valid: true, payload };\n}\n```\n\nA tier-0/1 game can simply ignore `signedReceipt` — nothing about the purchase flow requires you to\nverify it. It exists for the one case a client-trusting backend can't otherwise close: a modified\ngame client claiming a purchase happened that never did.\n\n## `POST /api/publisher/receipts/verify` — the one thing offline verification can't tell you\n\nA valid signature proves a receipt is genuine and unmodified; it cannot tell you whether the\npurchase has since been **refunded**. For that, call:\n\n```js\nconst res = await fetch(\"https://www.miniant.games/api/publisher/receipts/verify\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ signedReceipt }),\n});\nconst body = await res.json();\n// { status: \"valid\", receipt: { receiptId, subjectId, gameSlug, skuId, quantity, ticketsPaid, issuedAt } }\n// { status: \"revoked\", revokedAt: \"2026-...\", receipt: { ... } }\n// { status: \"invalid\" } -- 400: tampered token, bad signature, or malformed body. Same shape\n// regardless of which — don't try to distinguish the reason.\n```\n\n**No API key, account, or credential of any kind is required or accepted.** Possession of a\nvalidly-signed `signedReceipt` token is itself the entire authorization — the token already proves\nthe purchase is real and names its own `gameSlug`, so requiring anything more would be redundant.\nThis also means the endpoint does **not** check that the receipt belongs to *your* game — that's\nthe same check you already do offline (`payload.gameSlug !== yourOwnGameSlug` above); apply it to\nthis endpoint's response too before trusting a `\"valid\"` result.\n\n**Recommended cache TTL: re-check periodically, don't cache \"owns this\" forever.** If your server\ncaches an entitlement (e.g. \"this player owns `gold_dice`\") to avoid a network call on every\nrequest, put a TTL on that cache — somewhere in the range of a few minutes to at most an hour for\nanything gameplay-affecting, shorter if you can afford the traffic. A refund revokes the\nentitlement portal-side immediately; your cache won't know until it re-checks.\n[`getEntitlements()`](/guide/sdk/purchases) is the equivalent live check from your game's own\nclient side, for the same reason.\n"},{"slug":"sdk/progress-import","title":"Progress import","section":"SDK","agentSummary":"Tier-2 games with an importEndpoint receive a one-way, player-initiated, cosmetic-only progress import request signed by Miniant; documents the request/response contract.","checklist":[{"id":"import.https-only-endpoint","text":"Declare importEndpoint as an https:// URL; Miniant refuses to call anything else and never follows redirects from it.","verify":"manual"},{"id":"import.verify-purpose-and-slug","text":"Verify the assertion's signature, then check payload.purpose === 'progress_import' and payload.gameSlug matches your own game before trusting it.","verify":"manual"},{"id":"import.never-grants-rank-value","text":"Never let imported progress grant Tickets, affect rank, leaderboard position, or tournament eligibility; treat it as cosmetic progress only.","verify":"manual"},{"id":"import.respond-within-budget","text":"Respond with a 2xx JSON body containing blob within the 8-second timeout and roughly 65KB response cap.","verify":"manual"},{"id":"import.blob-under-cap","text":"Keep the returned blob within the same 64KB cap enforced on Miniant.state.save().","verify":"manual"}],"body":"# Progress import\n\nIf your game is also distributed outside Miniant (e.g. a Play Store release) and you run your own\nbackend (`authorityModel: \"developer_server_validates\"`, tier 2 — see the anti-cheat questionnaire\nin [Publishing your game](/guide/publishing)), you can declare an `importEndpoint` your own server\nimplements. A signed-in player who owns your externally-distributed build can then pull their\nprogress from it into Miniant, once, on their own request — cosmetically only, forever.\n\nThis is entirely opt-in. A tier-0/tier-1 game, or a tier-2 game that never sets `importEndpoint`,\nsimply never offers this — nothing else about your integration changes.\n\n## Declaring it\n\nAdd `importEndpoint` to your `miniant.json`, alongside your other manifest fields:\n\n```json\n{\n \"sdkVersion\": 1,\n \"importEndpoint\": \"https://your-backend.example.com/miniant/import\"\n}\n```\n\nIt must be `https://` — Miniant's server refuses to call anything else, and refuses to follow a\nredirect from it. This is the one endpoint anywhere in the SDK/manifest surface where Miniant's own\nserver calls a URL you control, so it's treated accordingly: HTTPS-only, no redirects, an 8-second\ntimeout, and a hard cap on how much of your response it will read (about 65KB — see \"What you\nreturn\" below). None of this is configurable from your side; build to it rather than around it.\n\n## What Miniant sends you\n\nA signed-in player who taps \"Import progress\" on your game's page triggers exactly one request,\ninitiated by Miniant's server, never by your game's client code:\n\n```\nPOST https://your-backend.example.com/miniant/import\nContent-Type: application/json\nAccept: application/json\n\n{ \"assertion\": \"<base64url-body>.<base64url-signature>\" }\n```\n\n`assertion` is a compact signed token — same envelope shape as a purchase's `signedReceipt` (see\n[Receipt verification](/guide/sdk/receipts)): base64url `{body}.{signature}`, **ECDSA P-256 +\nSHA-256**, signed with the same platform keypair a receipt is. Verify it exactly the same way,\nagainst the same already-published public key:\n\n```js\n// Node 20+ / any runtime with a global crypto.subtle. Fetch once, cache indefinitely — see\n// Receipt verification's own note on this: the key only changes on a deliberate, announced rotation.\nconst { jwk } = await fetch(\"https://www.miniant.games/api/publisher/receipts/public-key\").then((r) => r.json());\nconst publicKey = await crypto.subtle.importKey(\"jwk\", jwk, { name: \"ECDSA\", namedCurve: \"P-256\" }, false, [\"verify\"]);\n\nfunction base64UrlDecode(value) {\n const padded = value.replace(/-/g, \"+\").replace(/_/g, \"/\").padEnd(Math.ceil(value.length / 4) * 4, \"=\");\n return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));\n}\n\nasync function verifyImportAssertion(assertion, yourOwnGameSlug) {\n const [body, signature] = assertion.split(\".\");\n const ok = await crypto.subtle.verify(\n { name: \"ECDSA\", hash: \"SHA-256\" },\n publicKey,\n base64UrlDecode(signature),\n new TextEncoder().encode(body),\n );\n if (!ok) return { valid: false, reason: \"bad_signature\" };\n\n const payload = JSON.parse(new TextDecoder().decode(base64UrlDecode(body)));\n // payload: { pairwiseSubjectId, gameSlug, purpose, linkCode, issuedAt, nonce }\n\n // Two checks that are YOUR responsibility, same as with a receipt: a valid signature only\n // proves Miniant signed this for *something*.\n if (payload.purpose !== \"progress_import\") return { valid: false, reason: \"wrong_purpose\" };\n if (payload.gameSlug !== yourOwnGameSlug) return { valid: false, reason: \"wrong_game\" };\n\n return { valid: true, payload };\n}\n```\n\n**`purpose: \"progress_import\"`** is what stops this token from ever being confused with a purchase\nreceipt (or any future signed-token type) by a server that verifies the signature correctly but\nskips checking what the token is actually *for*. Reject anything that isn't exactly this value, the\nsame way you already reject a receipt that isn't for your own `gameSlug`.\n\nThe verified payload:\n\n| Field | Meaning |\n| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `pairwiseSubjectId` | See \"The pairwise subject id\" below. **Never the player's real Miniant account id.** |\n| `gameSlug` | Your game's slug — check it equals your own, exactly as above. |\n| `purpose` | Always the literal string `\"progress_import\"` for a valid request to this endpoint. |\n| `linkCode` | The proof-of-ownership code the player typed into Miniant's own import dialog. **Opaque to Miniant** — it never validates or interprets this value, only forwards it. Its format, length, and meaning are entirely yours to define and document to your players (an account ID, a redeem code, whatever your own external build already uses to identify an account). |\n| `issuedAt` / `nonce` | Token freshness bookkeeping, informational — mirrors the same fields on a purchase receipt. |\n\n## The pairwise subject id\n\n`pairwiseSubjectId` is **not** the player's real Miniant account id, and there is no way to derive\none from the other. It's a per-`(player, your game)` pseudonym: the same Miniant player gets a\n*different*, unlinkable `pairwiseSubjectId` for every different tier-2 game they import into — so\nif you publish two tier-2 games with import endpoints, you cannot tell from this id alone that the\nsame person imported into both. Treat it as an opaque key scoped to this one game, suitable for\n\"has this player already imported once\" bookkeeping on your own side, nothing more.\n\n## What you return\n\nA 2xx JSON response, within the 8-second budget and the ~65KB response cap mentioned above:\n\n```json\n{\n \"blob\": { \"...\": \"whatever your game's own save shape is\" },\n \"label\": \"Level 12 — Forest Temple\",\n \"progressPct\": 48\n}\n```\n\n- **`blob`** (required) — your save data, any JSON-serializable value. It's stored through the\n exact same path and the exact same **64KB cap** as `Miniant.state.save()` ([Data](/guide/sdk/data))\n — an oversized blob is rejected the same way an oversized `state.save()` call would be. This is\n the only field that actually becomes the player's new Miniant save for this game; get it into the\n same shape your own game's `state.load()` already expects to read back.\n- **`label`** (optional, 1–200 characters) — cosmetic slot metadata, same meaning as `state.save()`'s\n own `meta.label` ([Data](/guide/sdk/data)) — purely for Miniant's own \"Resume — {label}\" card.\n- **`progressPct`** (optional, integer 0–100) — same idea, for a progress indicator.\n\nMissing the `blob` key at all, a non-2xx status, an oversized or non-JSON body, or a timeout are all\ntreated identically by Miniant as \"the import failed\" — the player sees a generic error, and none of\nyour response body or the specific failure reason is ever echoed back to their browser. Log\nwhatever detail you need on your own side; there is nothing to negotiate with Miniant about the\nfailure reason.\n\n## Repeatable, one-way, cosmetic-only — permanently\n\nA few rules that never change, regardless of what your endpoint returns:\n\n- **One-way.** Miniant calls your endpoint; your endpoint is never called back, polled, or\n webhooked by Miniant afterward. There is no ongoing sync in either direction.\n- **Player-initiated, every time.** Nothing about this endpoint is ever called automatically or in\n the background — only when a signed-in player explicitly opens Miniant's import dialog and\n confirms, once per attempt.\n- **Repeatable, and destructive on Miniant's side.** A player can import again later (e.g. after\n more progress in your externally-distributed build); each successful import **overwrites** their\n existing Miniant save slot for this game. Miniant's own dialog tells the player this before every\n attempt — you don't need to.\n- **Cosmetic-only, with no exception.** Whatever `blob` claims — level, currency-looking fields,\n anything — it is progress and only ever progress once inside Miniant. It never grants Miniant\n Tickets, never affects rank or leaderboard position, and never confers tournament eligibility, no\n matter what state the player reached in your external build. See [Account\n integration](/guide/requirements/account-integration) and [Data](/guide/sdk/data)'s own \"Saved\n progress never buys rank\" section — the same firewall applies here, without exception. This is\n also covered as a binding term in the [Developer Terms](/terms) — read the \"Separate wallets and\n purchases\" clause if your game sells anything outside Miniant as well as inside it.\n"},{"slug":"sdk/multiplayer","title":"Multiplayer","section":"SDK","agentSummary":"Miniant.net is the only sanctioned way to exchange realtime gameplay messages in a room; documents connect/send/sendTo, syncRequest, and the message-rate budget.","checklist":[{"id":"mp.no-own-websocket","text":"Never open a custom WebSocket for gameplay messaging when capabilities.relay is declared; use Miniant.net exclusively.","verify":"validator:sdk.no-own-networking"},{"id":"mp.use-net-surface","text":"Use only Miniant.net connect/send/sendTo/on/close for relay messaging, the entire sanctioned surface.","verify":"validator:relay.net-used"},{"id":"mp.answer-sync-request","text":"Tag every complete-state broadcast {snapshot:true}, and answer net.on('syncRequest') the same way — that event fires only for a reconnecting GAME seat, never a spectator join.","verify":"manual"},{"id":"mp.send-action-at-input-time","text":"Send the action message the moment the input lands and the resolved outcome separately — never wait for your local animation or physics to finish before the first send.","verify":"manual"},{"id":"mp.honest-message-rate","text":"Declare an honest network.expectedMessagesPerMinute matching real traffic, not a padded value.","verify":"manual"},{"id":"mp.report-peer-availability","text":"Call Miniant.reportPeerAvailability at every seat-availability transition for a hand-rolled realtime game with an unavailable policy.","verify":"manual"}],"body":"# Multiplayer\n\nIf your manifest declares `capabilities.relay: true` ([manifest reference](/guide/requirements/technical)),\na room-based session (`online_friends` or `online_random`) hands you a non-null `context.room`, and\n`Miniant.net` is your only sanctioned way to exchange gameplay messages with the other seats in it —\nsee [what you must not build](/guide/requirements/technical), rule 3: **do not open your own\nWebSocket** for this unless you're also running your own validating (tier 2) server. For the\nrequirements-level framing — declaring modes, lobby sizes, and the `network` block — see\n[Multiplayer requirements](/guide/requirements/multiplayer). To let a friend watch a match without\njoining it, see [Spectating](/guide/sdk/spectating).\n\nThis page documents the raw `Miniant.net` surface — the contract for a `sync: \"realtime\"` mode,\nthe default when a mode omits `sync` ([Choose a sync\nmode](/guide/requirements/multiplayer#choose-a-sync-mode-for-an-online-mode)). If your mode\ndeclares `sync: \"turn_based\"` instead, use [Turn Kit](/guide/sdk/turn-kit) — it's built on this\nsame surface, so most of what's below (reconnect behavior, the message-rate budget) still applies,\nbut you drive it through `kit.endTurn()` rather than calling `net.send()` yourself.\n\n```ts\nawait Miniant.net.connect(); // opens the relay WebSocket for this session's room\n\nMiniant.net.on(\"message\", ({ from, data }) => {\n // `from` is the sending seat number; `data` is exactly what that peer passed to send()/sendTo(),\n // untouched by Miniant in transit.\n});\n\nMiniant.net.on(\"throttled\", ({ retryAfterMs, observedRate }) => {\n // You are sending faster than your own declared budget allows — back off. See \"Message-rate\n // budget\" below.\n});\n\nawait Miniant.net.send({ type: \"move\", x: 3, y: 4 }); // broadcast to every other seat\nawait Miniant.net.sendTo(\"1\", { type: \"ping\" }); // to one seat (seat number, as a string)\n\n// ... later, e.g. on your own \"leave game\" action ...\nawait Miniant.net.close();\n```\n\n## Send the action when it starts, not when it ends\n\nYou own the message shape in a `realtime` mode, and the single most common protocol mistake is\nsequencing: the acting client plays its own animation to completion, *then* sends the resolved\noutcome — a final velocity, a landed shot, a score. On the other seat that reads as a slideshow:\nnothing happens, then a result materializes mid-air with no wind-up and no tell. Send the action\nthe moment the input lands (the aim, the strike, the release — whatever starts the motion) and let\nthe receiving client animate from it; the resolved outcome is a *second* message, reconciled after\nthe fact. Anything sampled continuously (a run-up, a keeper drifting) streams as\n`{ reliable: false }` frames per the bullet below. This is the requirements-level rule at\n[Broadcast the action, not just the outcome](/guide/requirements/multiplayer) — and with\n`capabilities.spectate` declared, these same frames are what a spectator watches.\n\n## The `Miniant.net` surface\n\n- **`connect()` / `send()` / `sendTo()` / `on()` / `isConnected()` / `close()`** are the entire\n surface. `on()` returns an unsubscribe function.\n- **Know whether *you* are the one who dropped.** `isConnected()` reports whether your own socket\n is open right now, and `on(\"connected\", cb)` / `on(\"disconnected\", cb)` give you the edges —\n `connected` carries `{ reconnected }` (`false` on the session's first open, `true` on every\n re-open after a drop), `disconnected` carries `{ code, reason, willReconnect }`. This matters\n more than it looks: with no frames arriving, *\"my opponent went quiet\"* and *\"I lost the relay\"*\n are indistinguishable from inside your game. A game that decides **\"nobody has moved, so my\n opponent forfeited — I win\"** from silence alone gets that wrong under a network partition,\n where both clients reach the same conclusion at the same moment and each reports a win for the\n same match. Prefer a relay-reported `peerLeft` as the trigger for a forfeit; if all you have is\n silence, end the match as `abandoned` rather than declaring a winner.\n- **A `reconnected: true` is the moment to distrust your own state.** The relay can answer a cold\n reconnect from its own cached snapshot rather than from a live peer, and the two are\n indistinguishable on the wire — so the first snapshot you get back may be *older* than the true\n state. Apply it, but keep input locked until you hold a snapshot at or above the state version\n you last had.\n- **Calling `send`/`sendTo` before `connect()` resolves — never rejects.** Messages sent before the\n socket is open (including your very first call, before `connect()` has finished) are queued\n locally, in order, and flushed to the socket the moment it opens — you do not need to await\n `connect()` before you start calling `send`. The same queueing applies during a disconnect/\n reconnect: reliable sends made while offline queue rather than fail. Pass `{ reliable: false }`\n for a message that's only worth delivering *right now* (e.g. one frame's input sample) — an\n unreliable send that can't go out immediately is dropped, not queued, so you never deliver a\n stale value late.\n- **Reconnects automatically, with backoff**, on any unexpected disconnect — you never call\n `connect()` a second time yourself. The retry delay starts small and doubles up to a cap, reset\n after any successful reconnect. Your own `close()` call and a `roomClosed` event both stop\n reconnection — they're treated as final.\n- **Listen for `\"throttled\"`.** The relay enforces your own declared `network.expectedMessagesPerMinute`\n ([manifest reference](/guide/requirements/technical)) — exceeding it repeatedly gets you a\n `throttled` event first, and the relay closing your connection outright if you keep ignoring it.\n A conformant game reduces its send rate when it sees one, rather than treating it as informational\n only.\n- **No delivery acknowledgement exists.** `send`/`sendTo` resolving means \"handed to the transport,\"\n not \"received by a peer\" — build your own game-level ack if you need one.\n- `sendTo(peerId, payload)` — `peerId` is the target seat's number as a string (`\"1\"`, `\"2\"`, …).\n There is no peer identity independent of seat; `on(\"peerJoined\"/\"peerLeft\", cb)` payloads carry\n the same `peerId` string.\n\n## Absent players\n\nA seat going quiet mid-match — screen off, tab backgrounded, network drop, or the player switching\napps — is normal, not an error state. If your mode declares `unavailable` ([manifest\nreference](/guide/requirements/technical), [the policy\ncontract](/guide/requirements/multiplayer#when-a-player-becomes-unavailable)), the portal expects\nyour `realtime` game to implement the same availability semantics [Turn Kit](/guide/sdk/turn-kit)\ngets for free — this section is that recipe. A [Turn Kit](/guide/sdk/turn-kit) game never needs any\nof this itself; the kit already does it.\n\n**The recipe, mirroring [Turn Kit](/guide/sdk/turn-kit)'s own internal implementation exactly —\nbuild your own version of the same five things:**\n\n1. **A 5s liveness beacon, sent only while your tab is visible.** A hidden-tab beacon (or no\n beacon at all) is evidence of *absence*, never of liveness — a peer that stops beaconing, or\n whose beacons all say \"hidden,\" should be treated exactly like one that stopped sending\n entirely.\n2. **Away/back signaling on visibility transitions**, sent immediately on\n `visibilitychange`/`pagehide` rather than waiting for the next beacon — a fast path so the rest\n of the room learns \"gone\" and \"back\" without waiting out a full beacon interval.\n3. **A per-seat last-activity clock**, refreshed by any real game frame from that seat, a `\"back\"`\n signal, or a beacon that isn't marked hidden. A peer with no refresh for your mode's\n `timeoutSeconds` has gone unavailable.\n4. **A lowest-available-seat driver election**, staggered by rank: when a seat goes unavailable,\n the currently-available occupied seats (excluding the target) sort ascending, and each becomes\n eligible to act — auto-driving a move, or retiring/deciding the match under `\"forfeit\"` — at\n `deadline + rank * 3000ms`. The lowest-ranked candidate acts first; each further rank waits\n another 3s, so a still-silent target eventually gets handled even if a lower-ranked candidate has\n also gone quiet.\n5. **Call `Miniant.reportPeerAvailability({ seat, state, deadlineMs? })` at every transition** —\n `\"grace\"` when a seat's unavailability window begins (with `deadlineMs`, so the portal can render\n a countdown), `\"returned\"` when it comes back, and `\"auto\"`/`\"retired\"`/`\"forfeit\"` at your\n policy's own trigger points. **This is not optional decoration** — it's the only way the\n portal's grace/auto-pilot/forfeit banner chrome renders at all. Skip it and your game may work\n correctly while every player sees nothing but silence during a takeover.\n\n**Your frame bytes are your own** — the beacon/away/back/election messages above are payloads your\ngame defines and every peer's build already understands, since every seat runs the same build. What\nthe platform actually requires is the *semantics* (evidence, timing, election) and the\n`reportPeerAvailability()` calls — not any particular wire shape.\n\nA reconnecting peer's `peerJoined` (or your own `\"back\"` evidence) reclaims their seat exactly as if\nthey'd never left. The portal's own Exit flow gives you the same courtesy when a player leaves on\npurpose: it sends `terminate` with reason `\"player_exit\"` *before* it ends the session — your moment\nto broadcast a resignation message to the remaining seats, rather than that seat just going silent\nwith no explanation. `player_exit` **ends the game** and is recorded as a **forfeit loss** for that\nseat, never `\"abandoned\"` (2026-09-17) — the portal clears whatever it had saved for this\nplayer/game/mode the moment this fires, so there's nothing to flush for it — see\n[Scores and results](/guide/sdk/scores#reporting-results-in-an-online-multi-seat-session) for how\neach seat's own result reporting works in a room, and [Game](/guide/sdk/game)'s \"Pause, resume, and\nterminate\" for the full reason-by-reason contract.\n\nBuilding this yourself is real work — [Turn Kit](/guide/sdk/turn-kit) is the canonical\nimplementation of exactly this recipe (`onSeatUnavailable`/`onSeatAvailable`/`onAutoTurn`/\n`onSeatRetired`/`onMatchDecided`/`onSeatAutoDriven`, plus the `reportPeerAvailability` wiring, all\nbuilt in). If your mode's rotation is naturally turn-based, using it instead of hand-rolling this\nrecipe is almost always the better trade.\n\n**Spectators never appear here.** A spectator joining or leaving the room never fires `peerJoined`/\n`peerLeft` for anyone — spectators are invisible to the \"who's present\" story entirely, and a\nspectator join never triggers `syncRequest` either (removed 2026-09-21 — see\n[Spectating](/guide/sdk/spectating) for why). A spectator's own state bootstrap is served straight\nfrom the relay's cache of your last `{snapshot: true}`-tagged broadcast, entirely outside your\ngame's code — see [`snapshot:true`](/guide/sdk/spectating#snapshottrue-and-catching-up-a-spectator-mid-match).\n\n## Answering `syncRequest`\n\n**A conformant `realtime` game must answer `syncRequest`.** The relay sends\n`{\"type\":\"sync_request\"}` to exactly one connected game-channel socket when a game seat itself\n(re)joins a room that already has another live peer — a player refreshing mid-match, for example.\n**Not on a spectator join** — that case never sends this event (see \"Spectators never appear here\"\nabove). Your game is expected to respond by rebroadcasting a full state snapshot, the same one you\nalready send on a normal state change, tagged the same way:\n\n```ts\nMiniant.net.on(\"syncRequest\", () => {\n Miniant.net.send(getFullSnapshot(), { snapshot: true }); // the exact same snapshot shape your other broadcasts use\n});\n```\n\n**The relay marks your answer (SDK 2026.10.17).** A `{snapshot: true}` broadcast from the seat the\nrelay just asked, within 10 seconds of the request, reaches every client with `syncAnswer: true` on\nits `message` event (`NetMessageEvent.syncAnswer`). Nothing else carries it: a broadcast from any\nother seat, or one sent without `snapshot: true`, arrives unmarked. A game that adopts a peer's\nfull state on resync can therefore tell the real answer from a frame some other player chose to\nsend. Two rules follow:\n\n- **Send the answer with `{ snapshot: true }`**, as above. An untagged answer is never marked.\n- **Decide who may resync you, with `Miniant.net.resyncDropReason(event, rule)` (SDK 2026.10.18).**\n It returns `null` to take the frame, or the reason to drop it:\n\n ```ts\n Miniant.net.on(\"message\", (event) => {\n if (event.data?.k === \"sync\") {\n // \"sync\" is sent ONLY as the answer to syncRequest: take it with the relay's mark.\n if (Miniant.net.resyncDropReason(event, { kind: \"answer-only\" })) return;\n } else if (event.data?.k === \"snap\") {\n // \"snap\" is also your periodic board, unmarked by design: take it from the authority seat.\n if (Miniant.net.resyncDropReason(event, { kind: \"shared\", authority: hostSeat })) return;\n }\n applyFrame(event.data);\n });\n ```\n\n Do not use `answer-only` for a kind you also send in normal play: it is unmarked by design and\n would be dropped. A `shared` rule with no authority seat throws instead of trusting everyone.\n The mark needs the relay that sets it (2026-10-17) and ships relay-first, so a game that starts\n requiring it must be deployed after the relay carries it, or it refuses every resync.\n\n**What this does not cover (accepted limit).** The check judges who sent the frame, never what it\nsays. The seat that was asked, or the authority seat of a host-authoritative game, is still trusted\nfor the content: in a two-player match the opponent is the only seat the relay can ask, so a\ncheating opponent can send a false board inside the 10-second window. Closing that needs a server\nthat runs your game's rules, or every client replaying the match deterministically from a move log\n(Turn Kit games with a log can, with `applySnapshot(snapshot, source)`). Otherwise keep validating\nthe payload (ranges, history only ever appended) and expect the rest to be a trust decision.\n\nThis is existing behavior for any relay game, made explicit here: a `sync: \"turn_based\"` game\nusing [Turn Kit](/guide/sdk/turn-kit) gets this answered for you automatically and doesn't need the\nlistener above. A hand-rolled `realtime` game does — skipping it leaves a reconnecting peer stuck\nlooking at nothing until your next unrelated broadcast happens to fire. See\n[Spectating](/guide/sdk/spectating) for the full resync protocol, including why an old/cached SDK\nbuild that predates this event simply falls back to your next broadcast instead.\n\n## The relay answers every boot: \"no match yet\"\n\nA game seat that connects never has to guess from silence whether a match exists. Right after\n`hello`, the relay sends every **game-channel** connect one of:\n\n- the stored match, re-shaped as a Turn Kit `sync` marked `syncAnswer: true` and `replay: true` (a\n tagged `{snapshot: true}` frame exists: a `turn`, a `sync`, or a `checkpoint()` `state` frame, so\n a seat reloading after the first deal but before the first move gets that deal), or\n- when the room never held a tagged frame, this explicit no-match frame, also marked `syncAnswer`:\n\n ```json\n {\"type\":\"msg\",\"from\":-1,\"data\":{\"k\":\"sync\",\"seq\":0,\"snapshot\":null,\"currentSeat\":null,\"none\":true},\"syncAnswer\":true}\n ```\n\nIt is sent on every connect, including a seat joining beside live peers. Spectators never get it.\nRead it from the `message` event: `event.from === -1 && event.syncAnswer === true && event.data?.k === \"sync\" && event.data.none === true`.\nThat is the moment a host may deal a fresh match. **Check `from === -1`:** the relay also marks\n`syncAnswer` on a full-state broadcast from the peer it asked, without reading its `data`, so\nwithout that check an asked peer could send `none: true` and make a reloading seat deal over a live\nmatch. `event.replay` is likewise set only on the relay's own replay, never on a peer's frame. The\nTurn Kit uses it to let a seat with no state accept the seq-0 deal; a peer's seq-0 sync answer\nstays a harmless duplicate on every seat that saw the deal. A Turn Kit ignores the frame by itself (its\n`currentSeat` is not a number and its `seq` is 0, never newer than the kit's own), so the kit\ncannot apply it as a state. **A hand-rolled game using `resyncDropReason` with `answer-only` for\n`k: \"sync\"` must check `data.none` before applying `data.snapshot`.**\n\n**Turn Kit games: before your match exists, `getSnapshot` must return `null`.** The kit answers\nevery relay `sync_request` with your `getSnapshot()`. A non-null snapshot is sent as tagged complete\nstate, which the relay stores and from then on treats the room as holding a match. A `null` (or\n`undefined`) one is sent untagged, so the relay keeps answering \"no match yet\" to a host that reloads\nbefore the first deal. Return a placeholder object such as `{ bundle: null }` before the deal and\nthat reloading host is served the placeholder as if it were a match. An older SDK bundle tags the\nnull answer too; the relay ignores a tagged `sync` whose `snapshot` is null, so that is harmless.\n\n**Not a Turn Kit game?** When no live peer can be asked, a seat that boots alone is sent the room's\nlast tagged `{snapshot: true}` frame as it was broadcast (its own `from`, your game's own `data`),\nmarked `syncAnswer: true` and `replay: true`, even when that seat sent it itself. Adopt your full\nstate from it (for example `data.snap`) exactly as a watcher does; never deal fresh when you get it.\nYour sync request goes to the lowest other seat. The relay also sends the same stored frame, marked\n`syncAnswer: true` and `replay: true`, at once when that seat has reported its tab hidden (a sleeping tab\nnever answers), and after 6 s when it stays silent. This applies only to a tagged frame whose `data` has\nno top-level string `k` (a Turn Kit game keeps its own path). A hidden tab can still wake and answer, so\nyou may get the replay first and a live answer second: handle both, and adopt by your own version or seq\nrule, never by arrival order.\n\n**Two seats must never deal.** Once the room holds a match, the relay drops a seq-0 `checkpoint()`\n`state` frame from any seat but the one that dealt first, and any seq-0 state once the match has\nmoved past it. Moves and that dealer's own re-checkpoints pass as before. The dealer is the room's\nlowest human seat, taken from the seat list in the room token, never whoever connects first; if it has\nnot connected 8 s after the first human did, the lowest connected human seat deals instead (your own\npre-deal fallback should wait 10 s or more), and a dealer returning after that cannot deal again.\n\n**Who starts a match that is not a Turn Kit game: `dealSeats`.** On every game connect the relay sends a\ncontrol frame (never a `msg`, so `message` handlers do not see it) just before the boot answer, and\n`Miniant.net` emits it as `dealSeats`:\n\n```json\n{\"type\":\"deal_seats\",\"seats\":[0,1,2],\"dealer\":0,\"dealerAbsent\":false}\n```\n\n`seats` is the room's human seats, `dealer` the seat that may start. When the lowest seat has not\nconnected 8 s after another seat did and nothing is held, the relay names the lowest connected listed\nseat and sends the frame again to every game seat with `dealerAbsent: true`; it names another one only\nif that seat drops for 8 s too. `dealer`/`dealerAbsent` is for non-Turn-Kit games: in a Turn Kit room\nthe kit's own first-deal rule decides, and the kit never reads it. **Game rule: start a fresh match only\non `none` AND `dealer === mySeat`**, evaluated on either event (the `none` can arrive before a new\n`dealSeats`). Anything else waits for the dealer's frame, which arrives as the normal relayed frame, or\nfor the stored state. No `dealSeats` before the `none` means an older relay or a token without a seats\nclaim: keep your present behaviour.\n\n**A second device takes the seat.** When the same player opens the match elsewhere, the older\nconnection gets `{\"type\":\"superseded\"}`, is closed with code 4409, and does not reconnect;\n`Miniant.net` emits `superseded`. Stop driving the match in that tab.\n\nThe relay has no end-of-match signal, so it keeps a room's stored match state while the room lives\nand sweeps it only 24 hours after the room last emptied. Seats gone for minutes or hours are still\nserved the stored state on return. Silence is possible only after that 24 hour expiry, when the\nrelay cannot say \"no match\" truthfully and sends nothing; do not deal a fresh match on silence.\n\n## Report manipulation\n\nWhen your game catches another seat breaking its rules (a move out of turn, a move the rules\nforbid), **enforce the rule yourself first**: forfeit that seat or reject the move, and show your own\nwarning in your own UI. The portal shows no toast for this. Then report it:\n\n```js\nMiniant.reportViolation({ seat: 2, kind: \"out_of_turn\", evidence: { move: \"e2e4\" } });\n```\n\n- `seat` is the offending seat's index, the same index your room context gives you. The portal\n works out who sits there; you never send an account.\n- `kind` is a short slug, `a-z`, `0-9` and `_`, up to 32 characters (`out_of_turn`).\n- `evidence` is optional small JSON, up to 2 KB, shown to staff.\n- It returns nothing, waits for nothing and never throws. A report with a bad `kind` or `seat`, or\n oversized `evidence`, is dropped.\n\nThe report is **record only**. It never changes a result, XP, a wallet or a session by itself, and a\nreport about an AI seat is ignored. Staff see reports grouped by player (\"reported by 2 of 3 seats\")\nand decide on a ban themselves. Every seat that saw the violation may report it; the platform keeps\none entry per match and lists each reporting seat. Do not use it as a substitute for enforcing the\nrule: a game that only reports has not stopped the cheat.\n\n## Message-rate budget\n\nYour manifest's `network.expectedMessagesPerMinute` ([manifest reference](/guide/requirements/technical))\nis not just documentation — it sets your game's actual rate ceiling. The relay enforces a **hard cap\nof 3× your declared rate**, applied consistently everywhere the platform computes a per-room rate\nlimit: sustained traffic above that ceiling gets `throttled`, then disconnected on continued abuse. A\nroom whose *aggregate* traffic sustains at or above that same ceiling is also fed into the platform's\nanti-cheat engine as a `RELAY_RATE` signal — declare a realistic `expectedMessagesPerMinute` for your\ngame rather than a padded one; padding it just raises your own game's ceiling for what the platform\nwill treat as suspicious.\n\n**Declare `expectedMessagesPerMinute` honestly and keep your traffic well under it in normal play.**\nThe relay's own cost envelope is why fast-action, high-frequency games are a poor fit for\n`Miniant.net` today — turn-based and low-rate games (chess, cards, quiz-style) are the target until\nplatform capacity for higher-frequency traffic is available.\n\n**What counts against your rate budget when your game is spectated.** Rate limits are enforced\nper-connection, on *inbound* sends only — the relay fanning one of your broadcasts out to any\nnumber of spectators costs you nothing extra; delivering it to N spectators still counts as exactly\none send against your own budget. Spectator (and portal chat/emote overlay) traffic also never\nfeeds the `RELAY_RATE`/anti-cheat aggregates above — those aggregate GAME-channel traffic only, the\nreal seats, never spectators. What *does* count: your own snapshot rebroadcasts — both the\nturn-change broadcasts and the `syncRequest`-triggered ones described in\n[Spectating](/guide/sdk/spectating) — are real sends from your own seat, so budget them into your\ndeclared `network.expectedMessagesPerMinute` the same as any other message you send.\n"},{"slug":"sdk/turn-kit","title":"Turn Kit","section":"SDK","agentSummary":"Miniant.createTurnKit is the mandatory turn-rotation helper for sync: 'turn_based' online modes: seat rotation, tagged snapshot broadcast, checkpoint(), and reconnect syncRequest, automatically.","checklist":[{"id":"turnkit.gate-turn-mode","text":"Use createTurnKit only for a mode declaring sync: 'turn_based' with capabilities.relay: true.","verify":"validator:manifest.mode-rules"},{"id":"turnkit.no-manual-send","text":"Never call Miniant.net.send() for game state once kit.start() has run; drive all state changes through kit.endTurn(). Action frames with your own k are allowed — the kit ignores them.","verify":"manual"},{"id":"turnkit.show-action-live","text":"Send an action frame the moment the acting player's input lands so opponents and spectators watch the move happen — the endTurn snapshot is authority, never the first sign of motion.","verify":"manual"},{"id":"turnkit.full-snapshot","text":"Make getSnapshot()/applySnapshot() round-trip a full state snapshot every turn, never a delta.","verify":"manual"},{"id":"turnkit.pass-unavailable-fully","text":"Pass unavailable: context.mode.unavailable to createTurnKit only when implementing every corresponding availability callback.","verify":"manual"},{"id":"turnkit.dispose-cleanup","text":"Call kit.dispose() from the leave-game path and the terminate handler, alongside Miniant.net.close().","verify":"manual"},{"id":"turnkit.checkpoint-multi-action-turns","text":"Call kit.checkpoint() after every committed action in a multi-action turn, and again at the start of the next in-flight action, so a spectator joining mid-turn can replay it.","verify":"manual"}],"body":"# Turn Kit\n\n`Miniant.createTurnKit(options)` is a thin turn-rotation helper layered on top of\n[`Miniant.net`](/guide/sdk/multiplayer) for a `sync: \"turn_based\"` online mode ([manifest\nreference](/guide/requirements/technical)) — seat rotation, a `{snapshot: true}`-tagged full-state\nbroadcast on every turn change, and automatic `syncRequest` answering (reconnect recovery for a\nGAME seat — **not** a spectator join, which never sends `syncRequest`; a spectator instead reads the\nkit's own tagged broadcasts straight from the relay's cache, see\n[Spectating](/guide/sdk/spectating)), all over that same relay connection. Your game writes rules\nand rendering; the kit writes \"whose turn is it, and does everyone agree on the board.\" This is\nwhat makes bringing a Chess- or Carrom-shaped game online cheap.\n\nMandatory **iff** the mode you're running declares `sync: \"turn_based\"` ([Choose a sync\nmode](/guide/requirements/multiplayer#choose-a-sync-mode-for-an-online-mode)). Gated on\n`capabilities.relay` the same way every other `net.*` call is — it throws\n`CapabilityNotGrantedError` immediately if your manifest didn't declare the relay.\n\n```ts\nconst context = await Miniant.init({ sdkVersion: 1 });\nawait Miniant.net.connect();\n\nconst seats = context.participants.map((p) => p.seat).sort((a, b) => a - b);\nconst teams = Object.fromEntries(\n context.participants.filter((p) => p.team !== undefined).map((p) => [p.seat, p.team]),\n);\n\nconst kit = Miniant.createTurnKit({\n seats,\n mySeat: context.player.seat,\n teams, // omit entirely on a free-for-all mode — see \"Teams\" below\n getSnapshot: () => game.serialize(),\n applySnapshot: (snapshot) => game.load(snapshot),\n onTurnStart: (seat) => ui.setActiveSeat(seat),\n onPeerAbandoned: (seat) => ui.showDisconnectNotice(seat), // a signal only — see below\n unavailable: context.mode.unavailable, // omit entirely for no auto-takeover/forfeit — see below\n});\n\nkit.start();\n\n// ... your move handler, once it's this client's turn ...\nfunction onPlayerMove(move) {\n if (!kit.isMyTurn()) return;\n game.applyMove(move);\n kit.endTurn({ lastMove: move }); // patch is optional, a cheap animation hint only\n}\n```\n\n## Build `seats`/`mySeat`/`teams` from the play context\n\n- **`seats`** — every occupied seat, from `context.participants[].seat`. Gaps are fine (`[0, 2, 3]`\n is a legal 3-seat rotation with seat 1 empty) — pass them in any order, the kit sorts its own\n copy.\n- **`mySeat`** — `context.player.seat`. Must be one of `seats`, or `createTurnKit` throws\n immediately. It must also be a non-negative integer — `createTurnKit` throws immediately for a\n spectator's synthetic seat (`-1`) too (2026-09-25), and `Miniant.createTurnKit` (the `guest.ts`\n wrapper) throws the same way whenever `context.spectator === true`, before ever building a kit. A\n spectator's own client uses [`Miniant.createSpectatorKit`](/guide/sdk/spectating) instead.\n- **`teams`** — build it from `context.participants[].team` ([Game](/guide/sdk/game)'s \"Team Up\"\n section), not by computing it yourself: `{ [seat]: team }` for every participant that has a\n `team`, omitted entirely for a seat with none. `kit.teamOf(seat)` reads it back and returns\n `null` for a seat with no entry — it never throws for an unrecognized seat. See \"[Declare Team Up\n if your game has it](/guide/requirements/multiplayer#declare-team-up-if-your-game-has-it)\" in\n [Multiplayer requirements](/guide/requirements/multiplayer) for where `participants[].team` itself\n comes from.\n\n## Drive every state change through `endTurn`\n\nOnce `start()` is called, the kit handles every relay frame it recognizes as its own —\n**never call `Miniant.net.send()` for game state yourself** once you're using the kit; the two\nwould race each other's turn sequence numbers. This ban is scoped to *state*: frames of your own\nkind are allowed and expected — see [Show the action live](#show-the-action-live) below.\n`endTurn(patch?)`:\n\n- Broadcasts `getSnapshot()` as the new authoritative state and advances the turn to the next\n occupied seat, wrapping past the highest back to the lowest.\n- Throws `NotYourTurnError` when called off-turn — guard your input handler with `isMyTurn()`\n before calling it, the same way you'd gate a move on it being this seat's turn at all.\n- `patch` is optional and rides alongside the full snapshot purely as a cheap animation hint (e.g.\n \"which square moved\") — the kit never reads it on the receiving end, so a game that ignores\n `patch` entirely still gets fully correct state from `snapshot` alone.\n\n**`getSnapshot()`/`applySnapshot()` must round-trip a full snapshot, every turn — no deltas.** The\nkit broadcasts the entire game state on every turn change rather than computing an incremental\ndiff, the same convention Ludo already uses today: simpler, and self-healing, since a dropped or\nlate frame never compounds into permanent desync — the very next turn carries the complete state\nagain. `applySnapshot` is only ever called with a peer's snapshot, never your own — `endTurn()`\nadvances your own local turn state directly, without round-tripping through the network.\n\n**`applySnapshot(snapshot, source)` tells you where a snapshot came from.** The kit trusts a\nsnapshot's content as sent, so any player could end their own turn with a rewritten history.\n`source` lets your game refuse that:\n- `{ kind: \"turn\", seq, from, fromSeat, auto, replaced }`: the turn of `fromSeat` just ended. `auto`\n means `from` played it on that seat's behalf. `replaced` means it outranked a move already applied\n at the same `seq`.\n- `{ kind: \"sync\", seq, from }`: reconnect recovery or a late join, which can jump several turns.\n- `{ kind: \"retire\", seq, from, seat }`: the forfeit policy retired `seat`, and no turn was played.\n\n`from` is always the sender the relay verified. A game whose state is a move log can check that a\n`turn` snapshot keeps the history it already holds and adds moves only for `fromSeat`.\n\n**`snapshotDropReason(snapshot, source)` lets your game refuse a snapshot (SDK 2026.10.19).** It is\nan optional kit option, called before the kit moves anything, with the arguments `applySnapshot`\nwould get. Return `null` to take the frame, or a short reason to drop it: the kit logs the reason\nand treats the frame as if it never arrived, so the turn does not move and an honest frame for the\nsame turn is still accepted. A hook that throws refuses the frame. It also judges the board the relay's\nstand-in hands back to a waking tab (`adoptStandin`, `source.kind === \"standin\"`): that board can be\nseveral moves ahead of yours, so judge it as a state, not as one move. Use it to say \"that is not one\nlegal move from the state I hold\":\n\n```ts\nMiniant.createTurnKit({\n // ...\n snapshotDropReason: (snapshot, source) =>\n source.kind === \"turn\" && !isOneLegalMove(localState, snapshot, source.from)\n ? \"not one legal move\"\n : null,\n});\n```\n\n**Never refuse a `turn` frame with `source.replaced === true` just because you already hold a\ndifferent state at that step.** When two seats drive an absent seat at once (a slow connection lets\nthe next driver in), each commits the same step; the kit picks one winner the same way on every\nclient and hands it to you as `replaced`. A hook that refuses it (\"same step, different state\")\nleaves each client on the state it saw first: the clients disagree and the table freezes. Still\nrefuse it if it is not a legal move.\n\nCheck `source.from`, not `fromSeat`, for who moved: on a non-auto `turn` the kit now requires\n`fromSeat` to equal `from`, and on an `auto` one `from` is the driver. The kit also checks that a\n`turn` frame hands the turn to the next seat in the rotation (a sender can no longer play twice or\nskip a seat; under the `forfeit` policy it may skip ahead past a seat it has retired), and that a\n`sync` names a seat in the room. It still cannot check clocks, timeouts or abandon outcomes, and\nthe answering seat of a `sync` is trusted for what it says unless you check it here.\n\n## Show the action live\n\nThe kit keeps every seat *correct*; it does nothing to let anyone *watch* a move happen. If the\nonly thing that ever crosses the relay is `endTurn()`'s committed snapshot, your opponent — and\nevery spectator — sees a frozen board, then the result. The fix is a small action frame sent the\nmoment the acting player's input lands:\n\n```ts\n// The instant the input lands — finger lifts off the flick, piece starts moving:\nMiniant.net.send({ k: \"flick\", shot }); // your own `k`, your own payload\n\n// Every other client (spectators included) replays it live:\nMiniant.net.on(\"message\", ({ data }) => {\n if (data?.k === \"flick\") animateFlick(data.shot);\n});\n```\n\nThis coexists safely with the kit, by design: the kit only handles frames whose `k` is one of its\nown — `turn`, `sync`, `hb`, `away`, `back`, `retire` — and silently ignores everything else, while\nyour own `net.on(\"message\")` listener receives every frame alongside the kit's. Pick any other `k`\nand the two never touch. Three rules keep it honest:\n\n- **Send at input time, not after your animation finishes.** Whatever time your local presentation\n takes, every watcher inherits it on top of network latency — until the frame arrives they see\n nothing at all.\n- **The snapshot stays the authority.** Peers replay the action frame for motion, then reconcile\n to the very next `endTurn` snapshot once the replay settles. Never *derive state* from action\n frames — they're presentation, delivered best-effort, and a client that missed one must still\n end up correct from the snapshot alone.\n- **Count these frames in your `network.expectedMessagesPerMinute`** ([manifest\n reference](/guide/requirements/technical)) — they cross the same relay under the same budget.\n\nThis is the requirements-level rule at [Broadcast the action, not just the\noutcome](/guide/requirements/multiplayer); it's also what makes `capabilities.spectate` worth\ndeclaring, since spectators receive exactly the frames the seats exchange.\n\n## Reading whose turn it is\n\n- **`isMyTurn()`** — `true` when the current holder is your own `mySeat`. Gate input on this.\n- **`currentSeat()`** — the seat currently holding the turn.\n- **`onTurnStart(seat)`** — fired once, synchronously, from `start()` for the initial holder (the\n lowest occupied seat), and again on every accepted turn change, whoever's turn it becomes next.\n- **`onStateApplied(cb)`** — fired every time the kit calls your `applySnapshot` on your behalf\n (a normal turn change or a recovery frame), with that snapshot, after it's already applied.\n Returns an unsubscribe function. Use it if your game wants to react generically to \"state\n changed\" (re-render, replay a sound) without separately handling turn frames and recovery\n frames.\n\n## Reconnect and spectator recovery, and `checkpoint()`\n\nYou never see or handle the resync frame yourself. If this client reconnects into a room that\nalready has another live peer, the kit answers the relay's `syncRequest` on your behalf with your\nlatest `getSnapshot()`, tagged `{snapshot: true}` — the same auto-answer obligation\n[Multiplayer](/guide/sdk/multiplayer) asks a hand-rolled `realtime` game to implement itself, done\nfor you here.\n\nThe kit applies a resync only when the relay marks it as the answer to its own request. Any seat\ncan broadcast a frame shaped like a resync, and the kit drops every unmarked one. So the relay must\nbe at least as new as your SDK.\n\n**A spectator joining is a different, simpler case — it never reaches your game at all.** The kit\nalready tags every `turn`/`sync`/`retire` broadcast `{snapshot: true}`; the relay serves a joining\nspectator its cached copy of the latest one directly, with no `syncRequest` and no call into your\ncode. See [Spectating](/guide/sdk/spectating#snapshottrue-and-catching-up-a-spectator-mid-match) for why a spectator join\nwas deliberately kept off the wire path players' own frames travel.\n\n**`checkpoint()`.** Keeps a spectator current mid-turn: `kit.checkpoint()` broadcasts a `{snapshot: true}`-tagged\nmid-turn snapshot **without ending the turn** — for a turn that covers several committed actions\n(bowling: two balls per frame), a spectator joining mid-turn otherwise always replays from the\nturn's start, however far play has actually progressed.\n\n```ts\nfunction bowlBall(shot) {\n game.applyBall(shot);\n if (game.frameComplete()) {\n kit.endTurn({ lastShot: shot }); // normal turn end — unchanged\n } else {\n kit.checkpoint(); // mid-turn: keep a joining spectator's first paint current\n }\n}\n```\n\n- Call it **after every committed action** in a multi-action turn, and **again at the start of the\n next action** with that action already in flight and included in `getSnapshot()` (e.g. right as a\n ball is released, not only once it stops) — so a watcher who joins mid-action can replay the\n in-flight action from its own launch instead of picking it up partway through.\n- Legal only for whoever is entitled to act for the current holder right now: `mySeat` on its own\n turn, or — under `unavailable: { policy: \"auto\" }` — the currently eligible elected driver for the\n current seat, same eligibility `endTurnAs()` itself checks. Anyone else gets no send and a single\n `console.warn` per kit instance, not a thrown error — a checkpoint is an optimization, not a state\n transition your control flow depends on.\n- Returns `true` iff the frame was actually sent; `false` for every case above, and once the match\n has already been locally decided (the `\"forfeit\"` policy's 2-seat `onMatchDecided` branch) — there\n is no turn left to checkpoint.\n- Budget: one extra broadcast per call, same `snapshot: true`-tagged relay cache write a\n `turn`/`sync`/`retire` frame gets — count it in `network.expectedMessagesPerMinute` like any other\n send.\n\nThere is no shipped reference implementation for a checkpoint-using game yet — see the kit's own\ntests, `packages/game-sdk/tests/turnkit-checkpoint.test.ts`, for how it's called and what it sends.\n\n## `onPeerAbandoned` is a signal only\n\nFired the instant a seat's connection drops (`net`'s own `peerLeft`, for any of this kit's\noccupied `seats`) — no grace period, no reconnect-window timer, and no decision baked in about\nforfeiting or pausing the match. **Show a notice; nothing more** — the availability machinery below\nis what actually decides what happens to the seat when your mode declares `unavailable`. `onPeerAbandoned`\nstill fires independently of, and in addition to, that machinery either way.\n\n## Player availability: `unavailable`, auto-takeover, and forfeit\n\nPass `unavailable: context.mode.unavailable` and the kit takes over everything described in this\nsection for free — beacons, driver/retire election, staggering, handback, and reporting to the\nportal's own grace/auto-pilot/forfeit banner chrome. **Omit the option and none of it runs**: no\nextra timers, no extra listeners, byte-for-byte the same kit you already know from the sections\nabove. See [Multiplayer requirements](/guide/requirements/multiplayer#when-a-player-becomes-unavailable)\nfor the policy contract itself (`policy`/`timeoutSeconds`, what each policy means for players, the\nnetwork-headroom manifest rule) — this section covers only the API surface the kit adds on top.\n\n`context.mode.unavailable` is populated with the resolved default (`forfeit`/30s) even when your\nmanifest declared nothing — pass it into `createTurnKit` only if you're prepared to implement the\ncallbacks below.\n\nThe kit calls `Miniant.reportPeerAvailability()` for you at every transition it detects, unless you\npass your own `reportAvailability` option to `createTurnKit` — supply one only if you need to\nintercept or duplicate that reporting yourself; most games should leave it unset and let the kit's\ndefault wiring handle it.\n\n```ts\nconst kit = Miniant.createTurnKit({\n seats,\n mySeat: context.player.seat,\n teams,\n getSnapshot: () => game.serialize(),\n applySnapshot: (snapshot) => game.load(snapshot),\n onTurnStart: (seat) => ui.setActiveSeat(seat),\n\n // Only meaningful when your mode declares `unavailable` — omit context.mode.unavailable\n // entirely and none of the callbacks below ever fire.\n unavailable: context.mode.unavailable,\n onSeatUnavailable: (seat, deadlineMs) => ui.showGraceCountdown(seat, deadlineMs),\n onSeatAvailable: (seat) => ui.clearGraceCountdown(seat),\n\n // \"auto\" policy: compute the seat's move yourself and drive it.\n onAutoTurn: (seat) => kit.endTurnAs(seat, ai.computeMove(seat)),\n onSeatAutoDriven: (seat, bySeat) => ui.closeStaleTurnPrompt(seat),\n\n // Fires before the snapshot in `applySnapshot` is applied — cancel local in-flight play,\n // never gate the snapshot itself on it. See \"One committed history per turn\" below.\n onTurnOverridden: ({ seq, seat, bySeat, reason }) => {\n if (reason === \"takeover\") game.cancelLocalPlay(); // my own turn was played by a stand-in\n else game.discardDerivedFrom(seat); // a stand-in's turn I'd applied was replaced\n },\n\n // \"forfeit\" policy: 3+ seats retires the silent one, exactly 2 seats decides the match.\n onSeatRetired: (seat) => ui.showRetiredNotice(seat),\n onMatchDecided: ({ survivingSeat, forfeitedSeat }) => {\n if (survivingSeat === context.player.seat) showVictoryScreen();\n Miniant.reportResult({ outcome: \"win\", forfeit: true, durationMs });\n },\n});\n\nkit.start();\n```\n\n- **`onSeatUnavailable(seat, deadlineMs)`** — fires the moment a peer seat's grace period begins,\n either from natural silence crossing `timeoutSeconds` or an unambiguous disconnect (`peerLeft`)\n forcing it immediately. `deadlineMs` (epoch ms, usable with `Date.now()`) is when the resolved\n policy is expected to act — render a countdown from it if you want one, though the portal's own\n banner chrome already shows one without any work from you.\n- **`onSeatAvailable(seat)`** — fires when a peer seat's liveness evidence returns before its policy\n acted (the grace period is cancelled), and also for **handback**: a seat already being driven\n under `\"auto\"` sending real evidence again.\n- **`onAutoTurn(seat)`** — `policy: \"auto\"` only. Fires when it's currently `seat`'s turn, `seat`'s\n grace deadline has been reached (offset by this client's own driver rank — see \"Driver election\"\n below), and no fresher evidence has arrived. Compute that seat's move with your own AI and call\n `kit.endTurnAs(seat, patch?)` — this callback never advances the turn itself. May fire more than\n once for the same seat/turn if an earlier-ranked candidate never actually calls `endTurnAs`.\n `endTurnAs(seat, patch?)` is legal only while this client is the currently eligible elected driver\n for `seat` — call it outside `onAutoTurn`'s own firing (or after another rank has already driven)\n and it throws `NotDriverEligibleError` instead of broadcasting.\n- **`onSeatRetired(seat)`** — `policy: \"forfeit\"`, 3+ occupied seats at the deadline. Fires on every\n client once a `retire` frame for `seat` has been applied (whether this client sent it or received\n it) — `seat` is now permanently out of turn rotation, and the match continues without it.\n- **`onMatchDecided({ survivingSeat, forfeitedSeat })`** — `policy: \"forfeit\"`, exactly 2 occupied\n seats at the deadline. Fires **only on the surviving client** — there's no rotation left to\n protect and no one else in the room to tell, so no wire frame is sent for this transition. Show\n your victory screen and report the outcome with `forfeit: true` (see\n [Multiplayer requirements](/guide/requirements/multiplayer#when-a-player-becomes-unavailable)'s\n \"Reporting the result\").\n- **`onSeatAutoDriven(seat, bySeat)`** — fires on `seat`'s **own** client, after the auto-driven\n turn frame for its own seat has already been applied, when it learns it was played on its behalf\n while it was away. Close any stale \"your turn\" UI you might still be showing — this is your one\n hook for \"welcome back\" chrome for the returner themselves; the portal's own banner chrome\n deliberately has none. That same client immediately sends `back`, handing its seat back — you\n don't have to do this yourself.\n- **`onTurnOverridden({ seq, seat, bySeat, reason })`** — fires **before** `applySnapshot`, whenever\n an incoming frame is about to replace a turn this client already committed to locally. Two cases,\n told apart by `reason`:\n - **`\"takeover\"`** — `seat` is this client's **own** seat, and a stand-in (`bySeat`) played its\n turn while it held it. Abandon any local in-progress play for that turn — animation, aim,\n timers, self-`Auto`, AI planning — and let the incoming snapshot apply; don't race it.\n - **`\"replaced\"`** — a stand-in's turn for `seat` that this client had already applied is being\n replaced by `bySeat` (the seat's own real move, or a lower-numbered stand-in — see \"One\n committed history per turn\" below). Drop anything you derived from the turn being replaced.\n\n**Driver/retire election, so you never have to think about it.** Both policies elect a candidate the\nsame way: sort the currently-available occupied seats (excluding the target) ascending, and this\nclient becomes eligible to act at `graceDeadline + rank * 3000ms`, where `rank` is this client's own\nposition in that sorted list. The lowest-ranked candidate acts right at the deadline; each\nfurther-ranked one waits another 3s, so a still-silent target eventually gets acted on even if a\nlower-ranked candidate has also gone silent or crashed. Any real evidence from the target cancels\nevery pending rank's timer. You never see this directly — it's why `onAutoTurn`/`endTurnAs` and the\nretire/`onMatchDecided` path are simply \"eligible or not\" from your game's perspective.\n\n**Warning against your own timers.** Everything above — beacons, election, staggering, retire\npropagation, handback — is the kit's job the moment `unavailable` is set. Implement only the\ncallbacks; building your own disconnect timer or reconnect-window logic on top of this is redundant\nat best and a source of desync at worst (your timer and the kit's own election would race each\nother's decisions about the same seat).\n\n## One committed history per turn\n\nA stand-in's committed turn under `\"auto\"` — an elected driver's `kit.endTurnAs(seat, ...)` — is\nauthoritative for **every** client, including the seat it stood in for, even one that was still\nvisible (its heartbeats may just have been lost in transit). At an equal turn `seq`, one fixed\nprecedence decides on every client: the seat's own real move beats a stand-in's, and between two\nstand-ins the lower-numbered seat wins. A later `seq` always wins.\n\nThe other clients do not take a stand-in's move for a seat they heard from in the last\n`timeoutSeconds - 3` seconds. Without that check, any player could play a present player's turn.\nBots, seats that left, and seats a client has never heard from are still played at once. A client\nwhose own connection is down never judges another seat silent, and never stands in for one, until it\nis back.\n\nNever gate `kit.applySnapshot` on local uncommitted state (\"my own shot is still in flight, ignore\nthis frame\"). Cancel local in-flight play in `onTurnOverridden` and let the applied snapshot win.\n\n## Cleaning up\n\nCall `kit.dispose()` from your own \"leave game\" path and from your `\"terminate\"` handler\n([Game](/guide/sdk/game)'s \"Pause, resume, and terminate\"), alongside `Miniant.net.close()`. It\nremoves every `net` listener the kit registered — safe to call whether or not `start()` was ever\ncalled, and safe to call more than once. `start()` itself is idempotent too: a second call is a\nno-op (with a console warning) rather than double-registering listeners and double-firing\ncallbacks.\n"},{"slug":"sdk/sealed-deck","title":"Sealed deck","section":"SDK","agentSummary":"Miniant.net.deck(name): hidden cards held by the relay for online room card games. Server shuffle, each seat gets only its own ids. Pass the Turn Kit as kit; forfeit any seat onViolation names.","checklist":[{"id":"deck.hidden-cards-on-relay","text":"In an online room, keep every hidden card (hands, the closed pile) in Miniant.net.deck, never in a kit snapshot, a net.send payload or a shared seed.","verify":"manual"},{"id":"deck.turn-check","text":"Pass the match's Turn Kit as kit, and end the match with the violating seat forfeiting when onViolation fires.","verify":"manual"},{"id":"deck.timeout","text":"Use unavailable.timeoutSeconds of 15 or more in a sealed-deck game, so the relay already counts a silent seat as absent when the Turn Kit elects a driver for it.","verify":"manual"},{"id":"deck.driver-key","text":"Key drawFor and moveFor on the Turn Kit's turn (e.g. `${seq}:d`), the same on every client, so two drivers racing for one absent seat commit once.","verify":"manual"}],"body":"# Sealed deck\n\n`Miniant.net.deck(name)` gives an online room game hidden cards that no client can read until they\nare dealt to it or turned face up. The relay holds the deck: it shuffles with server randomness,\nsends each seat only its own card ids, and refuses a play of a card the seat does not hold.\n\nUse it for any card game in `online_friends` or `online_random` rooms. Dealing every hand from the\nshared `session.seed` lets a modified client read every hand and the whole closed pile, and a dealer\nseat can read everything too. `vs_ai` and AI-filled `online_random` matches have no room: keep their\nhidden cards in the stand-in's `secret` field ([Spectating](/guide/sdk/spectating)).\n\nGated on `capabilities.relay`, like every `net.*` call.\n\n```js\nconst context = await Miniant.init({ sdkVersion: 1 });\nawait Miniant.net.connect();\nconst kit = Miniant.createTurnKit({ /* ... */ unavailable: context.mode.unavailable });\nconst deck = Miniant.net.deck(`deal-${dealNo}`, {\n kit,\n onViolation: ({ by }) => endMatchWithForfeit(by),\n});\n\n// The dealer seat, by your game's rule:\nawait deck.create(allCardIds); // e.g. [\"AS.1\", \"AS.2\", ..., \"JK.1\"]; public, unique\nawait deck.deal({ 0: 13, 1: 13 });\nawait deck.flip(\"joker\"); // the cut joker, face up for everyone\n\n// On my turn:\nconst { cards: [drawn] } = await deck.draw(); // or: await deck.take(\"open\")\nawait deck.discard(chosenId, \"open\");\nkit.endTurn();\n```\n\n## Cards are opaque ids\n\nA card is a string of 1 to 16 characters from `A-Z a-z 0-9 . _ -`, unique within the deck. The relay\nnever reads meaning into it: `\"QH.2\"` is the second queen of hearts only to your game. A deck holds\nup to 520 ids. Deck frames have their own 16 KB cap and do not count against your manifest's\n`maxMessageBytes`; a 520-card `create` is about 10 KB. A call that is waiting when the connection\ndrops rejects with `offline`: whether the relay applied it shows in `deck.state()` after the\nreconnect.\n\nPiles are public, named `[A-Za-z0-9_-]`, up to 8 per deck. Each seat has one private zone (its hand).\nOps go top-first: `draw` takes the last card of the sealed pile, `take` the last card of a pile.\n\n## Ops\n\nEvery op returns a promise that resolves with the applied op (below) or rejects with a `DeckError`\nwhose `reason` is one of `no_deck`, `exists`, `bad_op`, `not_held`, `empty`, `seat_live`,\n`duplicate`, `offline`. The acting seat is always your own seat from the relay token; nothing in the\ncall can name another actor.\n\n| Call | Who | Effect |\n|---|---|---|\n| `create(cards)` | any seat | A new sealed pile, shuffled on the relay. The list (not the order) is public. A name can be used once: deal again under a new name. |\n| `deal({ seat: n })` | any seat | Top `n` cards to each seat's zone. |\n| `draw()` | own | Top sealed card to my zone. |\n| `take(pile, card?)` | own | Top (or the named) card of a public pile to my zone. |\n| `discard(card, pile)` | own | A card I hold onto a public pile. Refused `not_held` otherwise. |\n| `flip(pile)` | any seat | Top sealed card face up onto a pile. |\n| `recycle(pile, { keep })` | any seat | The pile minus `keep` back into the sealed pile, reshuffled (the closed pile ran out). |\n| `show()` | own | My zone face up to everyone for the rest of the deck. |\n| `showSeat(seat)` | others | Shows an **absent** seat's zone once every present seat has asked; until then it resolves `{ pending: true }`. A present seat is refused `seat_live`: it shows its own. |\n| `drawFor(seat, key)` | others | Top sealed card into an **absent** seat's zone, unseen by the caller. |\n| `moveFor(seat, pile, pick, key)` | others | One card of an absent seat's zone onto a pile: `\"last\"` (the card it got last) or `{ prefer: [[ids], [ids]] }` (the first held id of the earliest list). Refused `bad_op` for a pile the deck cannot add, else `not_held` whether the hand is empty or holds none of the ids; any refusal after the seat/key checks spends the `key`, and a retry with that key gets the same refusal again (never `duplicate`, which means applied). |\n\n| `give(to, cards)` | own | Named cards from my hand into seat `to`'s, hidden from every other seat and watcher. `not_held` unless I hold every card; `bad_op` for myself or a seat with no hand in this deck. |\n| `exchange(round, to, cards, targets?)` | own | My part of an atomic pass (below), declaring the round's pass map in `targets`. |\n| `exchangeFor(seat, round, to, { count, prefer? }, key)` | others | **Not available yet** (the relay answers `bad_op`). Next release: `exchange` for an **absent** seat, blind (below), same absence rule and `key`. |\n\nThere is no `giveFor`: a driver never chooses where another seat's cards go. Only `exchangeFor`\ndrives, and only along the round's declared pass map.\n\n`create`, `deal`, `flip`, `recycle` and `showSeat` are open to any seat because only your rules know\nthe dealer and the end of a deal. Check them in an `op` handler (below).\n\n## Passing cards: give and exchange\n\n`give` moves cards between two hands. Only the giver and the receiver get the ids (`op.gave` on the\ngiver's copy, `op.cards` on the receiver's); every other seat and every watcher gets `seat`, `to`\nand the new `counts`, never an id. A face-up (shown) hand cannot give or receive.\n\n`exchange` is the simultaneous pass (Hearts: three cards left). Every seat that holds a hand in the\ndeck commits once per `round` (a name, e.g. `\"pass1\"`):\n\n```js\n// every live seat sends the SAME rule-defined map, { seat: receiver } for every seat holding a hand\nconst targets = { 0: 1, 1: 2, 2: 3, 3: 0 }; // pass left: a permutation, nobody to itself\nawait deck.exchange(\"pass1\", targets[mySeat], [id1, id2, id3], targets); // resolves when my commit is held\ndeck.on(\"op\", (op) => { if (op.op === \"exchange\" && op.done) render(deck.state().mine); });\n```\n\n- The relay holds each commit; nobody sees it, not even the receiver, until the last seat has\n committed. Then all are applied together and each seat's closing op carries the ids it received\n (`op.cards`). Before that, `op.done` is `false` and `counts` only shows who has fewer cards.\n- A committed give leaves my hand at once, so it cannot be played while the round is open: it is out\n of `state().mine` and shown in `state().pendingGive` (`{ round, to, cards }`).\n- The required seats are the seats the deck dealt to, not a list a client sends. A second commit\n from a seat in a round, or any commit to a round already applied, is refused `duplicate`; an\n absent seat must be committed for (`exchangeFor`) or the round waits for it.\n\n**The round's pass map.** A driver must never route an absent seat's hidden cards to itself or a\nconfederate, so the relay does not take the receiver on a driven commit from the driver:\n\n- Each live seat (connected, committing for itself) declares the map in `targets` on its own commit,\n and its own `to` must be its entry (`bad_op` otherwise). A map must be a permutation of exactly the\n seats the deck dealt to, nobody to itself (`bad_op` otherwise). Games with a rule-defined pairing\n (Hearts: left, right, across, none) compute it from the deal number, so every honest seat sends the\n same one.\n- The round's map is the **server stand-in's** when it declared one (it is authoritative), otherwise the\n one a strict majority of the live seats declared. A tie has no map. A seat whose map disagrees is\n refused if its commit would settle against it, or named in the result's `mismatch` and reported.\n- `exchangeFor` (a driven commit) is allowed only once the map is settled, only with `to` equal to\n that seat's entry, and never with `to` = the driver (`bad_op`). It may not carry `targets`\n (`bad_op`). Until every live seat has declared it is answered `map_wait`: nothing is held on the\n relay, and the SDK retries the call every 4 s, up to 8 times, then rejects with `map_wait`. With no\n map (a game that sends none) a driven pass never applies, so always send `targets`.\n- The map is stored with the round, so a reload or an evicted room keeps it; no frame ever carries it.\n\n> **Not available yet: `exchangeFor`.** A driven (blind) exchange for an absent seat is switched off\n> in this release; the relay answers it `bad_op`. Your own `exchange` works as described. The rest of\n> this section and the `exchangeFor` row in the Ops table are the next-release design.\n\n**Blind pass for an absent seat.** The driver cannot see an absent seat's hand, so `exchangeFor`\ntakes `{ count, prefer? }` in place of the card list:\n`deck.exchangeFor(seat, \"pass1\", targets[seat], { count: 3, prefer: [weakIds, allIds] }, key)`. The relay\nchooses: the held ids of `prefer` first (lists in order, ids in the order listed), then the rest of the\nhand in the order the cards arrived, until `count`; a hand with fewer than `count` is refused\n`not_held`. The ids chosen are never sent to the driver (its `deck_ok` carries none): only the receiver\ngets them, as usual, and an exchange keeps the pick hidden from everyone until the round applies. Same\nabsence rule and `key` as the named form. Over the wire: `{ \"type\": \"deck\", \"op\": \"exchange\", \"round\":\n\"pass1\", \"seat\": 3, \"to\": 0, \"pick\": { \"count\": 3, \"prefer\": [[\"QS.1\"]] }, \"key\": \"7:x\" }`.\n\n**Reload rule.** `deck_state` is always the truth: `mine` is the seat's current hand (received cards\nincluded after the round applied, a pending give already out), `pendingGive` is its own commit if the\nround is still open, and `exchange` lists the seats that have committed per open round. A reloading\nseat does not remember or re-send anything: it renders from `deck.state()`, and commits again only\nif `pendingGive` is absent and its round is still open.\n\n## Read the deck\n\n```js\ndeck.on(\"op\", (op) => render(op)); // every applied op, in relay order\nconst view = deck.state();\n// { seq, sealed: 52, piles: { open: [\"7H.1\"] }, zones: { 0: 13, 1: 13 },\n// shown: { 1: { by: 1, cards: [...] } }, mine: [\"AS.1\", ...] }\n```\n\nAn op carries `deck`, `seq` (per deck, gap-free), `op`, `by` (the acting seat), and as it applies\n`seat`, `seats`, `pile`, `card` (a card that just became public), `keep`, `cards` (ids you may see:\nthe created list, a shown zone, or cards entering your own zone) and `counts` (`sealed`, `zones`,\n`piles`).\n\nRender card positions from the deck, never from your kit snapshot: the snapshot holds turn phase and\nscores only. A hand id in a snapshot reaches every seat and every watcher.\n\n**Reconnect and late watchers.** On connect the relay sends the current state of every deck before\nanything else, so `deck.state()` is right by the time your `hello`, `syncRequest` or snapshot\nhandler runs. A spectator's deck is read-only (calls throw) and never holds a private id.\n\n## Turns: the relay checks cards, your clients check turns\n\nThe relay has no turns, so it carries out a `draw` or `discard` whenever a seat sends one. Pass the\nTurn Kit as `kit`: every client then checks each op as it arrives against `kit.currentSeat()`. All\nclients see kit frames and deck ops in the same relay order, so they reach the same verdict.\n\n- `draw`, `take`, `discard` must come from the current seat.\n- `drawFor`, `moveFor` must target the current seat.\n\nA failure calls `onViolation({ by, op, reason: \"out_of_turn\" })`. The op is still applied (the relay\nalready did it). End the match with seat `by` forfeiting: it is attributable to that seat's token,\nand the most it gained was one extra card. Add your own rule checks (one draw per turn, the dealer\ndeals) in `deck.on(\"op\")` and treat a failure the same way.\n\n## An absent seat's Auto\n\nThe relay lets another seat play for seat `k` only while `k` is absent: no live connection, or\nnothing heard from it for 12 seconds. A present seat answers `seat_live`. Use\n`unavailable.timeoutSeconds` of 15 or more, so the Turn Kit never elects a driver before the relay\nagrees; `drawFor`/`moveFor` retry once after 4 seconds on `seat_live` to cover the edge.\n\nThe driver never sees `k`'s hand, so Auto plays blind and weak by design. For rummy:\n\n```js\n// `duplicate` means another driver already did this step: carry on to the next one.\nconst once = (p) => p.catch((e) => { if (e.reason !== \"duplicate\") throw e; });\n\nonAutoTurn: async (seat) => {\n const seq = kit.seq(); // the same on every client for this turn\n await once(deck.drawFor(seat, `${seq}:d`));\n await once(deck.moveFor(seat, \"open\", \"last\", `${seq}:m`));\n kit.endTurnAs(seat);\n},\n```\n\nThe `key` must be the same on every client for the same turn: two drivers racing for one seat then\ncommit once, and the second gets `duplicate`. The Turn Kit elects a second driver when the first is\nslow, so a second driver is normal: it must skip a `duplicate` step, not stop, because the first\ndriver may have left between the draw and the discard. For a trick game, list the ids of the led suit and\nthen every id: `moveFor(seat, \"trick\", { prefer: [ledSuitIds, allIds] }, key)`.\n\nWhen the match ends, show each seat's zone for scoring: present seats `show()` their own, and every\npresent seat calls `showSeat(k)` for an absent one. No seat can show a present opponent's zone, and\nan absent seat's zone is shown only once every present seat has asked.\n\n## AI seats: only the stand-in sees their hands\n\n> **Not available yet.** Server-played bot seats (`capabilities.botsByServer`, the Turn Kit's\n> `botsByServer` option, the end check, the blind fallback and the held-state rules below) are switched\n> off in this release: the validator rejects `capabilities.botsByServer: true`, and no room is ever\n> flagged, so the tabs play the bots as before. What follows is the design for the next release, kept\n> so you can plan for it; do not build on it yet.\n\nUnder the sealed deck no client sees another seat's hand, so a tab cannot play an AI seat well. In a\nroom with AI seats the **server stand-in Worker** (never a human client and never a watcher) plays\nthem from their real hands, so the bots keep the game's normal AI. **Opt in** by declaring\n`\"capabilities\": { \"standin\": true, \"botsByServer\": true }` in `miniant.json` and passing the same\n`botsByServer: true` to `createTurnKit`; both are needed (the relay then refuses a tab driving a bot\nseat, and the kit never elects a tab for one). The relay does three things:\n\n- **It calls the stand-in for the bot's turn** as soon as the committed state says a synthetic seat\n is to move, while humans are connected too. The relay learns whose turn it is from the Turn Kit\n frame (`turn.nextSeat`, `sync.currentSeat`, or the `holder` of a `state` checkpoint), so a human tab\n must `checkpoint()` after the deal even when a bot leads. The stand-in's own think time is its\n `delayMs` (or a `hold`).\n- **It gives the Worker the hands, in the POST body only**, under a top-level `secret.decks`:\n `{ [deckId]: { [seat]: string[] } }`, the deck's own zone names. Every synthetic seat's hand is in it,\n and every human seat's too while all humans are gone (the stand-in is then standing in for them). A\n connected human's hand is never in it. The hands go nowhere else: not in any frame, not in a snapshot,\n not in `board`. Never copy a hand into the `board` or `actions` you return.\n- **It applies the Worker's `deckOps`**: the step reply may carry `deckOps: [{ deck, op, seat, ... }]`,\n the same op shapes a client sends plus the acting `seat`, applied in order as that seat (the plain\n forms: `draw`, `discard`, `take`, `flip`, `give`, `exchange`, `show`...; not `drawFor`/`moveFor`).\n Allowed only for a synthetic seat, or a human seat the stand-in is standing in for. Each op is\n validated exactly as a client's (the seat must hold the card): one refusal fails the whole step, nothing\n is applied or broadcast, and the relay retries after a backoff. Results go out as ordinary `deck_op`\n frames before the board of that step, and a bot's own ids go to nobody (it has no socket).\n\n```ts\nexport default createStandinWorker((board, nowMs, rng, room, away, secret) => {\n const hand = secret?.decks.d1?.[room.currentSeat] ?? [];\n const card = chooseCard(board, hand, rng); // the game's normal AI, with the real hand\n return {\n board: next(board, card), delayMs: THINK_MS, done: false, currentSeat: nextSeat(board),\n deckOps: [{ deck: \"d1\", op: \"discard\", seat: room.currentSeat, card, pile: \"open\" }],\n };\n});\n```\n\nA stand-in `exchange` deckOp may carry `targets` like a tab's; it is the round's authoritative pass\nmap, so declare it from your rules whenever bots pass: a lone live human's different map is then\nnamed a mismatch and loses, and a driven pass for an absent human follows yours. A seat whose declared\nmap disagrees is also reported to the portal (kind `pass_map_mismatch`, evidence `{ round, deck }`, once\nper round and seat; record-only for staff, never a penalty or a change to the match).\n\n**In a flagged room the relay holds only the legal next frame.** The held state is what the stand-in plays\non, and the relay cannot read your rules, so in a `botsByServer` room it keeps the held seq rising and\ntakes only: the first deal (a seq-0 `state`), a `turn` at held seq + 1 whose `fromSeat` is the seat that was\nto move (sent by that seat, or an `auto` turn driven by a human tab; for a bot seat only during the blind\nfallback), and a `state` checkpoint at the held seq by its holder. Everything else is still relayed to\nthe table but never held: a late tab frame below the held seq can no longer roll the state back (the bot\nwould play twice and the table fork), and an unasked `sync` is never held. Validate what you adopt\nanyway: `snapshotDropReason` is also consulted for the board the stand-in hands back to a waking tab\n(`source.kind === \"standin\"`). That board may be several moves ahead of the state you hold, so judge it as\na state (cards conserved, scores consistent), not as one legal move. A refused handback leaves the tab as\nit was until the table's next `sync`.\n\nA human absent while others play is still Auto'd by a tab through `drawFor`/`moveFor` and the blind\n`exchangeFor` pick (weak by design); the stand-in only stands in for humans when all of them\nare gone. In the Turn Kit set `botsByServer: true` next to `syntheticSeats`: this says the game is built\nfor server bots and is *allowed*, not forced. The kit only treats the bots as server-driven when the room\ncontext says so (`context.room.botsByServer`, the token claim, which the kit reads through `net`); a room\nwith no stand-in claim (kill switch off, failed lookup, no stand-in URL) has nobody to play the bots, so\nthe tabs drive them as without the flag and the bots never stall. When the room is server-driven no tab\nis elected to drive a bot, and an auto turn frame naming one is dropped. The relay refuses a client's `drawFor`,\n`moveFor` or `exchangeFor` for a bot seat (`seat_live`) when the room is flagged, except during the blind fallback (below). The flag\nreaches the relay in the token claim `standin.room.botsByServer`. A game that needs the stand-in to\n`show` a bot's hand at the end of the match plays that `show` as a `deckOp` too. In a flagged room the\nrelay also refuses a client's `showSeat(bot)` (`seat_live`, however many seats ask) until the stand-in\nhas reported the match done, or confirmed an end check (below), so a client can reveal the bot hands for\nscoring however the match ends.\n\n**A match a human ends: the end check.** The relay hears of the end only from the stand-in, so a match\nthat ends on a human's move needs one more step. Pass `true` as the second argument of the final commit,\n`kit.endTurn(patch, true)`, which sets `over: true` on the `turn` frame. The relay never believes it: if\nthe claim is the next commit (held seq + 1) from the seat that was to move, it POSTs the stand-in the\nroom step body plus `\"endCheck\": true` (and `secret.decks`), and a Worker made with\n`createStandinWorker(step, { isOver })` answers from the game's own rules\n`{ \"endCheck\": true, \"over\": true, \"results\": [{ seat, result }] }`. Only a confirmed `over` unlocks\n`showSeat(bot)` for the end-of-match reveal and settles the humans from `results` (same shape as a final\nstep). A false `over` changes nothing; a Worker with no `isOver` answers 501 and the relay stops asking;\nan unreachable Worker leaves the hands locked and is retried with the usual backoff. `isOver` is a\nquestion, not a move: it gets `(prev, claim, room, secret)` and must not change anything. Until the lock\nlifts a `showSeat(bot)` is retried by the client on `seat_live` every 4 s, so call it at the end as before.\n\n**`isOver` judges the prior state plus the claim, never the claimed board alone.** The final commit's\nboard is the client's word: a holder could send a forged game-over board with `over: true`. So the relay\nsends the Worker `board` = the state it held BEFORE the claim (`prev`, with `room.seq` and\n`room.currentSeat` that state's), and the committed turn as `claim: { seq, fromSeat, nextSeat, snapshot,\npatch?, auto? }` (untrusted, exactly as the client sent it). Apply the move to `prev` with your rules,\nconfirm it is legal for `room.currentSeat`, and answer `over: true` only if the result is terminal (you\nmay compare it with `claim.snapshot`, never rely on it). A game that only tests `claim.snapshot` for a\nfinished match confirms a forged one: that settles results and reveals the bot hands. A Worker whose\nrequest has no `claim` (an older relay) answers 400 and is never asked to judge the claimed board.\n\n**When the stand-in fails.** A game bug must not stop a bot: give `createStandinWorker` a\n`fallback(board, nowMs, rng, room, away, secret)` that plays the weakest legal move (a bot may return\n`deckOps` with the blind forms), and the Worker plays it when `step` throws, answers something invalid\nor takes over 4 s (replies carry the `x-miniant-standin-fallback` header). If the Worker is still\nunreachable, the relay retries a network error, timeout or 5xx at 1 s, 2 s and 4 s before it counts one\nfailure. As a last resort the relay turns on the **blind fallback** for a bot's turn after **2\nconsecutive failed steps for that turn, or 15 s since the turn started with no successful step**,\nwhichever comes first: every game socket gets `{\"type\":\"bot_fallback\",\"seats\":[...],\"on\":true}`, the Turn\nKit (with `botsByServer: true`) elects one tab per bot seat like an absent seat's driver, and that tab\nplays the bot's turn through your `onAutoTurn`. The game **must** use the blind forms there\n(`drawFor`, `moveFor` with `\"last\"` or `prefer`, `exchangeFor` with `{ count, prefer? }`):\nthe relay still refuses `showSeat(bot)` and never returns a bot's ids. The relay tries the Worker at the\nstart of every bot turn; the first success sends `bot_fallback` with `on: false`, the election ends and\nthe server plays on. A tab's blind move and a stand-in step for the same turn never both apply (the\nstand-in's reply is dropped, equal seq loses).\n\n## Limits\n\n- Players trust the relay's shuffle; there is no commitment you can audit afterwards.\n- In a two-seat match one player can show an absent opponent's hand alone. The reveal is public and\n `shown[k].by` names who did it.\n- A portal-filled AI seat in a room plays blind like any absent seat.\n- A room keeps the four newest decks. Creating a fifth drops the oldest: the relay sends `{\"type\":\"deck_evicted\",\"deck\":\"<id>\"}` to every game seat and every watcher before the create, a later `deck_state` never lists it, and `deck.on(\"evicted\", cb)` fires with the deck's state cleared; a call in flight rejects `DeckError(\"evicted\")`, and a later op on it is refused by the relay with `no_deck` (a new `create` revives the name).\n"},{"slug":"sdk/spectating","title":"Spectating","section":"SDK","agentSummary":"Letting a friend watch a live match read-only via capabilities.spectate: snapshot:true-tagged broadcasts and checkpoint() for room games, publishState/publishAction for solo/vs-AI games.","checklist":[{"id":"spectate.declare-only-with-publishstate","text":"Implement publishState in the same change that declares spectate on a solo or vs-AI game — the declaration is mandatory, and without publishState a spectator sees nothing.","verify":"manual"},{"id":"spectate.render-read-only","text":"Render a spectate visit strictly read-only: no move or input controls, no turn-action prompts.","verify":"manual"},{"id":"spectate.call-ready-as-normal","text":"Call Miniant.ready() in a spectate visit exactly like a normal session.","verify":"validator:sdk.ready-called"},{"id":"spectate.tag-complete-state-snapshot-true","text":"Tag every complete-state broadcast (turn-change, sync/retire reply, checkpoint) with {snapshot:true} — never a delta, input sample, or chat line.","verify":"manual"},{"id":"spectate.broadcast-action-frames","text":"Broadcast in-turn action frames, as broadcasts never sendTo, so spectators watch moves happen live — snapshots alone make a match recoverable, not watchable.","verify":"manual"},{"id":"spectate.checkpoint-multi-action-turns","text":"For a multi-action turn (e.g. two balls per frame), call kit.checkpoint() after every committed action and at the start of the next in-flight one, so a joiner can replay it.","verify":"manual"},{"id":"spectate.publishstate-once-then-on-change","text":"Call Miniant.spectate.publishState(snapshot) once as soon as the first board is on screen, then again on every meaningful change — the Watch button needs an observed publish.","verify":"manual"},{"id":"spectate.snapshot-size-cap","text":"Keep every publishState/publishAction payload JSON-serializable and at or under the 16KB cap.","verify":"manual"},{"id":"spectate.gate-on-spectator-flag","text":"Gate is-this-my-seat logic on context.spectator === true first; never rely on the player.seat === -1 sentinel alone.","verify":"manual"},{"id":"spectate.render-from-anchor-seat","text":"Render the board from context.spectateAnchorSeat's perspective, using your own local-player seat-orientation logic.","verify":"manual"},{"id":"spectate.no-social-sends","text":"Never rely on send() or sendTo() reaching anyone as a spectator; those calls are silently dropped, receive-only by construction.","verify":"manual"},{"id":"spectate.watcher-never-drives-match","text":"Never let a spectate visit start a local match or run the AI — a watcher only ever renders what publishState/publishAction/checkpoint/broadcasts deliver to it.","verify":"manual"},{"id":"spectate.no-local-multiplayer-for-watchers","text":"Never offer local_multiplayer (pass-and-play) to a spectate visit — there is nothing for a read-only watcher to pass.","verify":"manual"},{"id":"spectate.call-showend-not-own-end-screen","text":"When a watched match ends, call Miniant.spectate.showEnd(); never draw your own watcher end screen.","verify":"manual"}],"body":"# Spectating\n\n`\"capabilities\": { \"spectate\": true }` ([manifest reference](/guide/requirements/technical)) lets\na player's friend watch one of their in-play matches, read-only, without joining it — in\n**any** mode, not just room-based ones. It is **required on every manifest**: spectating is a core\nportal feature (friend watching, tournament broadcasts, the Live hub), and the validator fails a\nbuild that omits the declaration — see\n[Spectating in Multiplayer requirements](/guide/requirements/multiplayer#spectating). Two paths exist, and which one applies to your game depends\non whether the match has a relay room at all (`context.room`, non-null only for\n`online_friends`/`online_random`):\n\n## The two paths\n\n- **Room-based games (`online_friends`/`online_random`, `capabilities.relay: true`).** Spectating\n rides the exact same [`Miniant.net`](/guide/sdk/multiplayer) relay connection the match itself\n already uses: a spectator joining is served the relay's cached copy of the latest broadcast you\n tagged `{snapshot: true}` (see \"`snapshot:true`\" below) — nothing more is asked of\n your game, and nothing is ever sent to a player on a spectator's behalf.\n- **Solo/vs-AI games (`context.room === null`, no `relay` declaration required).**\n\n > **Do not declare `capabilities.spectate` on a solo game unless you implement `publishState` in\n > the same change.** Declaring it is what makes the portal offer a **Watch** button to the\n > player's friends; the snapshots are the only thing that gives those friends something to look\n > at. Ship the declaration alone and a friend taps Watch, connects successfully, and stares at a\n > permanently blank frame with no error explaining why — strictly worse than not declaring it at\n > all. Either do both, or leave `spectate` off.\n\n **The Watch button appears once the portal has observed a publish, independent of `relay`.** The\n portal offers watching a solo/vs-AI session the moment it has seen at least one\n `publishState`/`publishAction` call from it — declaring `capabilities.relay: true` for an\n unrelated online mode in the same manifest never withholds this (the old \"`relay: true` ⇒ the\n portal offers the ROOM path only\" rule was removed in commit `7e1f629`). A mixed game that runs\n both an online mode and a solo/vs-AI mode gets both paths watchable, each on the strength of its\n own traffic.\n\n There is no relay\n connection for a spectate visit to ride, so you call `Miniant.spectate.publishState(snapshot)`\n yourself, whenever your state changes meaningfully (a turn, a move, a score event). The portal\n receives it over the same `postMessage` channel every other SDK call already uses and forwards it\n to any watchers on your behalf — your game never opens a connection for this, and never knows\n whether or when anyone is watching. There is no `syncRequest` in this path at all — a newly-joined\n spectator is served the portal's cached copy of your last published snapshot directly, without\n invoking your game code.\n\n**Correction: spectating is no longer \"only meaningful alongside `relay: true`.\"** That was true\nbefore solo/vs-AI spectating shipped; it isn't anymore. With `relay: true`, spectating rides your\nexisting broadcasts (first bullet above, no extra call). Without it, you call `publishState`\nyourself (second bullet) — one code path works across both, described next.\n\n## One code path for both\n\n**A game with both a room mode and a solo/vs-AI mode needs only one branch:**\n\n```ts\nfunction broadcastSnapshot(snapshot) {\n if (context.room) {\n Miniant.net.send(snapshot); // room mode: your existing broadcast\n } else {\n Miniant.spectate.publishState(snapshot); // roomless: pushes to watchers yourself\n }\n}\n```\n\nYou can even skip the branch and call `Miniant.spectate.publishState(snapshot)` unconditionally\nalongside your normal `net.send`: in a room session it's a **documented no-op** — resolves\nimmediately and warns once via `console.warn`, never throws — because the room's own relay\nbroadcasts are already mirrored to spectators, so publishing again there would be redundant, not\nwrong. That no-op is what makes the unconditional call safe.\n\nOne thing that no-op does **not** cover: `publishState` still throws `CapabilityNotGrantedError`\nimmediately if your manifest doesn't declare `capabilities.spectate` at all — that check runs before\nthe room/roomless branch, so it fires the same way whether or not `context.room` is set. The no-op\nabove only applies once the capability is granted.\n\n## What `init()` hands a spectator\n\nYou do not build any spectate-specific UI, entry point, or \"who's watching\" list in either path —\nthe portal owns all of that (the friend clicks \"Watch\" from its own chrome, is dropped straight\ninto your game, in whichever mode it happens to be). The only thing that changes on your side is\nwhat `Miniant.init()` resolves with:\n\n```ts\nconst context = await Miniant.init({ sdkVersion: 1 });\nif (context.spectator === true) {\n // Read-only visit: render the board/table/whatever exactly as for a real player, but with every\n // input affordance removed — no move controls, no \"your turn\" prompts, nothing clickable that\n // would normally act on the game state.\n}\n```\n\n- **`context.spectator === true`** is the only signal, in both paths — everything else in `context`\n still looks like an ordinary session for that mode: `context.mode`/`context.capabilities` are\n populated normally, and for a room-based spectate visit specifically, `context.room` is non-null\n and `context.participants` lists the match's real seats (never a \"spectator\" entry among them).\n Absent/`undefined` (every session before this field existed, and every session that isn't a\n spectate visit) means \"this is a real seat\" — treat it the same as `false`.\n- **`context.spectateAnchorSeat` — whose view to render.** Present only in a spectate visit\n (whenever `context.spectator === true`), and always equal to one of `context.participants[].seat`.\n `spectator`/`player.seat: -1` only tell you the visit is read-only — they say nothing about WHICH\n participant's perspective a multi-seat game should draw, and without that signal a game has no\n principled way to choose (a real gap this field closes: a spectator of a 2-player game with no\n anchor rendered both players stacked in a neutral orientation instead of the view the watched\n friend's own client shows). The rule is universal, independent of player count: **render the board\n from the anchor participant's perspective, identically to how your own local-player view renders\n when THEY are the local player** — whatever seat-rotation/orientation logic you already apply for\n \"this seat is mine\" is exactly the logic to apply here, substituting the anchor's seat for your own.\n (For a 2-player game this typically reproduces the anchor-at-bottom/opponent-diagonal view; for an\n N-player game it reproduces whatever seat-ordering your own local-player view already applies —\n there's no special-cased 2-player rule, just \"however many seats you have.\") Omitted/`undefined`\n for every non-spectate session, same back-compat pattern as `spectator` itself.\n- **`context.player` is synthetic in a spectate visit — it is not a portal identity.** A spectator\n still gets a `player` object (so code that reads it unconditionally doesn't crash), but every\n field describes the visit, not a person: `seat: -1` — a sentinel value the platform guarantees no\n real seat is ever assigned (real seats are always `>= 0`) — plus a synthetic `id`,\n `displayName: \"Spectator\"`, and `isGuest: false`. **Never derive \"is this my seat\" by comparing\n `participants[i].seat === player.seat` alone** in a game that declares `capabilities.spectate` —\n gate on `context.spectator === true` *first*; once that's true you have no seat, full stop, and\n should render read-only. The `-1` sentinel guarantees the naive per-seat comparison can never\n accidentally match a real participant, but the explicit `context.spectator` check is the\n documented, required pattern — don't rely on the sentinel as a substitute for it.\n- **Call `Miniant.ready()` in a spectate visit exactly like a normal session.** The portal uses it\n to clear its own loading state for the visitor the same way it does for a real player — skipping\n it just leaves the spectator stuck on a loading screen. The reviewer's 30-second first-frame check\n (see [\"What the reviewer sees\"](/guide/publishing) in Publishing your game) applies to normal\n review sessions only; a spectate visit is never smoke-tested that way, so there's no\n build-approval consequence either way — but the person actually watching still needs a prompt\n `ready()` call.\n## Rendering read-only\n\n- **Render read-only, in both paths.** No move/input controls, no turn-action prompts.\n - Room-based: apply every incoming `Miniant.net.on(\"message\", …)` frame exactly as you already do\n for a remote peer's move.\n - Solo/vs-AI: `Miniant.net` still works in a spectate visit — regardless of whether your manifest\n declares `capabilities.relay` at all, since a spectate visit's `net` access is the portal's own\n delivery channel for the watched stream, orthogonal to your game's own relay declaration. Call\n `Miniant.net.connect()` (this is the one case where that succeeds without `capabilities.relay`\n declared), then render exactly the blobs the playing instance published, delivered as ordinary\n `msg` frames:\n ```ts\n Miniant.net.on(\"message\", ({ data }) => {\n render(data); // `data` is exactly what the playing instance passed to publishState(snapshot)\n });\n ```\n- **Your `send()`/`sendTo()` calls are silently dropped by the platform, relay-side** — a\n spectator's connection is receive-only by construction (not by convention your code has to\n enforce), in both paths. Don't rely on sending anything; if you skip building input affordances\n per the point above, you never will anyway.\n## Relay events during a spectate visit\n\n- **`peerJoined`/`peerLeft` list the match's real seats** in a room-based spectate visit,\n same as for any other room connection — a spectator's own presence is never announced as a peer to\n anyone (not to the real seats, not to other spectators). See\n [Absent players](/guide/sdk/multiplayer#absent-players): a spectator's join/leave never fires\n `peerJoined`/`peerLeft` for anyone at all. A solo/vs-AI spectate visit has no other seats to list\n in the first place — there's no relay room backing the match itself, only the portal's own\n forwarding.\n- **`reportProgress()`/`reportResult()` may be called but are no-ops** — the portal acknowledges\n them (so your code never hangs waiting on a reply) without recording anything. Simplest to just\n skip calling them at all in a spectator session, but it's safe not to bother checking first.\n## `snapshot:true` and catching up a spectator mid-match\n\n**`snapshot:true`.** `Miniant.net.send(payload, { snapshot: true })` marks one broadcast as\n**complete state** — someone who has seen nothing else could render the whole board from it alone.\nThe relay caches the most recent tagged broadcast and hands it to a spectator the moment they join,\nso a watcher sees the real board immediately instead of an empty frame.\n\n- **Tag every complete-state broadcast**: the turn-change snapshot a turn-based game already sends,\n a hand-rolled realtime game's own periodic full-state broadcast, and — for a multi-action turn —\n every `kit.checkpoint()` (see [Turn Kit](/guide/sdk/turn-kit#reconnect-and-spectator-recovery-and-checkpoint)). **Never tag**\n a delta (\"player 2 moved a token\"), an input sample, or a chat line. `snapshot` is ignored on\n `sendTo` — it only means anything on a broadcast, since it's a statement about the room's state,\n not a private message to one seat.\n- **What lying costs.** The relay never looks inside your payload — tagging is a promise, not\n something it can check. Tag a delta and every future watcher of this game boots from a wrong\n board, which is worse than a slow one — this is the exact failure that took spectating off the air\n in 2026-08 (migration 0036).\n- A [Turn Kit](/guide/sdk/turn-kit) game gets this for free: every `turn`/`sync`/`retire` frame the\n kit sends is already tagged. A hand-rolled `realtime` game tags its own broadcasts explicitly:\n\n ```ts\n Miniant.net.send(getFullSnapshot(), { snapshot: true });\n ```\n\n- **Room-based: no wire request to your game at all.** A spectator joining is served the relay's\n cached `{snapshot: true}` broadcast directly — the relay answers from its own cache, without\n invoking any game code. **There is no `sync_request` on a spectator join** (removed 2026-09-21,\n after a watcher's join froze a live `penaltyshootout` match — asking a seat to rebroadcast state\n on a spectator's behalf injected an unsolicited frame into the middle of that seat's own in-flight\n animation. Watching must never be able to reach the players, no matter how well any one game\n guards its own snapshot handler). If nothing has been tagged yet, the spectator's board stays\n empty until your next tagged broadcast — there's no other bootstrap in this path.\n\n **A GAME seat itself reconnecting is unaffected — that case still triggers `sync_request`.** A\n player refreshing mid-match, or (re)joining a room that already has another live GAME peer, still\n gets the relay's `{\"type\":\"sync_request\"}` nudge to the room's lowest-seat connected peer, exactly\n as before:\n\n ```ts\n Miniant.net.on(\"syncRequest\", () => {\n // A peer (re)connected into a room with another live peer — rebroadcast your full snapshot,\n // the same one you tag {snapshot: true} on a turn change. Never fired for a spectator join.\n });\n ```\n\n A [Turn Kit](/guide/sdk/turn-kit) game answers this automatically; a hand-rolled `realtime` game\n must answer it itself — see [Multiplayer](/guide/sdk/multiplayer#answering-syncrequest).\n- **Solo/vs-AI: you never see `syncRequest` at all, in either direction.** The portal answers for\n you — a newly-joined spectator is caught up from the portal's own cached copy of your last\n `publishState(snapshot)` call, entirely outside your game's code.\n- **Required for a good spectator experience:**\n - **Room-based: tag a complete-state broadcast on every turn change (or at least every few\n seconds)**, not just incremental deltas — see \"`snapshot:true`\" above. A\n spectator can join mid-match with no history of prior moves; untagged, incremental-only traffic\n leaves them looking at a stale or empty board until you happen to tag one. This is the hard\n correctness requirement spectating asks of a room-based game's code.\n - **Room-based, multi-action turns: call `kit.checkpoint()`.** A turn that covers several\n committed actions (bowling: two balls per frame) otherwise always replays from the turn's start\n for a spectator who joins mid-turn, however far play has actually progressed. Call it after\n every committed action, and again at the start of the next action with that action already\n in-flight inside `getSnapshot()` — see [Turn Kit](/guide/sdk/turn-kit#reconnect-and-spectator-recovery-and-checkpoint) below.\n - **Room-based: broadcast the action itself, live — snapshots make a match recoverable, not\n watchable.** A spectator receives exactly the frames the seats exchange, so a game whose only\n traffic is committed snapshots shows its audience a board that teleports between end states.\n Send a small action frame the moment the acting player's input lands and animate from it on\n every receiving client — spectators get it with no extra work from you. The pattern (and how\n it coexists with the Turn Kit's own frames) is documented at\n [Show the action live](/guide/sdk/turn-kit) and the requirements-level rule at\n [Broadcast the action, not just the outcome](/guide/requirements/multiplayer).\n - **Solo/vs-AI: call `publishState(snapshot)` once as soon as the first board is on screen, then\n again on every meaningful state change** (a turn, a move, a score event) — not on a timer and\n not per animation frame. There's no `syncRequest` fallback in this path, so your last published\n snapshot is genuinely the only thing a newly-arrived spectator ever sees until your next call.\n Publishing at all — even once — is also what makes the portal offer the Watch button in the\n first place; see \"The two paths\" above.\n - **Solo/vs-AI, live motion: call `publishAction(action)`.** `publishState` alone makes a match\n recoverable, not watchable, for exactly the same reason a room-based game needs action frames —\n see \"`publishAction`\" below.\n\n## Live action, the spectator kit, and explicit rules\n\n**`publishAction`.** `Miniant.spectate.publishAction(action)` publishes a single discrete LIVE event for a roomless\n(solo/vs-AI) spectate visit — \"the dice just landed,\" \"a ball was just launched\" — the roomless\ncounterpart of a room-based game's own action frames\n([Show the action live](/guide/sdk/turn-kit#show-the-action-live)). It shares the same\ncapability gate, 16KB payload cap, and rate budget as `publishState` — the two are not separate\nallowances.\n\n- **Never cached, never replayed to a joiner.** A spectator who joins mid-match boots from the last\n `publishState` snapshot only, then receives actions live from that point on, same as a player\n would — calling `publishAction` before any `publishState` call reaches nobody but a spectator who\n happened to already be watching.\n- **One call per discrete event, never per animation frame** — same rate budget `publishState`\n shares with it: **240 messages per minute, 16KB per frame** (`SESSION_PUBLISH_RELAY_MAX_RATE`/\n `SESSION_PUBLISH_RELAY_MAX_BYTES`, 2026-09-25). One shared budget across three sources on this\n channel: every `publishState` call, every `publishAction` call, AND the portal shell's own resend\n of your last snapshot to a newly-arrived watcher (see \"`snapshot:true`\" above) — none of those are\n separately metered. If your game exceeds it, the relay silently drops the overage frame with no\n error surfaced to your code; publish at human-action cadence (a move, a turn, a score event, a\n discrete live event) and you will not come close.\n- A watcher receives these as the `spectateAction` net event, never `message` — see\n `createSpectatorKit`'s `onAction` below for the shape a game actually has to handle.\n\n```ts\n// The instant the input lands:\nMiniant.spectate.publishAction({ k: \"flick\", shot });\n```\n\n**`showEnd`: the match-end card.** Don't draw a watcher end screen. When the match ends, the spectator's own instance\ncalls `Miniant.spectate.showEnd({ title, detail?, scores? })` and the portal draws one card for\nevery game: your title, optional detail, score rows, and **Exit** (plus \"Watch the rematch\" once a\nrematch is in play). `title` (≤ 80 chars) is the headline, e.g. `\"Red wins\"` or\n`\"Rumi left — Red wins\"`. `detail` (≤ 120) only adds what the title doesn't say. `scores` is 1–8\n`{ name, score }` rows in your results board's order — never format a score into `detail`. Outside\na spectate visit it's a no-op that warns once. Without it the card still appears, as \"Match ended\".\n\n```ts\nMiniant.spectate.showEnd({ title: \"Red wins\", scores: [{ name: \"Red\", score: 3 }, { name: \"Blue\", score: 1 }] });\n```\n\n**`createSpectatorKit`: the receiving end.**\n`Miniant.createSpectatorKit({ applySnapshot, onHolder, onAction, onEnded, readAction?, readState? })`\nis the watcher-side counterpart — used only when `context.spectator === true` — that turns the wire\nshapes above (tagged room broadcasts, `checkpoint()`/`state` frames, roomless\n`publishState`/`publishAction` blobs, and — via `readState` — a hand-rolled realtime game's own\ncomplete-state frames) into one consistent stream your rendering code consumes. It calls\n`net.connect()` itself at construction — there's no separate `start()` — and never owns that\nconnection's lifecycle: its own `close()` only unsubscribes the kit's listeners, never `net.close()`.\n[`Miniant.createTurnKit`](/guide/sdk/turn-kit) is the participant-side counterpart and refuses a\nspectator visit outright (2026-09-25): it throws immediately if `context.spectator === true`, so a\nwatcher's client always uses this kit instead, never the other one.\n\n- **`applySnapshot(snapshot, { source, holder })`** — called with a COMPLETE snapshot only, never a\n delta: a room `turn`/`sync`/`retire`/`state` frame's `snapshot`, a roomless `publishState()` blob\n (`source: \"publish\"`), or a `readState`-classified custom frame (`source: \"custom\"`, see below).\n `holder` is the seat now holding the turn where the frame carries one — `null` for `\"publish\"`\n (a roomless publish carries no turn concept at all). **`\"retire\"` now reports `holder` too**\n (2026-09-25): a `RetireFrame` names the seat taking over from the one that just retired, so a\n joiner no longer has to infer the new holder from the snapshot body itself the way it used to.\n Ordering is `(seq, n)` lexicographic, an equal pair accepted (the relay's own cached bootstrap\n frame, or the portal shell's own resend to a newly-arrived roomless watcher — see the note below),\n a strictly older one refused.\n- **`applySnapshot` must be idempotent for an identical blob.** A roomless spectate visit can\n legitimately receive the exact same snapshot twice: the relay's own cached-frame bootstrap and\n `useSpectatePublish`'s first-paint resend (see \"`snapshot:true`\" above) are two independent paths\n that can both deliver the player's last published snapshot to the same newly-joined watcher.\n `applySnapshot` has no way to distinguish \"the state really is unchanged\" from \"this frame was\n redelivered\" — don't treat a second call with equal `(seq, n)`/identical content as an error or a\n reason to re-run first-load side effects (an entrance animation, a sound cue); render it the same\n way you'd render any other snapshot.\n- **`applySnapshot(snapshot, { info.ageMs })`** (2026-09-25) — how many milliseconds old this\n snapshot already was when the relay served it. Present only on the cached frame a JOINING or\n reconnecting watcher is bootstrapped from; absent (`undefined`, never `0`) on every live broadcast,\n since nothing there was cached at all. Use it to fast-forward a turn clock so a newly-arrived\n watcher doesn't see a countdown that started ticking before they joined:\n `const turnMsLeft = Math.max(0, snapshot.turnMsLeft - (info.ageMs ?? 0))`. Do **not** use it to\n fast-forward a physics replay — an in-flight action (a ball mid-roll, a die mid-tumble) must still\n replay from its own launch so it stays in lockstep with the deterministic animation every other\n viewer is already watching; only a display countdown is safe to offset by it.\n- **`onHolder(seat)`** — fired only when the holder actually *changes* from what the kit last\n reported, never on a same-holder replay or a mid-turn checkpoint. Now also fires off a `\"retire\"`\n frame when it reports a new holder (see the `applySnapshot` bullet above) — previously `\"retire\"`\n never changed the kit's notion of holder, so a game reading only `onHolder` (never `applySnapshot`'s\n own `holder` field) silently missed the handoff.\n- **`readState?(data) => { snapshot, holder?, version? } | null`** — for a hand-rolled `realtime`\n game (no Turn Kit) that already tags its own complete-state broadcasts (`{ snapshot: true }`, per\n \"`snapshot:true`\" above) in a shape the kit doesn't otherwise recognize. Called for every incoming\n `\"message\"` frame that isn't already one of the kit's known kinds; return `null` to say \"not one of\n mine\" (the kit ignores the frame) or `{ snapshot, holder?, version? }` to hand it to `applySnapshot`\n as `source: \"custom\"`. **With `version`**, the frame is ordered under the same `(seq, n)` watermark\n as every other source, as `(version, 0)` — a strictly older `version` is refused, an equal one\n accepted (so your own redelivery, e.g. answering a reconnecting peer's `syncRequest` with the same\n state, is safe exactly like the relay's own cached-frame replay). **Without `version`**, the frame\n is applied unconditionally, every time — appropriate only for a game whose own frames already\n self-order (e.g. a monotonic tick counter baked into `snapshot` itself), never for one that might\n replay an old broadcast.\n- **`onAction({ seat, data, from })`** — fired for a room-path frame `readAction` classifies as an\n action (default `readAction` reads a plain `{ seat?, seq? }` shape; override it for your own\n frame shape), or a roomless `publishAction()` event (`seat` always `null` there). `seat` is this\n kit's own attributed seat (`null` when it can't be trusted — see \"Explicit rules\" below); `from`\n is the untouched relay-assigned sender; `data` is the raw payload. Deduped by `(from, seq)` when\n `readAction` returns a `seq`.\n- **`onEnded({ reason })`** — fired once, on `net`'s `\"roomClosed\"` event; never carries a winner,\n only the relay's own closure reason (or `\"room_closed\"` when it supplied none) — a spectate visit\n never learns or reports a result.\n\nThere is no shipped reference implementation for the room/checkpoint path yet; the kit's own tests\n(`packages/game-sdk/tests/spectator-kit.test.ts`,\n`packages/game-sdk/tests/turnkit-checkpoint.test.ts`) show its usage until one exists. For the\nroomless publishing side, see tic-tac-toe's `src/spectate.js` and miniantludo's\n`src/ui/spectatePublish.js`.\n\n**One example per session kind:**\n\n```ts\n// Room, turn-based (Turn Kit) — spectator side:\nconst kit = Miniant.createSpectatorKit({\n applySnapshot: (snapshot, { holder }) => game.load(snapshot, holder),\n onHolder: (seat) => ui.setActiveSeat(seat),\n onAction: ({ seat, data }) => animateFromAction(seat, data),\n onEnded: ({ reason }) => ui.showMatchEnded(reason),\n});\n\n// Room, hand-rolled realtime — same kit, but with `readState` filling in for the Turn Kit frame\n// shapes it doesn't recognize: your OWN tagged { snapshot: true } broadcast (whatever shape you\n// already chose for it) is what readState has to unpack.\nconst realtimeKit = Miniant.createSpectatorKit({\n readState: (data) =>\n data && typeof data === \"object\" && \"board\" in data\n ? { snapshot: data.board, holder: data.turnSeat, version: data.tick }\n : null,\n applySnapshot: (snapshot, { holder }) => game.load(snapshot, holder),\n onAction: ({ seat, data }) => animateFromAction(seat, data),\n onEnded: ({ reason }) => ui.showMatchEnded(reason),\n});\n\n// Roomless (solo/vs-AI) — same kit again; publishState()/publishAction() blobs\n// arrive through the same applySnapshot/onAction callbacks (source: \"publish\", seat: null),\n// no branch needed.\n```\n\n**Explicit rules.**\n\n- **Live action frames must be broadcasts, never `sendTo`.** A spectator's connection is\n receive-only and never a target of a `sendTo` — a game that only ever sends action frames with\n `sendTo` shows spectators nothing.\n- **A watcher instance must never start a local match or run the AI.** Render exactly what\n `publishState`/`publishAction`/tagged broadcasts/`checkpoint()` deliver to it — nothing computed\n locally.\n- **An action frame for an auto-driven seat names the seat it describes**, not the seat that sent\n it — see [Turn Kit](/guide/sdk/turn-kit)'s `endTurnAs`/`auto` framing for the same convention on\n the state side.\n- **A spectator never sends frames players act on, and never reports a result.** `send()`/`sendTo()`\n from a spectator's own connection are dropped platform-side (see \"Rendering read-only\" above), and\n `reportProgress()`/`reportResult()` are no-ops in a spectate visit.\n- **`local_multiplayer` (pass-and-play) is never offered to a spectate visit** — there is no remote\n stream to watch for a mode where every seat is a human sharing one device.\n## Snapshot size, cadence, and cost\n\n- **Snapshot shape and cost — `publishState` specifically.** `snapshot` must be JSON-serializable;\n `publishState` throws `SPECTATE_SNAPSHOT_NOT_SERIALIZABLE` synchronously if it isn't, and\n `SPECTATE_SNAPSHOT_TOO_LARGE` (naming the actual byte count and the cap) if the serialized form\n exceeds **16KB**. This traffic is not relay traffic against your own declared budget — a solo/\n vs-AI game has no `network` block to declare in the first place ([manifest reference](/guide/requirements/technical))\n — and when nobody is watching it costs nothing beyond the call itself: the portal only caches the\n snapshot until a spectator connects, it forwards nothing over the wire until someone actually does.\n When someone IS watching, delivery rides the portal's own small, fixed budget for this channel, not\n anything your manifest declares. The practical rule either way: keep snapshots at or comfortably\n under the 16KB cap, and publish at human-action cadence (a move, a turn, a score change) — never\n per animation frame, whether or not anyone happens to be watching.\n- **What counts against your rate budget — room-based games:** fan-out to any number of spectators is\n free (rate limits are enforced per-connection on your own *inbound* sends only), and spectator\n traffic never feeds the anti-cheat `RELAY_RATE` signal (see\n [Message-rate budget](/guide/sdk/multiplayer#message-rate-budget)) — but your own snapshot sends\n above, both the turn-change ones and the `syncRequest`-answered ones (a peer resyncing, never a\n spectator join), are real sends from your seat and do count against your declared\n `network.expectedMessagesPerMinute`\n ([manifest reference](/guide/requirements/technical)). Solo/vs-AI games have no such budget to\n worry about — see the `publishState`-specific cost note above instead.\n- `Miniant.net.on(\"spectatorCount\", cb)` fires with `{ count, subjects }` whenever the room's\n spectator headcount changes, if your game wants its own subtle \"N watching\" indicator — available whenever\n `capabilities.relay` is granted, whether or not you declare `capabilities.spectate` yourself, and\n reaches your game connection in a real room-based match exactly as documented, no change here.\n **The portal already shows this count to the match's own seat-holders in its own chrome — don't\n duplicate it for them**; this event is for your own optional in-game indicator, if any, not a\n replacement for the portal's. There is no equivalent for a solo/vs-AI game: your game never holds a\n `net` connection to the spectate channel in that path (only the portal shell does, on your behalf),\n so it has no way to learn whether, or how many, spectators are watching — by design.\n `subjects` is the watching players' subject ids, matching the `id` field on `participants`\n entries. It is optional and absent on relay builds that predate it, so fall back to `count` alone\n rather than rendering an empty list. The ids are **not** display names and the SDK gives you no\n way to resolve one: a spectator is never a seat-holder, so they will not appear in\n `context.participants`. If you show anything at all, show the count — the portal already names\n the watchers to the player in its own chrome.\n\n## Stand-in: keep a roomless match playing while the phone sleeps\n\nA `vs_ai` match, or an `online_random` match filled with AI, runs in the player's page, so it freezes\nwhen the phone sleeps and every watcher sees a dead board. A **stand-in** is a small per-game Cloudflare\nWorker the relay calls to play the AI's turns on the server.\n\nThe relay `POST`s JSON `{ board, nowMs }` with `authorization: Bearer <STANDIN_SECRET>` and expects\n`{ board, delayMs, done }` back (plus `result` when `done` is true, and optional `actions`, below). `board` is the same blob you\npass to `Miniant.spectate.publishState`, and it must carry a top-level numeric `n` (the committed-action\ncount); the returned `board.n` must be greater. `createStandinWorker` handles the HTTP, auth and\nvalidation; you write only the step.\n\nHidden information (hands, fog, unfound items) goes in a top-level `secret` field: the relay gives it to\nyour stand-in and to the player's own tab, never to watchers. Never put hidden information anywhere else\nin the board. Here is the step:\n\n```ts\nimport { createStandinWorker } from \"@mag/game-sdk/standin\";\nimport { applyMove, chooseAiMove, isOver, resultFor } from \"./rules\";\nimport { THINK_MS, THINK_JITTER_MS, animationMsFor } from \"./pacing\"; // shared with your tab\n\nexport default createStandinWorker((board, nowMs, rng) => {\n const next = applyMove(board, chooseAiMove(board, rng)); // must bump next.n\n const done = isOver(next);\n // delayMs = the think time plus this move's animation, exactly as on screen.\n const delayMs = THINK_MS + rng() * THINK_JITTER_MS + animationMsFor(board, next);\n return { board: next, delayMs, done, ...(done ? { result: resultFor(next) } : {}) };\n});\n```\n\n**Pace the stand-in like the real match.** `delayMs` is when the relay calls again, and watchers\nanimate each step as it arrives. Set it to the same think time your game uses plus the length of the\nanimation the move plays on screen (import both from the code your tab uses), so a stand-in match\nlooks exactly like one the player is playing. A shorter `delayMs` plays the match at fast-forward.\n\n**Hold the away player's turn.** The relay calls your stand-in as soon as the player goes away, and\nsends `absentSinceMs`, when that absence began, as the step's fifth argument. An away player keeps\ntheir turn time (30 s unless your game's turn is shorter or longer) before Auto plays it, so when the\nseat to move is the player's and its deadline has not passed, return a hold instead of a move:\n`holdUntil(deadlineMs, nowMs)` gives `{ hold: true, delayMs }`, or `null` once the deadline is past.\nA hold changes nothing and broadcasts nothing; the relay calls again after `delayMs` (at most 10 s, so\na long turn is held over several calls). AI seats move at once, as on screen.\n\n```ts\nimport { createStandinWorker, holdUntil } from \"@mag/game-sdk/standin\";\n\nexport default createStandinWorker(\n (board, nowMs, rng, room, away) => {\n if (away && isPlayersTurn(board)) {\n const hold = holdUntil(board.turnDeadlineMs ?? away.absentSinceMs + TURN_MS, nowMs);\n if (hold) return hold;\n }\n // ...the move, as above\n },\n { holdsAwayTurn: true },\n);\n```\n\n**On a reload, adopt the held board.** A `vs_ai` or AI-filled `online_random` page that loads gets\nthe room's held board once, as `Miniant.on(\"standin_state\", ({ board, stepped }) => …)` with\n`stepped: true`, or `{ board: null, stepped: false }` when the room holds none yet (deal fresh only\nthen). Register the listener before `Miniant.ready()`: the portal delivers it right after ready.\n\n`{ board: null, stepped: false, timedOut: true }` is different: the portal waited 5 s and the relay\nnever answered, so nobody knows whether a match is held. **At page boot, never start a fresh match\non `timedOut`** (a held match would be overwritten); show a retry instead. On a reconnect your game\nalready has a live match: ignore the flag and carry on. `timedOut` is absent on the relay's\ndefinite answer, so at boot `{ board: null, stepped: false }` without it still means \"no match yet\".\nA flagged answer at boot can be followed by an unflagged `{ board: null, stepped: false }`: that later\none is the relay's definite \"no match yet\", so a game showing the retry may start a match then. After a\ntimed-out boot, later relay answers in the same page session stay flagged until the relay's definite\nnull arrives. Older portals never send the field. After a timed-out boot a late held board can still\narrive as `{ board, stepped: true }`; adopt it under your game's usual newer-board rule (the room's\nmatch wins, R4).\n\n`{ board, stepped: true, heldOnly: true }` means the room held this board but the stand-in never\nplayed it (the player reloaded; nothing was played for them). Adopt the board as usual, **keep Auto\nas it was (off) and do not arm a hand-back**: the player is present. Without `heldOnly`, `stepped:\ntrue` keeps its meaning (the stand-in played: adopt, Auto on, hand-back armed). `heldOnly` is never\n`false`; it is absent on a played board and from older portals. It reaches every game through the\nSDK runtime; a game acts on it once its handler reads it.\n\n`{ holdsAwayTurn: true }` is what tells the relay to call at once; declare it only when your step\nholds as above. Without it (or with a stand-in built before this SDK) the relay keeps its old\nfirst-step delay (10 s after the page hid, 60 s after a silent drop), and a hold answer is refused.\n\n**Watcher events.** Return `actions` for the short-lived watcher events (hits, sounds) your tab sends\nwith `publishAction`, in order; the relay delivers them to watchers like the tab's own. Each item is\nexactly what you would pass to `publishAction`; at most 32 per step, each within the same 16 KB per-frame\ncap, otherwise the Worker answers 500. Actions are not cached (a watcher who joins later never replays\nthem) and a top-level `secret` on one is stripped.\n\n> **Not available yet: server-played bot seats.** The next two paragraphs (the stand-in playing AI\n> seats of a sealed-deck room, and the `isOver` end check) and the blind fallback mentioned under\n> \"The relay retries\" describe the next release. In this release `capabilities.botsByServer` is rejected by the validator and no room is\n> flagged, so the tabs play the bots and the relay never asks `isOver`. A stand-in that only keeps a\n> match going for sleeping players (everything above) works as described.\n\n**Sealed decks: the stand-in plays the AI seats.** In a room that deals a [sealed deck](/guide/sdk/sealed-deck),\nno client sees another seat's hand, so the stand-in is the only reader of the AI seats' hands. The\nrequest carries them under a top-level `secret.decks` (`{ [deckId]: { [seat]: string[] } }`, passed to\n`step` as the sixth argument: every synthetic seat, and every human seat while all humans are gone), and\nthe reply may carry `deckOps: [{ deck, op, seat, ... }]`, applied in order as that seat (a synthetic seat\nor a human the stand-in stands in for; one refusal fails the step). The relay calls the stand-in for a\nbot's turn while humans are connected too. Hands never leave the POST body; never put one in `board`.\nSee [Sealed deck](/guide/sdk/sealed-deck#ai-seats-only-the-stand-in-sees-their-hands).\n\n**End check: `isOver`.** A match that ends on a human's move never makes the Worker step, so the relay\nasks. The final `turn` commit may carry `over: true` (`kit.endTurn(patch, true)`); the relay treats it as\na claim only and POSTs the room step body plus `\"endCheck\": true` (and `secret.decks`), where `board` is\nthe state the relay held BEFORE the claim (never the client's claimed board) and `claim` is the committed\nturn `{ seq, fromSeat, nextSeat, snapshot, patch?, auto? }`. Pass\n`createStandinWorker(step, { isOver })`, where `isOver(prev, claim, room, secret)` returns\n`{ over: boolean, results? }` from the game's rules (apply `claim` to `prev`, confirm the move is legal for\n`room.currentSeat`, answer `over: true` only if the result is terminal; a game that only tests\n`claim.snapshot` confirms a forged game-over board), with `results` (one report per human seat, as in a\nfinal step) exactly when `over` is true. The Worker replies `{ \"endCheck\": true, \"over\": true, \"results\": [...] }`;\na malformed `isOver` answer is a 500, a request that is not room mode or has no `claim` a 400, and a Worker without `isOver`\nanswers 501 (the relay then stops asking). `isOver` must not change anything and nothing it returns is\napplied except a confirmed `over` (it unlocks the bot hands and settles the humans).\n\n**A failed step never fails the match.** Pass `fallback(board, nowMs, rng, room, away, secret)` to\n`createStandinWorker` (the game's weakest legal move, same contract as `step`; for sealed-deck bots it may\nreturn `deckOps` with the blind forms). When `step` throws, answers something the contract refuses or\ntakes longer than the time budget (**4 s**, `stepBudgetMs`; set `slug` to name the game in the error log,\nwhich never contains board content), the Worker plays `fallback` and answers a valid step with the header\n`x-miniant-standin-fallback: 1`, which the relay counts as `standinFallbacks`. The budget races an `await`:\nWorkers freeze `Date.now()` during synchronous compute, so a `step` that spins without awaiting cannot be\ninterrupted. Keep `step` synchronous and cheap. A game SHOULD supply `fallback`: without it, or when the\n`fallback` itself fails, the step answers 500, never a hold (a hold is only for a genuine wait; holding on\nan error would retry the same bug forever). The relay counts the failure, and past its threshold a player's\ntab plays the seat blind, so the match keeps moving.\n\n**The relay retries before it counts a failure.** A network error, timeout or 5xx from the stand-in is\nretried at 1 s, 2 s and 4 s (on the room's alarm, so an eviction does not lose it); only then is it one\nfailure, with the 5 s to 60 s backoff after that. A 4xx (a contract refusal) is not retried quickly. The\nroom's telemetry report carries `standinFailures`, `standinFallbacks` and `botFallbackTurns`. In a\nsealed-deck room the failed bot turn finally turns on the blind fallback (see\n[Sealed deck](/guide/sdk/sealed-deck#ai-seats-only-the-stand-in-sees-their-hands), \"When the stand-in fails\").\n\n**A match the stand-in finishes is reported by the server.** The tab may never wake, so when `done` is\n`true` the reply must also carry `result`: exactly what your game would pass to `Miniant.reportResult(...)`\nfor that match (`outcome`, and optionally `score`, `detail`, `forfeit`, `placement`), from the seat the\nplayer holds. `durationMs` may be omitted: the board has no start time, so the portal measures it from\nthe session start. The Worker checks it against the same schema as `reportResult` and\nanswers 500 if it is missing or invalid; `done: false` must not carry one. The portal then records it\nthrough the same path as a tab's report, so verdict, points and tickets work as for any match, and marks\nthe session `resultSource: \"standin\"`. If the tab wakes afterwards it adopts the finished board and\ncalls `reportResult` as usual; that call is acknowledged as accepted and records nothing a second time.\n\n`result.perParticipant` may be empty or omitted. The stand-in cannot know player ids (the spectate board\ncarries none, because watchers receive it), so when it is empty the portal attributes the match to the\nsession owner with the result's `outcome` and `score`. Put per-seat standings in `detail` (for example\n`rankings` or `teams`) if the game needs them; ids are not required.\n\nDeclare `\"capabilities\": { \"standin\": true }` in `miniant.json`. **This is required** for a game that\ndeclares `spectate` and has a `vs_ai` or `online_random` mode: without it the validator fails the build\n(`standin.required`: \"Spectatable games with vs_ai/online_random must ship a stand-in (capabilities.standin)\nso a sleeping player's match keeps playing for watchers.\"). The validator rule covers only those modes\ntoday, but every mode except `solo` is a server room: an `online_friends` room whose players are all\naway is played by your stand-in too (room mode, `synthetic: []`), and the room reports the result. The\nvalidator also requires `standin/wrangler.jsonc` (or `wrangler.toml`) with a\n`main` once `standin` is declared. The portal lists a game in its home and \"All live\" sections only when it\ndeclares both `spectate` and `standin`.\nDeploy with `wrangler deploy --config standin/wrangler.jsonc`\non the Miniant account, after setting the secret with `wrangler secret put STANDIN_SECRET`.\n\n**Ship `standin/fixture.json`.** It is the exact request body of one step, `{ board, nowMs }` (plus `room`,\n`absentSinceMs` or `secret` if your step reads them), taken from a real mid-match board. After every\ndeploy, the SDK's `deploy-standin.sh` posts it to the live Worker with the real secret and fails loudly\n(non-zero exit, naming the game) unless the reply is HTTP 200 with a valid step (`board`, `delayMs`, `done`)\nor a hold. Run it by hand with `node node_modules/@mag/game-sdk/scripts/standin-smoke.mjs <url> standin/fixture.json`.\nA game without a fixture only gets a warning today.\n\n### Board rooms (non-Turn-Kit room stand-in)\n\nA real-time game that is not a Turn Kit game can have its room played on by the stand-in too, once every\nplayer's socket has closed. The game sends its whole board with `Miniant.net.send(board, { snapshot: true })`;\nthe board is an object with a top-level integer `n` and no top-level string `k` (a string `k` makes the\nrelay treat it as a Turn Kit frame). Opt in on both sides: `createStandinWorker(step, { roomBoard: true })`\n(the Worker then marks every reply with `x-miniant-standin-room-board: 1`) and\n`\"capabilities\": { \"standin\": true, \"standinRoomBoard\": true }` in `miniant.json` (the validator rejects\n`standinRoomBoard` without `standin`). The relay commits a reply only when it carries that header; a Worker\nthat never sends it ends the stand-in for that room at its first call and is never retried.\n\nThe step gets `room = { board: true, seats, synthetic }` and `away = { absentSinceMs, awaySeats }`\n(when each seat went away); hold a seat's turn with `holdUntil(away + turnMs)` and record on the board\nwhich seats are already Auto, because Away is not Auto: once Auto, stays on. Return a board whose `n` is above\nthe input's; when `done` is true, return `results` (one `reportResult` payload per human seat).\nThe reply board, `secret` included, must fit your `maxMessageBytes`, or the step fails.\n\n- **`n` is shared match progress**, not a per-tab counter: the simulation tick (or match-elapsed time\n from a start stamp inside the board) that any author computes the same way from the same board. Tabs,\n the stand-in and a new settler must agree on it.\n- **`secret`** (optional top-level field) is hidden from watchers only. Every game seat sees it, so never put one\n seat's private data in it.\n- **Pace:** the relay never schedules a step or hold sooner than **250 ms** after the previous one. Each\n step is a Worker call plus a frame to every watcher: advance several ticks per call and return the\n matching `delayMs`.\n- **Trust rule:** in a game seat, a board frame whose `from` is `-1` was written by the relay (the stand-in)\n and may be adopted when its `n` is higher than yours; a frame from a seat is that seat's own claim. A\n watcher applies the same rule only when `fromSubject` is also absent, because watchers chat with `from: -1`\n plus `fromSubject`.\n- **Hand-back:** a returning seat receives `standin_state { board, stepped: true }` before `hello`.\n Adopt it only if `board.n` is higher than yours (the stored-board replay that follows is then a\n no-op). The relay drops a seat's snapshot frame whose `n` is not above the stand-in's until a seat frame\n above it has been held.\n- **All seats hidden:** the stand-in also starts **10 s after every seat's tab is hidden**, even while the\n sockets stay open (locked phones). The first seat that becomes visible again takes the match back and is\n handed `standin_state`; a seat that reconnects still hidden changes nothing. Each seat's own away start\n reaches your Worker in `awaySeats`. Games SHOULD NOT send snapshot frames while `document.hidden`: while\n the stand-in plays, the relay drops a hidden seat's snapshot frame.\n- **Deploy order:** (1) the platform (relay, portal validators, minting); (2) the SDK release; (3) your\n Worker redeployed with `roomBoard: true`; (4) only then declare `standinRoomBoard` in `miniant.json` and\n republish. Capabilities are stored at publish, so declaring it earlier silently does nothing.\n\n## Player privacy\n\n- **Player privacy, both paths.** Every player has an \"Allow friends to watch my games\" switch in\n their own portal settings (on by default). Your game never sees or handles this switch in any\n way — it's mentioned here only so you understand why a spectate-capable game may simply never\n receive a visitor: a player who has turned it off is never watchable, and your game has no way to\n detect or query that state.\n\nSee also: [Multiplayer](/guide/sdk/multiplayer) for the underlying `Miniant.net` relay contract that\nroom-based spectating rides, and [Multiplayer requirements](/guide/requirements/multiplayer) for the\n`capabilities`/manifest-level framing.\n"},{"slug":"sdk/highlights","title":"Highlights","section":"SDK","agentSummary":"Optional Miniant.highlights records a canvas/WebGL match and posts a ~16.5 s vertical clip on a win: start(), frame() after each render, mark() per event, setScore(), then reportResult win.","checklist":[{"id":"highlights.start-once-before-ready","text":"Call Miniant.highlights.start({ canvas }) once per match, after Miniant.init() and before Miniant.ready(); a new match needs a new start(). Treat a false result as 'no highlights' and play on.","verify":"manual"},{"id":"highlights.frame-after-every-render","text":"Call Miniant.highlights.frame() after every render, in the same frame the canvas was drawn.","verify":"manual"},{"id":"highlights.mark-one-event-one-mark","text":"Call mark() once per event, classified by who it is about: by \"me\" or \"opponent\", never both for the same event, and never a second mark for the same moment.","verify":"manual"},{"id":"highlights.mark-by-for-good","text":"Pass `by` whenever you pass `good`; my keeper's save is { by: \"opponent\", good: false, label: \"SAVED!\" }.","verify":"manual"},{"id":"highlights.set-score-from-dom-hud","text":"Call setScore(text) whenever the score changes if your HUD is DOM; capture only sees the canvas.","verify":"manual"},{"id":"highlights.report-result-win","text":"Finish with reportResult({ outcome: \"win\" }) to post the clip; in online modes a \"loss\" or \"draw\" posts it too. Any other outcome discards it. Do not call any separate upload method.","verify":"manual"}],"body":"# Highlights\n\n`Miniant.highlights` turns a match your player wins, or an online match they lose or draw, into a\nshareable highlight clip. It is\n**optional**: a game that never calls it behaves exactly as before, and the recorder code is a\nseparate chunk (`v1-highlights.js`, loaded from the same place as `v1.js`) that is only downloaded\nwhen you call `start()`.\n\n**What the clip is.** A vertical (720×1280) video of about 16.5 seconds: the Miniant start\nanimation, then *all* of the winner's good moments plus up to two of the opponent's mistakes\n(always fewer than the winner's good moments), in match order, then a final-score card built from\nyour last `setScore()` text, then the end animation. After a win in a game that records, the\npost-match bar offers only **Share highlights** (it reads \"Preparing highlights…\" until the clip is\nready) and copies a ready caption, with the player's invite link, for their post; Share and\nChallenge come back only if the clip cannot be made. After an online loss or draw it offers the\nclip alone, and nothing if the clip cannot be made. You never see or handle the video. The clip has a soundtrack: Miniant's start sound and a music track that fades\ninto the end sound as the end animation plays. Your game's own audio is not recorded.\n\nEach moment is cut with a lead-in before it and 0.8 s held after it, so its caption can be read.\nThat means a cut shows a few seconds of play around each moment, and an event next to a chosen\nmoment (say the opponent's goal just before your save) can appear inside the cut even though it was\nnever selected.\n\n**A DOM-built game draws a recording canvas.** The recorder films a canvas, and a board made of\npage elements has none. Keep an offscreen canvas (4:5 suits a board), redraw the board onto it every\nanimation frame from what the page shows, and pass that canvas to `start()`. It is never added to\nthe page; the player sees nothing different.\n\n## `Miniant.highlights.start({ canvas, music?, framing?, cut?, playersOnScreen?, speed?, team?, partner? })`\n\n```ts\nconst on = await Miniant.highlights.start({ canvas });\nconst onPinned = await Miniant.highlights.start({ canvas, music: \"high-energy\" });\nconst onWhole = await Miniant.highlights.start({ canvas, framing: \"fit\" });\nconst onBoard = await Miniant.highlights.start({ canvas, cut: \"recent\" });\n```\n\n`music` picks the soundtrack's mood by id. Only `\"high-energy\"` exists today, and it is what you\nget when you omit `music`, so leave it out unless you want to pin a mood as more are added. An id\nthe portal does not know is not swapped for another: `start()` resolves `false` and the portal logs\na console warning.\n\n`framing` decides how your picture goes into the clip, which is always vertical (9:16):\n\n- `\"crop\"` (the default) fills the frame and trims the sides of a wide game. Use it when the action\n is in the middle of the screen: a bowling lane, a penalty kick.\n- `\"fit\"` keeps your whole picture: a 16:9 game shows at full width, and the space above and below\n is filled with a blurred, darkened copy of the same frame. Use it when the whole width matters:\n a one-screen room where the player and the goal sit at opposite ends.\n\nAny other value makes `start()` resolve `false` with a console warning.\n\n`speed` (1 to 2, default 1) plays the clip's game footage faster, so slow-moving play keeps up\nwith the soundtrack: `speed: 1.5` shows a 3 s roll in 2 s. The intro, the final card and the outro\nalways play at normal speed, and the footage is retimed, not re-encoded, so quality is unchanged.\nAt 1.5 the clip also covers 1.5 times as much play. Captions are part of the footage, so they show\nfor less time at a higher speed. Any other value makes `start()` resolve `false` with a warning.\n\n`playersOnScreen: true` says your canvas already shows every player's name and avatar (Ludo\ndraws them beside each corner). The strip across the top of the clip then leaves the names and\navatars out: it shows only the score cell if you call `setScore()`, and otherwise \"MATCH HIGHLIGHTS\" over your game's title. Leave it\nout if your canvas does not show the players.\n\n`cut` decides which footage the clip keeps:\n\n- `\"moments\"` (the default) keeps the player's good moments and up to two of the opponent's\n mistakes, as described under `mark()`.\n- `\"recent\"` suits short board games. Mark **every move**, either player's. The clip takes the\n latest moves counted back from the finish, each cut tight (0.3 s before the move lands, 0.9 s\n after, 1.6 s for the last), so the thinking time between moves drops out. If every move fits,\n the whole game is shown. `by` and `good` are not used for selection; `label` still captions the\n move.\n\nAny other value makes `start()` resolve `false` with a console warning.\n\nCall it **once per match**, after `Miniant.init()` and before `Miniant.ready()`. It can take a\ncouple of seconds (the brand pack, the recorder chunk and the brand pre-encode; longer on a slow phone\nthat is busy loading your game), and gives up with `false` once the pre-encode makes no progress for\n10 s or has run for 60 s, so call it during your own loading rather than at the first frame of play. It resolves `false`\nfor guests, spectators, portals too old to post a clip, and devices without WebCodecs H.264\nencoding. When it resolves `false`, every other `highlights` call is a silent no-op, so you never\nbranch on it. A new match needs a new `start()`; calling it again for a rematch starts a fresh\nrecording.\n\n## Team games: `start({ team: true })` and `setStars(n)`\n\nFor a co-op game (2 to 4 friends on one team, like a kitchen shift), start with `team: true`:\n\n```ts\nawait Miniant.highlights.start({ canvas, team: true });\nMiniant.highlights.mark({ by: \"me\", seat: 2, good: true, label: \"ORDER UP!\" }); // a teammate\nMiniant.highlights.setStars(2); // before reportResult\n```\n\n- **Teammates are our side.** A mark or `actor()` with `seat` is signed with that seat's photo and\n name, in the player's gold style (not an opponent's white). `by: \"me\"` without `seat` is the local\n player, as always.\n- **Only a win posts a clip.** A loss, draw or abandoned shift discards the recording, online or\n not (no clip for a failed shift); the portal then offers nothing for it, as for any lost match.\n- **The final card is the team card** instead of \"NAME WINS\": the game title, every team member's\n round photo with their name under it, three star icons (filled for the stars earned, outlined for\n the missed) and \"3 STARS!\", \"2 STARS!\" or \"1 STAR!\". Without `setStars` it reads \"TEAM WINS!\"\n with no star filled. If you call `setScore`, \"FINAL SCORE\" and the numeral stay above the team.\n- `setStars(n)` takes an integer 1 to 3 (clamped, rounded; a non-number is ignored). Call it any\n time before `reportResult()`. An older SDK has no `setStars`, and `team` is ignored there, so\n check `typeof Miniant.highlights.setStars === \"function\"` before calling it.\n\n## Partnership games: `start({ partner })`\n\nFor a game played in fixed partnerships against another side (a card game's two teams of two),\nname the local player's partner when you start:\n\n```ts\nawait Miniant.highlights.start({ canvas, partner: { name: \"Mitu\", seat: 2 } });\n```\n\n- **The final card names both, the local player first:** both round photos (the player's in front)\n and \"KAIS & MITU\", then \"WINS\" as usual, or the loss and draw lines online. Two long names go on\n two lines (\"KAISUDDIN AHMED &\" over the partner) rather than being cut.\n- `name` is the partner's name as your game shows it, so an AI partner in vs_ai can be named too.\n `seat` (optional, the partner's portal seat) supplies their photo when they are a participant;\n without one the card draws their initial.\n- A partner with an empty name or a non-integer `seat` makes `start()` resolve `false`, with a\n console warning. Nothing else changes: who posts a clip, the marks and the score work as in any\n versus game. This is not `team` (a co-op team with no rival, which discards loss clips).\n- An older SDK ignores `partner`, so passing it is always safe.\n\n## `frame()`\n\n```ts\nMiniant.highlights.frame();\n```\n\nCall it after **every** render, in the same frame the canvas was drawn. It reads the canvas as it\nis at that moment, so calling it from a timer or before drawing captures a stale or blank frame.\n\n## `mark({ by?, good?, label?, win?, leadMs?, minor?, seat?, beatMs?, montage?, loss?, agoMs? })`\n\n```ts\nMiniant.highlights.mark({ by: \"me\", good: true, label: \"GOAL!\" });\nMiniant.highlights.mark({ by: \"opponent\", good: false, label: \"SAVED!\" });\nMiniant.highlights.mark({ win: true });\n```\n\nTell it when something worth showing just happened. **One event is one mark**, classified by who\nthe event is *about*:\n\n- `by: \"me\"` or `by: \"opponent\"`: whose event this is.\n- `good`: whether it was good for that player. **`good` requires `by`.**\n- `label`: a short caption shown on the clip.\n- `win: true`: rides on the decisive moment of the match, or stands alone if there was no single\n moment.\n- `leadMs`: how long before this mark the move began, when a move animates\n for a while before it lands (a token hopping square by square). 0 to 10000; anything else\n ignores the mark with a console warning. The cut always starts at least this long before the mark.\n- `seat`: which seat the moment belongs to. Needed only in a game with more than one opponent\n (Ludo, a card table, a race): the clip signs each moment with its player's photo and name, and\n without a seat an \"opponent\" moment there is left unsigned rather than given the wrong name.\n- `minor: true`: a filler moment (a six in a dice game). It is cut only into play time your real\n moments leave unused, so a quiet match still fills the clip and a busy one never loses a real\n moment to it.\n- `beatMs` and `montage`: tell a win as a montage, see below.\n- `loss: true`: how the player lost (a fall, an elimination). Use it with `by: \"me\", good: false`;\n on any other mark it is ignored. See below.\n- `agoMs`: the moment happened this many ms ago (0 to 60000, clamped; a non-number is ignored). The\n mark is placed at that earlier time, and `leadMs` counts back from it. See below.\n\n**Back-dating a mark: `agoMs`.** When you only learn of a moment later (a shift is known won when\nit ends, but the win was the last serve a few seconds earlier), say how long ago it happened:\n`Miniant.highlights.mark({ win: true, agoMs: msSinceLastServe })`. The clip then ends right after\nthat serve instead of filming the quiet tail. A back-dated mark adds no caption, because the frames\non screen now are not its footage: give the original moment its caption when it happens. Footage is\nkept reliably for the last 10 s, or around any moment you marked; if a back-dated win's footage is\ngone, the win falls back to the time you called `mark`, so the clip still has a win.\n\n**How the player lost: `loss: true`.** Your own misses are never selected, so by default a loss clip\nshows the opponent's moments and the player's earlier good ones but never the fall itself. Mark that\none moment `{ by: \"me\", good: false, label: \"WRONG!\", loss: true }` and a loss or draw clip always\nincludes the latest such mark, in match order, cut like any moment (`leadMs` is honoured) and signed\nwith the player. It is dropped only after every other moment. A win clip never shows it.\n\nThe clip keeps the winner's good moments and up to two of the opponent's mistakes. Your own misses\nmay be marked but are never selected. The opponent's good moments (their goals) are never in a win\nclip; in a loss or draw clip the latest two are picked as real moments, even when the player's own\nmoments would fill the clip, and earlier ones fill spare time before plain fillers, so the clip\nshows what beat the player. Mark them `{ by: \"opponent\", good: true }`. So a save by the player's keeper is\nan event about the opponent's failed attempt: `{ by: \"opponent\", good: false, label: \"SAVED!\" }`,\nnot a good mark for the player.\n\n**Every moment is signed.** Under the picture the clip shows whose moment it is: the player's\nphoto and name over a gold caption, or the opponent's over a white one, and a filler without a\ncaption shows the photo and name alone. So pass `by` on every mark you can, fillers included; a\nmark without `by` (or `seat`) is shown unsigned. The white flash and the punch-in stay on the\nplayer's own moments.\n\n**A win as a montage: `beatMs` and `montage`.** For a win that is a climb rather than one moment (a\nquiz ladder, a combo chain), mark every step with `beatMs` (300 to 1200, ms: how long the step reads\nat a glance) and put `montage: N` (2 to 12) on the `win: true` mark. The clip then tells the win as\nquick cuts of your latest N beat marks, then your own marks after the last beat (the question\nopening, with its own `leadMs`), then the win. Nothing else is in it: no opponent moments, no other\ngood moments, no fillers. A beat is cut to its own length with no minimum, shows no flash, punch-in\nor caption slam, and keeps the player's name tag. If the beats do not fit the clip, the earliest go\nfirst; with fewer than two beats the clip is cut as usual. Both fields are clamped to their range,\n`beatMs` counts only on the player's own good or filler marks, `montage` only on `win: true`, and a\nvalue that is not a number is ignored. `beatMs` alone changes nothing about which moments are\nchosen, but a captioned beat-hinted mark is always drawn without the flash, punch-in and slam (its\ncaption still holds as usual), because the clip is filmed while the match runs.\n\n```ts\n// Quiz Ladder: every rung, then the million.\nMiniant.highlights.mark({ by: \"me\", good: true, label: \"SAFE!\", beatMs: 500 });\nMiniant.highlights.mark({ by: \"me\", good: true, label: \"FOR 1,000,000\", leadMs: 1500 }); // not a beat\nMiniant.highlights.mark({ win: true, montage: 8 });\n```\n\n## `actor({ by?, seat? })`\n\n```ts\nMiniant.highlights.actor({ by: \"me\" }); // the player strikes the cue ball\nMiniant.highlights.actor({ by: \"opponent\" }); // the opponent's dart leaves their hand\nMiniant.highlights.actor({ by: \"opponent\", seat: 2 }); // seat 2 picks up a token\n```\n\nCall it when an action **starts** — a shot struck, a dart thrown, a token picked up — so the clip\nsigns the action while it plays, not only from its landing (a mark only arrives when the action\nlands). The tag stays until the next `actor()` call; a mark's caption or filler shows its own player\nover it while it is up. Pass `seat` in a game with more than one opponent, as for `mark()`.\n`actor({})` clears the tag. Portals older than 2026-10-01 do not have it, so check\n`typeof Miniant.highlights.actor === \"function\"` before calling.\n\n## `setScore(text)`\n\n```ts\nMiniant.highlights.setScore(\"3 - 1\");\n```\n\nYour HUD is DOM, and DOM is invisible to canvas capture. Call `setScore` whenever the score\nchanges and the clip's score overlay follows it. If your score is not drawn on the canvas and you\nnever call `setScore()`, the clip's score plate shows the two names only, with no score.\n\n## `stop()`\n\nStops recording. Use it when the match ends without a `reportResult` that posts a clip.\n\n## Finishing: `reportResult`\n\n```ts\nawait Miniant.reportResult({ outcome: \"win\" });\n```\n\nThe clip is always exactly 16.5 seconds: a match too short or too sparsely recorded to fill it\nrepeats its best shots, the winning moment first, rather than ending sooner.\n\n`reportResult` is what finishes the clip. `outcome: \"win\"` finishes and posts it, ending on a\n\"WINS\" card. In an online mode (`online_friends`, `online_random`), `\"loss\"` and `\"draw\"` post it\ntoo, ending on a \"GOOD GAME\" card under a small \"LOST, BUT\" or \"DRAW,\"; the player's good moments are what it shows. Any other outcome,\nor a loss in any other mode, discards it. There is no separate upload call. `reportResult` remains mandatory for every game (see\n[Scores](/guide/sdk/scores#reporting-results-is-mandatory)).\n\nA game with more than two finishing places adds the player's place to the same call:\n\n```ts\nawait Miniant.reportResult({ outcome: \"loss\", placement: 2, durationMs });\n```\n\nIn an online mode, `placement: 2` or `3` ends the clip on a \"2ND PLACE\" or \"3RD PLACE\" card\ninstead of \"GOOD GAME\"; 4th and below stays \"GOOD GAME\". Against the AI a place makes no clip,\nthe same as a loss.\n\n## What the validator checks\n\n`miniant-validate` and the publishing check read your built bundle for seven highlight rules. None\nof them blocks a publish; a flagged rule is a warning and nothing more.\n\n- `highlights.missing`: a game with a `vs_ai` or online mode (and not a levels game) never calls `highlights.start`.\n- `highlights.not-for-solo`: a solo-only or levels game calls `highlights.start`.\n- `highlights.no-frame`: `highlights.start` is called but `frame()` never is, so the clip is empty.\n- `highlights.no-win-mark`: no `mark()` has `win: true`.\n- `highlights.no-opponent-marks`: a game with an online mode never marks `{ by: \"opponent\", good: true }`.\n- `highlights.no-placement`: a game that seats 3 or more players in a recording mode never passes `placement` to `reportResult`.\n- `highlights.speed-range`: a numeric `speed` passed to `start()` is outside 1 to 2.\n\nThe scan is a text search of the minified bundle, so it can miss a call hidden behind a renamed\nvariable. When the bundle calls `highlights.start`, the report also lists \"Highlights: check by\nwatching a clip\", a short list of things only a person watching a clip can confirm.\n\n## Spectators\n\n`start()` resolves `false` for a spectator visit, so none of this runs there. See\n[Spectating](/guide/sdk/spectating).\n"},{"slug":"sdk/video-ads","title":"Video ads","section":"SDK","agentSummary":"Miniant.ads requests interstitial or rewarded ads; the portal decides everything and runs its own placements (rematch, XP doubler, more) that bill to the game with no game code. Both calls optional.","checklist":[{"id":"ads.no-third-party-sdk","text":"Never embed a third-party ad SDK, network, or ad script; Miniant.ads is the only sanctioned way to show a video ad.","verify":"validator:ads.no-third-party-sdk"},{"id":"ads.no-iframe-embeds","text":"Never embed an ad iframe in your game; there is no method that puts a video on screen directly.","verify":"validator:html.no-iframe"},{"id":"ads.request-after-ready","text":"Call requestInterstitial or requestRewarded only once the game is running, never as part of load-time initialization.","verify":"manual"},{"id":"ads.request-at-natural-break","text":"Call requestInterstitial only at a natural break in play, never mid-gameplay or on a timer your own code drives.","verify":"manual"},{"id":"ads.no-interstitial-before-rematch","text":"In a one-round session that ends in a Rematch CTA, don't call requestInterstitial at round end; the portal's rematch placement shows that ad. Use it only at level ends inside a session.","verify":"manual"},{"id":"ads.rewarded-results-screen-only","text":"requestRewarded is optional (the portal offers the XP doubler itself); if you call it, do so only from the results screen after computing the session's normal reward, never mid-gameplay.","verify":"manual"},{"id":"ads.no-tickets-from-rewarded","text":"Never grant Tickets for a rewarded ad; only double the game's own already-earned XP or quest progress.","verify":"manual"},{"id":"ads.dont-double-pause","text":"Never add a second ad-specific pause path; rely on the existing host:pause/host:resume handlers.","verify":"manual"}],"body":"# Video ads\n\n`Miniant.ads` is how your game asks for a video ad. You **request**; the portal **decides and\nshows**. There is no method that puts a video on screen directly, and no way to control which\nnetwork serves it, how often it appears, or what a player sees while it's playing — that's all\nportal policy, the same way `wallet.purchase()` never lets your game render its own confirm\ndialog (see [In-game purchases](/guide/sdk/purchases)).\n\nTwo calls, two different jobs. Don't mix them up:\n\n- `requestInterstitial(reason?)` — a full-screen ad at a natural break *inside* a session, such\n as a level end.\n- `requestRewarded()` — optional: a video the player opts into from your results screen, for a\n doubled reward. The portal already offers this after every result on its own (see\n \"Portal-owned placements\" below), so most games never need to call it.\n\n**Most games need neither call to earn from ads.** The portal runs its own placements around\nevery game — the rematch interstitial, the results-screen XP doubler, the matchmaking wait\nand the spend waiver — and each of those bills to your game. Your own calls add\ninventory only where the portal cannot see a break: a level end inside a longer session.\n\n## `Miniant.ads.requestInterstitial(reason?)`\n\n```ts\nconst outcome = await Miniant.ads.requestInterstitial(\"level_end\");\n// \"shown\" | \"skipped\" | \"unavailable\"\n```\n\nCall this at a natural break — a level end, returning to a menu — never mid-gameplay, never on a\ntimer your own code drives. `reason` is an optional, short (≤40 characters) hint for *why* you're\nasking (e.g. `\"level_end\"`); it's informational only and doesn't influence the portal's decision.\n\n**Don't call it at the end of a one-round session.** If your session is a single round that ends\non a results screen with a Rematch CTA (a Ludo match, a round of Tic Tac Toe), leave the break to\nthe portal: its own rematch placement shows the interstitial only when the player has chosen to\nplay again, whereas a call at round end fires even for a player who is about to leave. Calling\nboth is harmless — the shared 3-minute gap means the portal's ad is skipped — but it wastes the\nbetter moment. Reserve `requestInterstitial` for level ends inside a session the portal cannot see.\n\n**The portal enforces spacing you don't see:**\n- At least 3 minutes between interstitials, portal-wide, regardless of how often your game asks.\n- None at all during a player's first session.\n\nSo a `\"skipped\"` reply is the normal, expected result of most calls — not an error, and not\nsomething to retry immediately. Never stall gameplay waiting for `\"shown\"`; the call resolves\nquickly either way and your game continues the moment it does.\n\n`\"unavailable\"` covers everything else that isn't a normal skip — no inventory to fill the slot,\nthe portal shell isn't wired for ads yet, or the request simply didn't get a reply in time. Treat\nit exactly like `\"skipped\"`.\n\n**The shell pauses and resumes your game around the ad — don't also pause yourself.** When the\nportal actually shows an interstitial, it sends your game the same `host:pause` /\n`Miniant.on(\"pause\", cb)` push it uses for any other overlay (see [Game](/guide/sdk/game)), and\n`host:resume` once the ad closes. If your game already listens for `\"pause\"`/`\"resume\"` to freeze\nits own simulation (it should), this \"just works\" — don't add a second, ad-specific pause path.\n\n## `Miniant.ads.requestRewarded()`\n\n```ts\nconst outcome = await Miniant.ads.requestRewarded();\n// \"rewarded\" | \"dismissed\" | \"unavailable\"\n```\n\n**Optional, and results-screen doubling only.** The portal offers \"double this round's XP\" as a\nchip over your results screen after every eligible result, with no code from you, and the doubled\nXP is applied server-side either way. Call this only if you want the offer to live inside your own\nresults UI instead — the portal's chip then stays silent for that session. If you do call it, do\nso from your game-over/results screen, after you've already computed the session's normal XP or\nquest progress — never mid-gameplay, never to gate progress a player would otherwise get for free. On `\"rewarded\"` (the player watched to\ncompletion), **double** the XP or quest progress your game already awarded for that session.\nThat's the entire contract: there is no separate \"reward payload\" from the portal telling you what\nto grant — your own game decides what \"doubled\" means for its own scoring.\n\n**A rewarded ad never grants Tickets.** Tickets come only from the portal's own watch-to-play\nflow, never from `Miniant.ads`. Don't build a \"watch an ad, get tickets\" flow — it isn't wired to\nanything, and won't be.\n\n`\"dismissed\"` means the player backed out before the video finished — treat it exactly like\n`\"unavailable\"`: no reward, and don't stall or retry. Same pause/resume behavior as\n`requestInterstitial` above applies here too, for the duration the video is on screen.\n\n## Not a pricing mechanism\n\nDon't use a rewarded ad to unlock an undo, an extra life, a hint, or anything else you'd otherwise\nsell. Price that with `wallet.requestSpend()` (or a registered SKU via `wallet.purchase()`) — see\n[In-game purchases](/guide/sdk/purchases). The one exception is the portal's own spend waiver\n(see \"Portal-owned placements\" below): when a player cannot afford a spend, the portal may offer\nan ad and waive the player's next tap of that control. Your game sees an ordinary `{ ok: true }`\nand needs no code for it. From a game's point of view, `Miniant.ads` and `Miniant.wallet` stay two\nseparate systems: your own rewarded request only ever doubles an already-earned reward on your\nresults screen, it never buys anything.\n\n## Not implemented via any other route\n\nNo third-party ad SDK, ad iframe, or ad script — embedding one yourself is still not permitted (see\n[Advertisement](/guide/requirements/advertisement)) and will be rejected in review. `Miniant.ads`\nis the only sanctioned way to show a video ad in your game.\n\n## Portal-owned placements\n\nNot every ad a player sees comes from your `requestInterstitial`/`requestRewarded` calls. The\nportal also runs five ad placements entirely of its own, at moments your game code never sees:\n\n| Placement | Format | Where | Gate |\n|---|---|---|---|\n| Rematch | interstitial | \"Play again\" from the results screen | 3-minute gap, never a player's first session, never for a Plus subscriber |\n| XP doubler offer | rewarded | A chip on the results screen after your game reports its result | signed in, session XP-eligible, once per session |\n| Streak restore | rewarded | The daily-claim UI on the portal home | exactly one missed day, previous streak ≥ 3, once per 30 days |\n| Spend waiver | rewarded | The portal's \"Not enough tickets\" notice after a `wallet.requestSpend()` the player could not afford; the player watches, then taps the same control again and the portal waives that one spend | once per session, signed in, only when the player cannot pay |\n| Finding-players wait | interstitial | The online-random matchmaking card while it's searching | 3-minute gap, never a player's first session, never for a Plus subscriber |\n\nNone of this needs any code from you, and four of the five **bill to your game** exactly like your\nown `Miniant.ads` calls: the rematch interstitial, the XP doubler, the spend waiver and the\nfinding-players wait are all recorded against the game they happened around, so they\ncount toward your 70/30 statement. Only the streak restore, which happens on the portal home with no\ngame involved, is recorded without a game slug. Two things are worth knowing because they interact\nwith your own `Miniant.ads` calls:\n\n- **The rematch and finding-players interstitials share your game's own 3-minute gap.** If your\n game calls `requestInterstitial(\"level_end\")` and the player then clicks \"Play again,\" the\n rematch interstitial is gated by the exact same portal-wide ad history your call just advanced —\n a game that shows its own interstitial and also triggers a rematch will never see two ads back\n to back.\n- **The XP-doubler chip stays silent once your game has shown its own rewarded ad this session.**\n If your results screen already called `requestRewarded()`, the portal's own \"double this round's\n XP\" offer never appears alongside it — a player sees at most one rewarded-ad prompt per results\n screen.\n\n## Attribution and revenue share\n\nThere's nothing for you to implement here — this section just explains what happens after your\ngame calls `requestInterstitial`/`requestRewarded`, since it feeds your earnings statements (see\n[Monetization](/guide/publishing/monetization) and [Earnings](/guide/publishing#earnings)).\n\nEvery `requestInterstitial` or `requestRewarded` call the portal actually serves is recorded per\ngame. Not every recorded impression is billable: only `\"shown\"` (interstitial) or `\"rewarded\"`\n(rewarded) outcomes served by a **real ad network** count — an outcome served by the portal's own\nhouse cross-promo inventory (no real advertiser paid for it) is recorded the same way but never\nbillable, and neither is `\"skipped\"`/`\"dismissed\"`/`\"unavailable\"`.\n\nBillable impressions are billed to your game at a **70/30 split in your favour** — you keep 70% of\nthe revenue the ad actually generated. Statements are drafted **monthly** on the 1st. Once the\nportal team has imported the ad network's actual reported revenue for that month, the draft is\nre-priced pro-rata against that real figure before it is finalized, so your finalized share tracks\nwhat the network really paid rather than the provisional estimate a draft starts with.\n"},{"slug":"sdk/banners","title":"Banners","section":"SDK","agentSummary":"There is no banner API or ad system on the platform at all; monetization is the manifest-declared SKU purchase system, and third-party ad networks are rejected in review.","checklist":[{"id":"banners.no-third-party-ads","text":"Never embed a third-party banner ad, ad iframe, or ad script; the platform has no banner API and runs no banners.","verify":"validator:ads.no-third-party-sdk"}],"body":"# Banners\n\nThere is no banner API in the SDK, and no ad system on the platform at all. No banner\nplacements, no banner sizes or slots to request, no ad revenue share. If you're looking for a\nmethod to call to request a banner, there isn't one — that surface doesn't exist here.\n\nThe portal runs no banners inside games, full stop — this is unrelated to\n[`Miniant.ads`](/guide/sdk/video-ads)'s interstitial/rewarded requests, which are the only ad\nsurface that exists.\n\n## Third-party ad networks are not permitted\n\nDon't embed a third-party banner ad, ad iframe, or ad script in a game you submit. A build\nthat does this will be rejected in review.\n\n## Monetization is the in-game purchase system\n\nThe way to make money on this platform today is the built-in purchase system: you declare\nSKUs in your manifest and players buy them through the portal's own wallet. See\n[`/guide/sdk/purchases`](/guide/sdk/purchases) for the purchase contract.\n\nFor the platform's policy statement on advertising, see\n[`/guide/requirements/advertisement`](/guide/requirements/advertisement).\n\n## If that changes\n\nIf the platform ever adds a banner-ad system, this page is where the API will be documented.\n"},{"slug":"sdk/leaderboards","title":"Leaderboards","section":"SDK","agentSummary":"Clarifies that the portal has player-facing leaderboards but the SDK has no leaderboard API today; games only ever report via reportResult() and reportProgress().","checklist":[],"body":"# Leaderboards\n\nThis one is mixed, so read carefully — it isn't a flat \"doesn't exist.\"\n\n## The portal has leaderboards\n\nThe platform itself ranks games and players from real play data — that surface exists and is\nlive for players browsing the portal.\n\n## The SDK has no leaderboard API\n\nA game cannot create or post to a named leaderboard, cannot read standings back, and cannot\nrender portal standings inside itself. There is no method for any of that in the SDK today.\n\n## What a game can do today\n\nReport an outcome and score at the end of a session with `Miniant.reportResult(...)`, and send\nperiodic progress with `Miniant.reportProgress(...)` during the session. The portal derives its\nown rankings from those reports — you don't build or maintain a leaderboard yourself, you just\nreport honestly. See [`/guide/sdk/scores`](/guide/sdk/scores) for the full contract on both\ncalls.\n\n## This is a known gap, not a design stance\n\nAn SDK-facing leaderboard API — submit to a named board, read standings back for display\ninside your own game — is planned work. It does not exist yet. This page will document it,\nwith real method names and payload shapes, once it ships. There's no method name, shape, or\ndate to share ahead of that.\n"},{"slug":"sdk/engines","title":"Engines","section":"SDK","agentSummary":"Per-engine build pipeline support: Phaser/plain JS, Godot 4.2.2, and the prebuilt path for Construct, GameMaker, Cocos, and Unity, plus the SDK script tag each one loads.","checklist":[{"id":"engines.sdk-url-www-host","text":"Load the SDK script from the www host (https://www.miniant.games/sdk/v1.js), never the apex domain.","verify":"validator:html.sdk-url-www"},{"id":"engines.commit-lockfile","text":"Commit your package manager lockfile so builds are reproducible.","verify":"manual"},{"id":"engines.godot-web-preset-name","text":"Name your Godot HTML5 export preset exactly Web and commit export_presets.cfg to the repo root.","verify":"manual"},{"id":"engines.unity-bridge-gameobject-name","text":"Create a GameObject named exactly MiniAntBridge and attach the MiniAntBridge component to it.","verify":"manual"},{"id":"engines.prebuilt-commit-export","text":"For a prebuilt or Unity engine, export the static web build yourself and commit a non-empty output directory at your target commit.","verify":"manual"}],"body":"# Engines\n\nWhat the build pipeline actually supports, engine by engine.\n\n## Phaser / plain JavaScript\n\nAny build that outputs static files into a directory you point the portal at (`./dist` by\ndefault, or a custom output directory you set when you connect your repo — see\n[`/guide/publishing`](/guide/publishing)). npm, pnpm, and yarn are all auto-detected from your\nlockfile, and you should commit that lockfile so builds are reproducible.\n\nLoad the SDK with a plain script tag:\n\n```html\n<script src=\"https://www.miniant.games/sdk/v1.js\"></script>\n```\n\nThis sets a global `Miniant` object your game calls into (`Miniant.init()`,\n`Miniant.ready()`, and so on — see [`/guide/sdk`](/guide/sdk) for the full contract).\n\n**Load it from `www`, not the apex domain.** `https://miniant.games/sdk/...` redirects in a way\nthat breaks under the play frame's isolation, so the SDK silently never loads. The validator\nenforces the `www` host and fails your build if it's missing. See\n[`/guide/requirements/technical`](/guide/requirements/technical) for the full validation\nchecklist.\n\n## Godot 4.2.2\n\nSupported via an HTML5 export. Two things are required:\n\n- An HTML5 export preset named exactly **`Web`** (Project → Export in the Godot editor).\n- `export_presets.cfg` committed to your repo root. If it's missing, or has no preset named\n exactly `Web`, the build fails before it ever runs.\n\nThe same SDK script tag applies to the Godot HTML5 export — load\n`https://www.miniant.games/sdk/v1.js` from your export's HTML shell the same way a JavaScript\ngame would, and the same `www`-not-apex rule applies.\n\nBuilds are validated against Godot `4.2.2` specifically.\n\n## Prebuilt export (any engine)\n\nFor engines the pipeline can't compile from source — Construct, GameMaker, Cocos, and similar\ntools with an \"export to HTML5\" step — pick **\"Prebuilt web export (any engine)\"** when you\nconnect your repo. Nothing gets built: you export the static web bundle yourself, commit the\nexported directory to your repo, and the pipeline verifies what's already there instead of\nrunning a build.\n\n- Export your project to a static web build (HTML5/JS/CSS/assets) from your engine's own export\n feature, and commit the output directory to your repo.\n- Point the **build output directory** at that committed folder when you connect your repo\n (defaults to `./dist`, which is unlikely to match a Construct/GameMaker/Cocos export path —\n set this explicitly). If your project lives inside a monorepo, set the **project\n subdirectory** too; the output directory is still resolved relative to that.\n- The portal hard-fails preflight if the resolved output directory doesn't exist at your\n target commit, or exists but is empty — commit the real exported files, not an empty\n placeholder folder.\n- SDK integration is still plain JavaScript: load the same script tag from your exported HTML\n shell and call the same `window.Miniant` global a Phaser or plain-JS game would.\n\n```html\n<script src=\"https://www.miniant.games/sdk/v1.js\"></script>\n```\n\nThe same `www`-not-apex rule applies. The pipeline itself doesn't compile anything for this\npath — it confirms the output directory is non-empty, ensures `miniant.json` is present, runs a\nblocking malware scan, runs a non-blocking headless smoke test, then zips and uploads the\nbuild.\n\n## Unity (WebGL export)\n\nUnity uses the same prebuilt path described above: there's no Unity toolchain in this pipeline\n(no Unity Editor, license, or C# compiler in CI), so a WebGL build isn't something the platform\ncan produce for you. You export it yourself, in your own copy of the Unity Editor, and commit\nthe export.\n\n1. Download the Unity wrapper from\n [`/tools/miniant-unity-sdk.zip`](/tools/miniant-unity-sdk.zip) — same distribution pattern as\n the [validator script](/tools/miniant-validate.mjs). It contains `Plugins/MiniAnt.jslib`,\n `Runtime/MiniAntBridge.cs`, `Runtime/MiniAnt.cs`, `Runtime/MiniAntTypes.cs`, a\n `WebGLTemplates/TEMPLATE.md`, and its own `README.md`.\n2. Copy `Plugins/` and `Runtime/` into your project's `Assets/` folder.\n3. Create a GameObject named **exactly** `MiniAntBridge` and attach the `MiniAntBridge`\n component to it — the JS bridge delivers results back into Unity by GameObject name, and a\n mismatch fails silently.\n4. Patch your WebGL template's `index.html`: add the SDK script tag\n (`<script src=\"https://www.miniant.games/sdk/v1.js\"></script>` — the `www` host is\n mandatory, same as every other engine) and expose `window.unityInstance` from your\n `createUnityInstance(...)` callback. Both edits are required.\n5. Build your WebGL export, commit the export folder to your repo, then connect the repo here\n with engine **\"Unity (WebGL export)\"** and the build output directory pointing at that\n folder.\n\nThe full walkthrough — including the C# call patterns and what is and isn't bridged (notably\n`wallet.openStore()`, which is a portal-side stub that always throws today, and\n`wallet.on(\"balanceChanged\")`, which isn't wired at all) — lives in the wrapper's own\n`packages/game-sdk/unity/README.md`.\n\n**This wrapper is new and has not been compiled inside Unity by us** — there's no Unity\ntoolchain in this repo's CI to catch a Unity-specific compile error or runtime bug before you\nhit it. Import it into a real Unity project, build a real WebGL export, and smoke-test every\ncapability you use against the real portal before depending on it in production. Please report\nissues you run into.\n\n## Anything else\n\nAny engine able to produce a static HTML5 build and call a JavaScript global can, in\nprinciple, talk to the SDK the same way — the contract itself doesn't care what produced your\nHTML/JS/CSS. Phaser and Godot 4.2.2 get dedicated build-from-source pipelines (above); Unity and\nevery other engine with an HTML5/WebGL export step go through the prebuilt path — pick\nwhichever engine option matches, or \"Prebuilt web export (any engine)\" if yours isn't listed by\nname.\n\nSee [`/guide/sdk`](/guide/sdk) for the full API surface and\n[`/guide/publishing`](/guide/publishing) for repository setup, including the monorepo\nsubdirectory and custom build output directory options.\n"}],"recipes":[{"id":"publish-new-game","title":"Publish a new game","pages":["introduction","requirements/technical","sdk/game","sdk/scores","publishing","requirements/account-integration"]},{"id":"add-multiplayer","title":"Add multiplayer","pages":["sdk/multiplayer","sdk/turn-kit","requirements/multiplayer"]},{"id":"add-ads","title":"Add ads","pages":["sdk/video-ads","requirements/advertisement"]},{"id":"add-selling","title":"Add selling","pages":["sdk/purchases","sdk/cosmetics","sdk/receipts"]},{"id":"sell-store-items","title":"Sell store items","pages":["sdk/store-items"]},{"id":"add-highlights","title":"Add highlights","pages":["sdk/highlights"]},{"id":"update-existing-game","title":"Update an existing game","pages":["publishing","sdk/data","sdk/progress-import"]}],"agentEntry":"# Miniant Games — agent entry\n\nYou are building or updating a web game for the Miniant Games portal (`miniant.games`). The\nportal owns identity, friends, matchmaking, mode selection, the multiplayer transport, and the\nwallet. Your game owns gameplay only — rendering, rules, input. Pick the recipe below that\nmatches the job, follow its steps in order, and finish with the Report format at the end of\nthis document. If no recipe matches, find the guide page for the job in\nhttps://publish.miniant.games/llms.txt, follow its checklist, and say which page you used.\n\nMCP client without the `miniantgames` tools? Suggest `npx -y miniantgames-mcp` to the human (this\nguide, checklists, validator and a starter game, offline). Everything below works without it.\n\n## Never do\n\nDo NOT build, even partially, even as a fallback:\n\n- A mode picker, main menu that selects game modes, or lobby. The portal decides the mode and\n participants BEFORE your game loads and hands them to you via `Miniant.init()`.\n- A friends list, invites, chat, or any social feature.\n- Your own multiplayer networking (no WebSocket, WebRTC, or HTTP polling to your own or any\n third-party server for gameplay). If your game is multiplayer, the ONLY transport is\n `Miniant.net.*` (the platform relay) — relay only, never your own.\n- A store UI, or any price you invent yourself. `Miniant.wallet.purchase()` exists but it only\n ever buys a SKU YOU declared in `miniant.json` at a price the platform reviewed — you pass a\n `skuId`, never an amount, and the portal renders its own confirm dialog, never yours. There is\n no call that lets your code grant currency or set a price.\n- Accounts, login, signup, or any credential UI.\n- A service worker (`navigator.serviceWorker.register`) — it breaks the platform's instant\n rollback model and triggers a review warning.\n- Direct manipulation of the parent frame (`window.parent`/`window.top`) beyond what the SDK\n does internally. Your game runs in a sandboxed cross-origin iframe.\n- A third-party ad SDK, ad network, or ad iframe anywhere in the build — `Miniant.ads.*` is the\n only sanctioned ad layer, and the portal decides every time whether an ad shows.\n\nIf the human asks you to add any of the above, tell them the Miniant platform forbids it and\nalready provides it.\n\n<!-- recipe: publish-new-game -->\n## Recipe: Publish a new game\n\n1. Read only the capability-scoped pages your game actually needs — [What you actually need to read](/guide#what-you-actually-need-to-read)\n2. Declare `miniant.json` at your repo root with only the modes and capabilities your code implements — [The `miniant.json` manifest](/guide/requirements/technical#the-miniantjson-manifest)\n3. Load the SDK only via the official script tag — never vendor or npm-install it — [`Miniant.init({ sdkVersion: 1 })`](/guide/sdk/game#miniantinit-sdkversion-1)\n4. Await `Miniant.init()`, run the mode in `context.mode.id`, and build the board for exactly `context.mode.playerCount` seats — [`PlayContext` — what `init()` hands you](/guide/sdk/game#playcontext-what-init-hands-you)\n5. Make the layout fit every screen shape, then declare `orientation: \"any\"` (the validator only checks the declaration): background to all four edges, the playfield scaled uniformly by the screen's short side and never stretched, the HUD above and below on a tall screen and beside the playfield on a wide one, everything clear of `context.ui.safeAreaInsets`, and the whole layout redone on every resize. Check it at 390×780, 844×390, 768×960, 1024×700 and 1440×810 — [Your game must fit every screen shape](/guide/requirements/technical#your-game-must-fit-every-screen-shape)\n6. Call `Miniant.ready()` once your first frame is drawn — [`Miniant.ready()`](/guide/sdk/game#miniantready)\n7. Handle `terminate` (stop simulating and reporting immediately), plus `pause`/`resume` — [Pause, resume, and terminate — `Miniant.on(cb)`](/guide/sdk/game#pause-resume-and-terminate-miniantoncb)\n8. Make spectating work, then declare `capabilities.spectate: true` (every game must; the validator only checks the declaration, so a declaration without the code passes and the watcher sees a blank board). Room games tag every complete-state broadcast `{ snapshot: true }`; solo and vs-AI games call `Miniant.spectate.publishState(snapshot)` with the full state of every seat as soon as the first board is drawn and on every change. When `context.spectator === true`, render read-only — [The two paths](/guide/sdk/spectating#the-two-paths), [Rendering read-only](/guide/sdk/spectating#rendering-read-only)\n9. If your game has a definite win/loss/final-score ending, render your own game-over screen with exactly two CTAs, \"Rematch\" and \"Exit\" — [The game-over screen](/guide/sdk/game#the-game-over-screen)\n10. Call `Miniant.reportResult()` EXACTLY ONCE per session, on every end path — win, loss, draw, and abandon/quit — [`Miniant.reportResult(...)`](/guide/sdk/scores#miniantreportresult-outcome-score-durationms-perparticipant-detail)\n11. Send `Miniant.reportProgress()` heartbeats if a session can exceed ~60s — [`Miniant.reportProgress(...)`](/guide/sdk/scores#miniantreportprogress-checkpoint-score-tick)\n12. If you declare `capabilities.saveState`, call `state.load()` on every session start and snapshot progress, score and position at level START (only against-the-player values such as lives lost may be saved live) — [Call `state.load()` on every session start](/guide/sdk/data#call-stateload-on-every-session-start)\n13. Build the project and run the local validator until it prints PASS — [Enforced by the validator](/guide/requirements/technical#enforced-by-the-validator)\n14. Complete the anti-cheat questionnaire honestly, connect your repo, and request a publish — [Connect your repo and request a publish](/guide/publishing#connect-your-repo-and-request-a-publish)\n\n<!-- recipe: add-multiplayer -->\n## Recipe: Add multiplayer\n\n1. Pick a sync tier before writing any netcode — `parallel` (zero netcode), `turn_based` (use `createTurnKit`), or `realtime` (hand-rolled) — [Choose a sync mode for an online mode](/guide/requirements/multiplayer#choose-a-sync-mode-for-an-online-mode)\n2. Declare `capabilities.relay: true` and a `network` block with an honest `expectedMessagesPerMinute`/`maxMessageBytes` — [`capabilities.relay` and the `network` block](/guide/requirements/multiplayer#capabilitiesrelay-and-the-network-block)\n3. Declare AI — `capabilities.ai: true`, or a `vs_ai` mode — if you declare `online_random` or an `unavailable` policy of `\"auto\"`; declaring the whole ladder (`online_friends`, `online_random`, `vs_ai`) is only suggested — [`online_random` and `auto` need your game to have AI](/guide/requirements/multiplayer#onlinerandom-and-auto-need-your-game-to-have-ai)\n4. For `turn_based`, build `seats`/`mySeat`/`teams` from `context.participants` and call `createTurnKit` — [Build `seats`/`mySeat`/`teams` from the play context](/guide/sdk/turn-kit#build-seatsmyseatteams-from-the-play-context)\n5. Drive every state change through `kit.endTurn(patch?)` — never call `Miniant.net.send()` for game state once you're using the kit — [Drive every state change through `endTurn`](/guide/sdk/turn-kit#drive-every-state-change-through-endturn)\n6. For a hand-rolled `realtime` game, use only `Miniant.net` (connect/send/sendTo/on/close) — [The `Miniant.net` surface](/guide/sdk/multiplayer#the-miniantnet-surface)\n7. Answer `net.on(\"syncRequest\")` by rebroadcasting a full state snapshot in a hand-rolled `realtime` game — [Answering `syncRequest`](/guide/sdk/multiplayer#answering-syncrequest)\n8. If you declare `modes[].unavailable`, implement the availability recipe (or pass `unavailable` into `createTurnKit`) and call `reportPeerAvailability` at every transition — [Player availability (`modes[].unavailable`)](/guide/requirements/technical#player-availability-modesunavailable)\n9. Wire the same policy for a hand-rolled game: grace, auto-takeover or forfeit, and reporting — [Player availability: `unavailable`, auto-takeover, and forfeit](/guide/sdk/turn-kit#player-availability-unavailable-auto-takeover-and-forfeit)\n10. Handle peers going quiet or leaving as normal, not an error state — [Absent players](/guide/sdk/multiplayer#absent-players)\n11. Report where each seat's avatar sits with `Miniant.ui.setAvatarAnchors()` (required; centre in percentages 0-100, not pixels), and again on every layout change, so emotes and messages float from the right player — [Tell the portal where your players are](/guide/sdk/game#tell-the-portal-where-your-players-are-miniantuisetavataranchors)\n\n<!-- recipe: add-ads -->\n## Recipe: Add ads\n\n1. Confirm no third-party ad SDK, network, or iframe is being added — the portal's own ad layer is the only one — [Third-party ad networks are not permitted](/guide/requirements/advertisement#third-party-ad-networks-are-not-permitted)\n2. Do not reserve or build banner space anywhere — there are no banners on this platform — [No banners, ever](/guide/requirements/advertisement#no-banners-ever)\n3. Call `Miniant.ads.requestInterstitial(reason?)` only at a level end inside a session and continue immediately regardless of the result; do NOT call it at the end of a one-round session that ends in a Rematch CTA — the portal's own rematch placement shows that ad — [`Miniant.ads.requestInterstitial(reason?)`](/guide/sdk/video-ads#miniantadsrequestinterstitialreason)\n4. `Miniant.ads.requestRewarded()` is optional — the portal offers the results-screen \"double your XP\" ad itself; call it only to move that offer into your own results UI, and then only double XP/quest progress your game itself awards — [`Miniant.ads.requestRewarded()`](/guide/sdk/video-ads#miniantadsrequestrewarded)\n5. Never let a rewarded ad grant Tickets or any currency — [What a rewarded ad can and cannot do](/guide/requirements/advertisement#what-a-rewarded-ad-can-and-cannot-do)\n6. Build the project and run the local validator, then fix every reported error — [Enforced by the validator](/guide/requirements/technical#enforced-by-the-validator)\n\n<!-- recipe: add-selling -->\n## Recipe: Add selling\n\n1. Confirm the human actually wants the game to sell something before building any of this. If what they want to sell is a look (a cue, a board theme, a jersey) bought in the portal store, follow \"Sell store items\" instead — this recipe is for purchases made inside the game\n2. Declare every sellable item in `miniant.json`'s `store.skus` block, with `capabilities.wallet: true` — [The `store.skus` manifest block](/guide/sdk/purchases#the-storeskus-manifest-block)\n3. Call `Miniant.wallet.purchase({ skuId, quantity? })` — never render your own price or confirm UI — [`Miniant.wallet.purchase({ skuId, quantity? })`](/guide/sdk/purchases#miniantwalletpurchase-skuid-quantity)\n4. Call `Miniant.wallet.getEntitlements()` on boot to know what this player already owns — [`Miniant.wallet.getEntitlements()`](/guide/sdk/purchases#miniantwalletgetentitlements)\n5. Burn consumable units with `Miniant.wallet.consume({ skuId, units, idempotencyKey })`, using a stable idempotency key per real-world event — [`Miniant.wallet.consume({ skuId, units, idempotencyKey })`](/guide/sdk/purchases#miniantwalletconsume-skuid-units-idempotencykey)\n6. If selling cosmetics, sell them as entitlement-kind SKUs and never confuse them with the portal's own cosmetic catalog — [Yours: entitlement SKUs](/guide/sdk/cosmetics#yours-entitlement-skus)\n7. Never persist an entitlement-derived fact in saved state — re-derive ownership from `getEntitlements()` on load\n8. If your own backend needs to trust a purchase independently, verify `receipt.signedReceipt` against the platform's published public key, then check `payload.gameSlug` — [`signedReceipt` — verify offline](/guide/sdk/receipts#signedreceipt-verify-offline)\n9. Build the project and run the local validator until it prints PASS — [Enforced by the validator](/guide/requirements/technical#enforced-by-the-validator)\n\n<!-- recipe: sell-store-items -->\n## Recipe: Sell store items\n\n1. Confirm the human wants to sell looks in the portal store. Players buy store items in the portal, never inside your game, so build no store, price or buy button, and do not declare `capabilities.wallet` for this alone — [How items reach the store](/guide/sdk/store-items#how-items-reach-the-store)\n2. Declare each changeable part of the game as a slot in `miniant.json`'s `store.slots`: `\"self\"` if only the owner sees it, `\"everyone\"` if opponents and spectators do — [Declare slots](/guide/sdk/store-items#declare-slots)\n3. Declare each item as an `entitlement` SKU in `store.skus` with a `cosmetic` block naming its slot and a preview image inside the build — [Declare items](/guide/sdk/store-items#declare-items)\n4. Draw each seat's equipped item from `context.player.cosmetics` (self) or `context.participants[].cosmetics` (everyone), and draw your default look for a missing or unknown id — [Read the equipped item](/guide/sdk/store-items#read-the-equipped-item)\n5. Build the project and run the local validator until it prints PASS, then publish a build; each item is reviewed with it — [Review](/guide/sdk/store-items#review)\n\n<!-- recipe: add-highlights -->\n## Recipe: Add highlights\n\n1. Add highlights only to a game with a `vs_ai` or online mode; a solo-only or levels game must not call it — [What the validator checks](/guide/sdk/highlights#what-the-validator-checks)\n2. Call `Miniant.highlights.start({ canvas })` once per match, after `Miniant.init()` and before `Miniant.ready()`; a DOM-built game passes an offscreen canvas it redraws every frame — [Highlights](/guide/sdk/highlights)\n3. Call `Miniant.highlights.frame()` after every render, `mark()` once per event with `by` set, one mark with `win: true` on the winning moment, and `setScore(text)` when the score changes — [Highlights](/guide/sdk/highlights)\n4. Finish with the normal `Miniant.reportResult()`; there is no upload call — [Finishing: `reportResult`](/guide/sdk/highlights#finishing-reportresult)\n5. Build the project and run the local validator; fix every `highlights.*` warning, and report each item in its \"HIGHLIGHTS — CHECK BY WATCHING A CLIP\" list as watched or not watched — [What the validator checks](/guide/sdk/highlights#what-the-validator-checks)\n\n<!-- recipe: update-existing-game -->\n## Recipe: Update an existing game\n\n1. Before anything else, fetch the changelog (https://publish.miniant.games/guide/changelog.md) and apply every entry dated after this game's last update — a required entry is a build- or review-failing change you'd otherwise miss\n2. Re-read the manifest rules before changing `miniant.json` — a declared-but-broken mode fails review — [The `miniant.json` manifest](/guide/requirements/technical#the-miniantjson-manifest)\n3. If the manifest's `orientation` is `\"portrait\"` or `\"landscape\"`, make the layout fit every screen shape and switch it to `\"any\"`; whatever it declares, check the game at the five sizes in that section after your change — [Your game must fit every screen shape](/guide/requirements/technical#your-game-must-fit-every-screen-shape)\n4. If the game declares `capabilities.saveState`, call `state.load()` unconditionally on every session start — [Call `state.load()` on every session start](/guide/sdk/data#call-stateload-on-every-session-start)\n5. Save at the granularity the game's shape requires — a level snapshot at the START of the level (against-the-player values such as lives lost may be saved live), or the exact position for a no-level match game — [Save granularity depends on your game's shape](/guide/sdk/data#save-granularity-depends-on-your-games-shape)\n6. Keep your own periodic autosave regardless of whether you also register `onSaveRequest` — [`Miniant.state.onSaveRequest(cb)` — reacting to a portal-initiated save](/guide/sdk/data#miniantstateonsaverequestcb-reacting-to-a-portal-initiated-save)\n7. Never let saved progress buy Tickets, rank, or leaderboard position — only a portal-witnessed `reportResult()`/`reportProgress()` call does that — [Saved progress never buys rank](/guide/sdk/data#saved-progress-never-buys-rank)\n8. If adding tier-2 progress import, declare `importEndpoint` and verify the `assertion` envelope the same way as a receipt, checking `purpose === \"progress_import\"` — [What Miniant sends you](/guide/sdk/progress-import#what-miniant-sends-you)\n9. Respond within 8 seconds with a JSON `blob`, treating it as cosmetic-only progress forever, never Tickets or rank — [What you return](/guide/sdk/progress-import#what-you-return)\n10. Rebuild, rerun the local validator until it prints PASS, and re-read what the reviewer checks before requesting a re-publish — [What the reviewer sees](/guide/publishing#what-the-reviewer-sees)\n"}
|