Update to Mindblown
Snapshot Nov 3, 2026 · 00:13 UTC · version 1.0.1
Collection source: downloaded plugin package.
Instructions updated for mindblown-multiplayer
Skill instructions — Now says: | [Names on screen](#names-on-screen) | `mb.recording.mask`, for names you draw in 3D | - **A page can open already in its room.** Some places open your page...
Observed in instructions or declared skills. Runtime behavior has not been tested.
Up to 10 plugins free · no card required.
All fields and original values.
Skill instructions
Oct 31, 2026 · 01:15 UTC
No removed lines (ordering may have changed)
Nov 3, 2026 · 00:13 UTC
| [Names on screen](#names-on-screen) | `mb.recording.mask`, for names you draw in 3D | - **A page can open already in its room.** Some places open your page for a group of people who are already together, and give all of them one room...
Compare saved observations
Download comparison JSONSKILL.md text changes
− removed · + added
--- before +++ after @@ -43,6 +43,7 @@ | [What we keep](#what-we-keep) | how a round becomes a record, and why `places` | | [What happened in a room](#what-happened-in-a-room) | every tick, kept; run your rules against it again | | [Who is watching](#who-is-watching) | presence | +| [Names on screen](#names-on-screen) | `mb.recording.mask`, for names you draw in 3D | | [Refusals](#refusals) | every `reason` | | [A whole page](#a-whole-page) | the smallest complete example | | [The old wire](#the-old-wire-until-31-december-2026) | deprecated, and still answering | @@ -87,6 +88,12 @@ room the moment you register `match`. Do not read the address and do not show a start screen first: the person followed a link to somebody, and the lobby of that room is what they should see. +- **A page can open already in its room.** Some places open your page for a + group of people who are already together, and give all of them one room. + The address then carries `?room=` from the first load, as a link does. Show + that room's lobby at once. Do not show a menu for somebody alone: no solo + start, no public match, no private room to open. Every join from that page + sits in that room, whatever it asks for. - **`play(opts)` is your own button.** `{ public: true, seats: N }` puts them with people; `{ seats: N }` opens a room of their own to share. We write the room into the address, so a reload is the same seat. @@ -719,6 +726,27 @@ mark(s.seat, s.username, s.online ? "here" : `away ${Math.round(s.seen / 60)}m`)); ``` +## Names on screen + +A few views of a page are recorded, so its maker can watch how it is used. +People's names and what they send each other never reach a recording: we black +them out wherever we can see them drawn -- HTML text, and `fillText` on a +canvas in the page. + +Text you draw in 3D -- a name over a car, a chat line on a texture, a font +atlas -- we cannot see. A room page with a 3D canvas is then not recorded at +all, unless you say where the names are: + +```js +mb.recording.mask((canvas) => { + // CSS pixels of this canvas; [] when no name is on screen. + return labels.filter((l) => l.visible).map((l) => ({ x: l.x - 4, y: l.y - 4, width: l.w + 8, height: l.h + 8 })); +}); +``` + +Return every rectangle a name or a message covers this frame. Return `null` +to leave a frame out. Calling it costs nothing when the view is not recorded. + ## Refusals A refusal reaches the lobby as `state.refused.reason`:
Full technical diff · 1 changed fields
changed /skill_md_contents
"---\nname: mindblown-multiplayer\ndescription: \"Build multiplayer browser games with Mindblown: live rooms with a lobby, seats, and rules.js running on the server at twenty ticks a second. Use when a game or page has two or more players at once, a shared board, turns, or needs a test seat for a second player. Publish it with the Mindblown skill.\"\n---\n<!-- GENERATED by scripts/build-plugin-skills.py from mindblown-web/src/pod/multiplayer.md. DO NOT EDIT. -->\n\n> Publish with the `mindblown` skill. When the mindblown tools are connected,\n> ask for a test seat with `create_test_seats` (`page_id`, `count`, `seats`):\n> it answers the same JSON as `POST /me/test-seats/<game_id>` below, and you\n> need no token. Without the tools, use that HTTP call with the token the\n> `mindblown` skill's sign-in gave you.\n\n# Playing together on mindblown\n\nA page where more than one person is in the same place at once -- a table, a\nmatch, a shared board, everybody moving at once or one at a time. This is the\npart of the SDK (`references/sdk.md` in the `mindblown` skill) for that, and only that. Everything here is\n`window.mindblown` (`mb` below), the same object the SDK describes.\n\n**Read this whole file before writing the first line of a page that seats two\npeople.** Two things are different from every other page: the rules of your\npage run in a file that runs on the server, and your page draws what the\nserver says rather than what it worked out itself. The rules in here are the\nones the server enforces, and a refusal will name this file.\n\n## Contents\n\n| Section | What it settles |\n| --- | --- |\n| [The shape](#the-shape) | what runs where |\n| [The lobby and the match](#the-lobby-and-the-match) | `play`, `lobby`, `match` -- how people get to the table |\n| [The room is the authority](#the-room-is-the-authority) | where every fact about a room comes from |\n| [Seats](#seats) | numbering, and who goes first |\n| [The invitation](#the-invitation) | the name, the link, `shareUrl` |\n| [Accounts](#accounts) | why a seat needs one, and what your page does about it |\n| [Testing it](#testing-it) | a second person, so you can open a page for two |\n| [`rules.js`](#rulesjs) | the five exports, their arguments, what they may not do |\n| [`mb.live`](#mblive) | the channel: sending, hearing ticks, closing |\n| [The tick](#the-tick) | what every seat receives, twenty times a second |\n| [Turns on live](#turns-on-live) | a turn-based match, whole |\n| [Drawing between ticks](#drawing-between-ticks) | interpolate what the room moves, carry forward what a seat moves |\n| [What we decide](#what-we-decide) | and what is yours |\n| [What we keep](#what-we-keep) | how a round becomes a record, and why `places` |\n| [What happened in a room](#what-happened-in-a-room) | every tick, kept; run your rules against it again |\n| [Who is watching](#who-is-watching) | presence |\n| [Refusals](#refusals) | every `reason` |\n| [A whole page](#a-whole-page) | the smallest complete example |\n| [The old wire](#the-old-wire-until-31-december-2026) | deprecated, and still answering |\n\n## The shape\n\nYour page ships two things:\n\n- **`rules.js`** at the root of your bundle, served at `https://<your-host>/rules.js`.\n The room fetches it when a room opens and runs it. It has no DOM, no\n network, no clock of its own. It is the rules, and nothing else.\n- **The page**, which draws the lobby and the match, and sends the seat's\n input. It decides nothing about the match, and nothing about how people get\n to it: which room, the join and the start are ours.\n\nThe room runs `step` twenty times a second and sends each seat what `view`\nsays that seat may see. Everybody draws the same match because everybody is\ndrawing the room's answer.\n\n**Send one input per tick, which is twenty a second.** The room keeps the LAST\ninput per seat and clears them as the tick runs, so a second message in the\nsame tick replaces the first and anything beyond one per tick is thrown away by\nconstruction. Thirty a second is what the limiter tolerates, not a rate worth\naiming at: it cannot make the match more responsive, and on a weak connection\nit is the traffic that makes it worse. Send at the tick rate or below, and\npredict locally between ticks.\n\n## The lobby and the match\n\n**We run the room. Your page draws it.** Which room, the join, who is present\nand when the match starts are the platform's. How all of it looks is yours.\n\n```js\nmb.live.lobby((state) => drawLobby(state)); // every change before the start\nmb.live.match((channel) => play(channel)); // once, when every seat is here\nplayButton.onclick = () => mb.live.play({ public: true, seats: 2 });\n```\n\nRegister both on load, every load. Then:\n\n- **A link joins by itself.** When the address carries `?room=`, we join that\n room the moment you register `match`. Do not read the address and do not\n show a start screen first: the person followed a link to somebody, and the\n lobby of that room is what they should see.\n- **`play(opts)` is your own button.** `{ public: true, seats: N }` puts them\n with people; `{ seats: N }` opens a room of their own to share. We write the\n room into the address, so a reload is the same seat.\n- **A full room starts.** We call `match` once, when every seat is connected,\n and not before. Never put a Ready button, an invite control or a share\n prompt in front of it -- nobody is missing.\n\n`state` is data, and every pixel of it is yours to draw:\n\n| Field | Meaning |\n| --- | --- |\n| `room` | the room's name |\n| `seats` | how many it holds |\n| `mine` | this person's seat, from 1 |\n| `present` | `[{ seat, username, mine }]` -- who is connected now |\n| `link` | the link to send, already made with `mb.shareUrl` |\n| `refused` | absent, or `{ reason, needsAccount }` -- show it; see [Refusals](#refusals) |\n\nThe channel `match` hands you is described under [`mb.live`](#mblive):\n`channel.room` is the room with everybody named, `channel.seat` is yours.\n\n```js\nleaveButton.onclick = () => mb.live.leave(); // closes the socket, frees the seat\n```\n\n**`leave` is a control, never a lifecycle event.** Closing a tab is going\noffline, not leaving; the match is still theirs. Do not call it on `pagehide`,\n`unload` or a reload.\n\n`mb.room.join` and `mb.live.join` are the calls under these, and they still\nanswer. Do not use them in a new page: every piece of arrival and start logic a\npage wrote around them is what this section replaced.\n\n## The room is the authority\n\n**Every fact about a room comes back from the room, and from nowhere else.**\n\n| What you want to know | Where it comes from |\n| --- | --- |\n| how many seats this table has | `room.seats` |\n| which seat is mine | `room.mine` |\n| whose turn it is | `room.turn` |\n| who else is here | `room.seated` |\n| what the table looks like | `room.state` |\n| what I am holding | `room.private` |\n\nNever from your own address, never from a message another page sent, never\nfrom what somebody typed, never from what your page asked for a moment ago.\nThose are one thing: a value somebody else can set.\n\n```js\nmb.live.match((channel) => layOutTable(channel.room.seats)); // the room's answer\n```\n\n`seats` is read when a room is opened and never again. A later arrival is\nseated at the size the room already has, whatever it asks for.\n\n**Or ask for a room with people in it, and we find one.**\n\n```js\nmb.live.play({ public: true, seats: 4 });\n```\n\n`public` means *put me somewhere with people*: we take the fullest open room of\nyour page that still has a seat, and open one when there is nowhere to sit. When\nyour address carries `?room=` and that room is public with a free seat, it is\nthe one we take -- the person followed a link to somebody, and that is who they\nsit with. `seats` is still the size, and is used only if this ask is the one\nthat opens a room. The answer is the same room you always get, so everything after it is\nunchanged: put `room.name` in a link and you can still invite anybody you like\ninto it.\n\n**A public room is public because you said so when you opened it.** Nobody\npicks one from a list; a person asks and the server seats them. Asking twice\nreturns the room you are in, so a reload is not a second seat.\n\n**Tell an arrival that somebody is waiting.** Before anybody presses your\nbutton, you can ask who is in a public room of your page right now with a seat\nfree:\n\n```js\nconst waiting = await mb.live.waiting();\n// [{ room: \"quiet_lake_41\", seats: 2, present: [{ username: \"ada\", waited_s: 40 }] }]\nif (waiting.length) say(`${waiting[0].present[0].username} is waiting -- play now`);\nplayButton.onclick = () => mb.live.play({ public: true, seats: 2 });\n```\n\nIt is for SHOWING, not for choosing. Join with the same `play({ public: true })`:\nwe seat the person in the first room of that list, which is the fullest. Never\npass one of these names as `room` to steer somebody into it, and never offer\nthe list as a menu -- that is matchmaking of your own, and it spreads people\nacross rooms. A named room is never in the list, the person asking is never in\nit, and an empty list means nobody is waiting; draw nothing then. People wait\nseconds, not minutes, so ask when the page opens and again when your start\nscreen comes back, not on a timer.\n\n**Asking for a room while you are in one gives you that room.** Pressing your\nown Play button twice is one room and not two. A page that wants a SECOND room\ncalls `mb.live.leave()` first, then `play` again.\n\n**A seat in a public room is held while you are there, and is free when you are\nnot.** Close the tab and your seat goes back to the pool within about a minute;\ncome back and you are seated with whoever is playing now, which is what asking\nfor a public room meant. This is the one place a seat behaves differently from\na named room, where the seat stays yours.\n\n**So do not write your own matchmaking, and delete it if you have.** You can\nsee only the room you were handed, never the pool, so the only move available\nto you is to refuse one and open another -- which spreads the people you were\ntrying to meet across more rooms. Checking whether the people in a room are\n\"really there\", keeping a list of rooms that went bad, or leaving and re-asking\nare all answers to a platform defect that is fixed: ask once and play in what\ncomes back.\n\n**A room has a maximum, not a starting line.** `seats` is the most people who\nmay sit down, and the room ticks from the first one: `step` is handed the seats\nconnected right now, so whether anything happens before the rest arrive is your\ndecision and not ours. Say it in two places -- `step` advances the state or\nstands still, and `waiting` names the seats the match cannot go on without. A\npage that cannot begin alone stands still and returns the empty seats from\n`waiting`, which is how the person who is missing is told. A page people drop\ninto plays on and returns `[]`, because nobody is holding anything up.\n\n## Seats\n\n**Seats are numbered from 1.** `mine`, `turn`, `seated[].seat` and `next` all\ncount from 1. There is no seat 0, and a `next` that names one is refused.\n\n**Seat 1 acts first**, from the moment the room opens.\n\n```js\nconst piece = room.mine === 1 ? \"X\" : \"O\"; // never seat === 0\nconst myTurn = room.turn === room.mine; // this, and nothing worked out from the board\n```\n\n`turn` is the one the server enforces. A page that derives whose move it is\nfrom its own board has a second opinion that can disagree, and when it does the\npage locks.\n\n## The invitation\n\n**The link is the invitation.** `state.link` is the room's link, ready to send:\nwhoever opens it joins that room, because a link joins by itself. Show it while\nthe room has an empty seat.\n\n```js\nmb.live.lobby((state) => {\n if (state.present && state.present.length < state.seats) showLink(state.link);\n});\n```\n\n**Send `mb.shareUrl(...)` and never `location.href`.** Your page runs inside a\nframe, and its own address carries a marker of ours. A link built from\n`location.href` opens the page without the frame -- no panel, no sign-in, no\nway into a seat. `shareUrl(extra)` gives the address the page is reachable at,\nwith our marker removed, your query kept, and what you pass set on top; `null`\nfor a key drops it.\n\n**Put the room in the link and nothing else.** Every other value in a link is a\nvalue the person you invited can edit before they open it.\n\nA name you choose yourself is one room everybody on the page shares -- right\nfor a single open table, wrong for anything private.\n\n**Or ask somebody by name, and we draw the card.**\n\n```js\nconst asked = await mb.room.invite(room.name);\nif (asked.sent) show(\"Invitation sent\"); // asked.sent is a NUMBER\nelse show(\"Nobody was asked\"); // asked.closed, the card was dismissed\n```\n\n`mb.room.invite(name)` opens OUR sheet over your page: two lists the person\nalready knows -- who they sat down with, who they follow -- and a field that\ntakes a username. It resolves when the card closes.\n\n**It takes no name and returns none.** You never send us an address, and the\nanswer is a count: `{ sent: 2 }`, or `{ closed: true }` when nothing went out.\nYour page learns that a card was opened and how many invitations left it. There\nis no list of people in this SDK and there will not be one.\n\n**There is no accept step.** Whoever was asked takes a seat, and taking it is\nthe acceptance. Keep the link from `mb.shareUrl` as well: an invitation is one\nway in and a link is the other.\n\n## Accounts\n\n**A guest can join an existing named room.** The default join gate permits\nthis. An invitation-only room refuses a guest. A guest cannot open a named\nroom or enter a resource room.\n\n**A guest uses a pass, not a sign-in.** Their name is `guest_` and four\ncharacters. The browser keeps the same guest for one day. `mb.viewer` still\nreports that the person is not signed in. Their round result is recorded.\n\n**A guest seat lasts while the guest is present.** After 60 seconds without\npresence, another person can take it. A named account seat remains durable.\nAll public seats retain the presence rule above.\n\n**You do not build a sign-in.** An ask to open a named room without an account\nreturns `refused.needsAccount`. The mindblown panel supplies the sign-in.\nAfter sign-in, the person joins as their account. Your page draws the refusal\nuntil that join succeeds.\n\n**They may close the ask.** Then they are looking at your page with no seat --\na real state, and it has to work. Calling `mb.live.play` again brings the ask\nback, so give them one control that means \"play with somebody\".\n\n**Reading stays open.** `look` works signed out: who is here and what everybody\nsees, with no seat and no hidden state.\n\n## Testing it\n\n**A page for two people cannot be tested by one person.** You take seat 1 and\nwait, and nothing happens, because nothing can. So ask for a **test seat**: a\nsecond person who exists for half an hour, in one room of one page.\n\n**Do not say a page for two works until two people have met in it.** Running\n`rules.js` on your own machine proves the rules and nothing else: not the\narrival from a link, not the join, not the start. Open two seat links, and the\npage works when both of them see every seat connected.\n\nTwo doors, and the page must be the asker's own either way.\n\n```\nPOST https://api.mindblown.ai/me/test-seats/<game_id> you hold their session\nAuthorization: Bearer <the owner's access token>\n\nPOST https://api.mindblown.ai/internal/test-seats/<game_id> you are their pod\nAuthorization: Bearer <the service token>\nX-Mindblown-Subject: <the subject token naming the person>\n\n{ \"count\": 1, \"seats\": 2 } the pod's body also names \"channel\", \"channel_id\"\n```\n\n`channel` and `channel_id` must name an address that has written to the bot.\nA lookup never creates a person.\n\nThe answer is one entry per test person:\n\n```json\n{\n \"room\": \"quiet-lake-41\",\n \"ttl_seconds\": 1800,\n \"seats\": [{\n \"account_id\": \"...\", \"username\": \"test_9f2c1ab7\", \"name\": \"Tester 1\",\n \"seat\": 2, \"room\": \"quiet-lake-41\", \"pass\": \"...\",\n \"url\": \"https://mindblown.ai/...?mb_seat=<pass>&room=quiet-lake-41\",\n \"expires_at\": \"...\"\n }]\n}\n```\n\n**Open the `url` in a second window and that window is the second person.** It\njoins, it is seen, it plays, it leaves. Your page needs no change and cannot\ntell the difference: a test seat comes through the same door as anybody else.\nPut the OWNER in the same `room` -- open your own page with `?room=<room>` --\nand the two of them are at one table.\n\n**What a test seat is.** A real account with a name and a seat, so everything\nyour page reads about a person is really there, `mb.viewer` included: it reads\nas signed in. It holds a `pass`, not a session -- all that can do is take a\nticket for its own seat.\n\n**What it is not.** It never appears in the people anybody played with, it is\nnever counted as somebody playing now, it has no inbox, and a match in a room\nthat held one is not written down. So a page is not made to look busy by being\ntested, and a test does not end up in anybody's history.\n\n**So a test seat cannot prove the RECORD** -- everything up to the end of a\nmatch, but not what we wrote down. Check that with real accounts.\n\n**Half an hour, then it is gone.** The account is deleted and its seat with it.\nAsk again if you need longer; there is no way to extend one.\n\n**Count what you need.** `count` is how many test people, `seats` is how big the\nroom is -- leave `seats` out and the room is built for your test people plus\nyou. Up to fifteen test seats, because a room holds sixteen.\n\n**It is for testing and it is not a player.** Do not use test seats to make a\npage look played, to fill a room a real person is waiting in, or to leave a\npage running. Nothing stops you; the numbers simply will not move, because\nnone of them counts a test seat.\n\n## `rules.js`\n\nAn ES module with five exports. Nothing about where it runs.\n\n```js\nexport const config = { seats: 2, watch: true, hold: 60 };\nexport function init(seats, rng, context) { return state; }\nexport function step(state, inputs, dt, rng, seats) { return state; }\nexport function view(state, seat) { return whatThisSeatSees; }\nexport function waiting(state, seats) { return []; } // seats the match cannot go on without\nexport function done(state) { return null; } // or how it ended\n```\n\n| Export | Called | Arguments | Returns |\n| --- | --- | --- | --- |\n| `config` | read by your page | -- | `{ seats, watch, hold }`. **Not yet enforced by the room**: the size is what `mb.room.join` opened the room with, and `watch` and `hold` are on the list below |\n| `init` | before the first step without saved state | `seats`: connected seat numbers; `rng`; frozen `context` | the starting state |\n| `step` | every tick | `state`; `inputs`: `{ [seat]: latestInput }` for seats that sent one this tick; `dt` in ms; `rng`; `seats` connected now | the next state, or nothing to mean \"I changed it in place\" |\n| `view` | every tick, once per seat | `state`, `seat` | what that seat receives. JSON. |\n| `waiting` | every tick, optional | `state`, `seats` connected | the seats the match cannot go on without. A turn-based file answers `[state.turn]`; a match that is simply on answers `[]`. It is how the platform knows to say \"waiting for you\" |\n| `done` | every tick | `state` | falsy while the match is on; anything else is how it ended, sent to every seat and **written down**. See [What we keep](#what-we-keep) |\n\n**Time and randomness come in as arguments.** `rng()` gives a number in\n`[0, 1)` from a generator the room seeded; `dt` is ours. Use them. During a\ntick the room also answers `Math.random()` from the same generator and\n`Date.now()` with the tick's time, so a file that reads them can still be run\nagain from the room's log (see [What happened in a room](#what-happened-in-a-room)).\n\n**`view` is where hidden information lives.** A hand, fog of war, a bluff:\nreturn to seat 3 only what seat 3 may know. The room sends nothing a `view`\ndid not return. Everything in `state` that `view` returns to everybody is\npublic.\n\n**Keep it small and keep it pure.** Under 512KB. No imports of anything but\nyour own code. A `step` that throws is reported to every seat on every tick\nas `error`, in your own words, and the match stands still until it stops.\n\n**`seats` is the list of seats connected right now**, in no particular\norder. A seat whose socket dropped is absent from it and sends no input; a\nseat that comes back is present again. `init` receives the seats present at\nthe first tick, which may be one -- a match that needs two waits in `step`\nuntil `seats.length` says so.\n\n**What `done` returns is recorded, and two shapes name a winner.** The room\nreports the end once and the platform keeps it -- the seats, the accounts\nbehind them, how long it ran. Say `{ winner: 2 }` or `{ places: [2, 1, 3] }`\nand the platform knows who finished where; add `{ scores: { \"2\": 40 } }` and it\nknows what each seat scored. Anything else is kept whole and names nobody,\nwhich is right for a match that has no winner. This is an offer and not a\nrequirement: the shape is yours, and these three are the parts we can read.\n\n## `mb.live`\n\n```js\nmb.live.match((channel) => { // see The lobby and the match\n channel.send(input); // this seat's latest input\n const off = channel.on(\"tick\", (tick) => draw(tick));\n channel.on(\"close\", ({ code, reason }) => ...);\n});\n```\n\nA `Channel`:\n\n| Field | Meaning |\n| --- | --- |\n| `seat` | your seat number, from 1 |\n| `room` | the room: `name`, `seats`, `seated` with everybody named |\n| `send(input)` | your input. Any JSON. Last write per tick wins, so one a tick is all a room can use -- see above |\n| `on(event, fn)` | `\"tick\"` and `\"close\"`. Returns a function that unsubscribes |\n| `close()` | closes the socket. The seat is still yours; `mb.live.leave()` gives it back |\n\n**The page never holds the socket.** The mindblown panel on your page opens\nit, with a ticket your page never sees, and relays what the room says. Your\npage speaks to `channel` and nothing else.\n\n### Immutable resources\n\nCreate a room from published documents when its rules need a fixed layout.\nEach resource belongs to this page and remains unchanged during the attempt.\n\n```js\nconst requestId = crypto.randomUUID();\nconst channel = await mb.live.create({\n seats: 2,\n resources: { vault: published.id },\n requestId,\n});\nchannel.on(\"tick\", (tick) => draw(tick));\n```\n\nCreation requires a real account and one through four published documents.\nTheir combined JSON must fit within 64 KiB.\nThe channel has the same methods as the one `match` hands you.\nKeep `requestId` for an explicit retry after a lost response.\nRetry the same request; different content produces `IDEMPOTENCY_CONFLICT`.\nAn identical retry returns the original room without another seat replacement.\nA superseded attempt can refuse its ticket with `NO_SEAT`.\nThe error preserves `requestId` and any room receipt already received.\n\nThe rules receive a frozen context before initialization:\n\n```js\nexport function init(seats, rng, context) {\n const vault = context.resources.vault;\n return { cells: [...vault.value.cells], moves: 0 };\n}\n```\n\nEach resource contains `id`, `kind`, `schemaVersion`, `hash`, and `value`.\nOrdinary rooms receive `{resources:{}}`.\nSaved state survives runtime recovery; recovery does not call `init` again.\nThe context stays inside the rules unless `view` returns part of it.\n\nA resource room permits one completed attempt.\nIts first `done` stops simulation and becomes the final result.\nReconnects receive the same final view and result.\nCreate another room for another attempt.\nOwner removal lets an already-started attempt finish.\nPlatform removal can stop an attempt.\nTemporary authorization failures pause simulation until access returns.\n\n## The tick\n\nEvery seat receives, twenty times a second:\n\n```js\n{\n t: 1789371672003, // the room's clock, ms\n tick: 4812, // counts up from 1\n dt: 50, // ms since the previous tick\n seats: [1, 2], // who is connected right now\n waiting: [2], // who the match cannot go on without, from waiting()\n view: { ... }, // what view(state, mySeat) returned\n done: null, // or what done(state) returned\n error: \"...\" // only when rules.js threw or could not be fetched\n}\n```\n\nWhen `error` is present there is no `view`. Show the sentence; it is yours.\n\nA late joiner's first tick is a whole view, so there is nothing to catch up\non. A room with sockets open and no input for three seconds stops ticking\nuntil the next input; your page sees the gap as a pause, not as a fault.\n\n**One socket per seat, and the newest window holds it.** A seat is a person and\njoining twice is the same seat, so a second tab, a reload that raced its own\nclose and a reconnect after a drop all ask for a seat that already has a socket.\nThe room hands it to the newest and tells the old one `{ closed: \"elsewhere\" }`\nbefore closing it. Two consequences worth designing for: `seats` never carries\nthe same number twice, so `seats.length` is a count of PEOPLE; and one person\ncannot fill two seats by opening two windows -- to test a match with two\nplayers you need two accounts.\n\n**The view is the budget, and it is the only one you are likely to reach.** The\nroom sends one to every seat twenty times a second, so a view is paid for\nsixteen hundred times a minute at eight seats. Two measurements, both from real\npages: a page that returns the whole board with the parts a seat cannot see set\nto `null` sent 5.9KB per seat per tick -- about 1MB a second at eight seats --\nand a page that culled by line of sight inside `view` sent 333 bytes, which is\n52KB a second. The difference is twenty-fold.\n\n**So cull in `view`, not in the page.** It already exists to decide what one\nseat may see, which makes it the only place that can leave something out\nhonestly -- a page that filters what it draws still downloaded the rest, and a\npage that could read it could cheat with it. If a visibility test is expensive,\nwork it out once per tick rather than once per pair. One page's went from 56\ntraces to 28, and the cost of a whole tick's views fell from 0.615ms to\n0.015ms.\n\n**What the room itself can carry is not the constraint you will meet first.**\nMeasured against the live room, with a page that culls inside `view`:\n\n| seats | inputs each | ticks a second | gap, typical | gap, worst |\n| --- | --- | --- | --- | --- |\n| 2 | 15 | 19.8 | 49ms | 68ms |\n| 8 | 15 | 20.1 | 50ms | 81ms |\n| 8 | 30 | 20.0 | 49ms | 81ms |\n| 16 | 30 | 20.0 | 50ms | 102ms |\n| 16 | 60 | 20.0 | 50ms | 83ms |\n\nEvery message on the wire in those runs was around 464 bytes, envelope and all,\nand it did not grow when the input rate doubled. The last row is twice the rate\nany page should send, at the seat cap, and it is 960 messages a second into one\nroom -- the ceiling the platform gives us -- with the clock still at 50ms. Rerun\nit yourself with `mindblown-live/probe.mjs`; a number in a document nobody can\nreproduce goes stale without anybody noticing.\n\n## Turns on live\n\nA turn-based match is a rules file whose `step` listens to one seat. There is\nno other machinery: `state.turn` is a number your `step` moves along, hidden\nhands are what `view` keeps back, and `waiting` names the seat whose move it\nis so the platform can say so. The room enforces all of it -- nobody moves out\nof turn, nobody moves twice, nobody sees a hand that is not theirs -- because\nthe room is the only thing running the file.\n\nNoughts and crosses, whole:\n\n```js\nexport const config = { seats: 2, watch: true, hold: 60 };\nexport function init(seats, rng) { return { cells: Array(9).fill(0), turn: 1, moves: 0 }; }\nconst LINES = [[0,1,2],[3,4,5],[6,7,8],[0,3,6],[1,4,7],[2,5,8],[0,4,8],[2,4,6]];\nconst winner = (c) => LINES.map(([a, b, d]) => c[a] && c[a] === c[b] && c[a] === c[d] ? c[a] : 0).find(Boolean) || 0;\nexport function step(state, inputs, dt, rng, seats) {\n if (winner(state.cells) || state.moves === 9) return state;\n const input = inputs[state.turn]; // one seat is heard\n if (!input || !Number.isInteger(input.cell) || input.cell < 0 || input.cell > 8) return state;\n if (state.cells[input.cell]) return state;\n state.cells[input.cell] = state.turn;\n state.moves++;\n state.turn = state.turn === 1 ? 2 : 1;\n return state;\n}\nexport function view(state, seat) { return { me: seat, cells: state.cells, turn: state.turn }; }\nexport function waiting(state) { return winner(state.cells) || state.moves === 9 ? [] : [state.turn]; }\nexport function done(state) {\n const w = winner(state.cells);\n return w ? { winner: w } : state.moves === 9 ? { draw: true } : null;\n}\n```\n\nA card table is the same shape with a `deck` array in `state`, shuffled with\n`rng` in `init`, dealt by `step`, and hidden by `view` -- return to seat 3 only\n`state.hands[3]`. A trade is `step` moving items between two hands when the\nseat holding the turn asks. A showdown is `view` no longer hiding. A deadline\nis a number in `state` compared with the tick's clock.\n\nThe page for a turn-based match sends its move once, on the tap, and draws on the\ntick. It does not need thirty inputs a second.\n\n**The old turn-based wire** -- `mb.room.look` and `mb.room.act` with `next`,\n`hold`, `deck`, `deal`, `give`, `take`, `show` -- is deprecated as of\n2026-09-15. It keeps answering for the pages already on it until 31 December\n2026 and is written down at the end of this file. New pages write the file\nabove.\n\n## Drawing between ticks\n\nTwenty ticks a second is not sixty frames. Draw at your frame rate. A page\nthat draws only on ticks looks like twenty frames a second, because it is.\nHow to draw between ticks depends on who moves the thing.\n\n**What the room moves, interpolate.** A board, a score, anything `step`\nmoves: keep the previous view and its `t`, and draw a point `dt`-proportional\nbetween the last two. It is drawn about one tick late, and that is right,\nbecause the room is the authority on it.\n\n**What another seat moves, draw where it is NOW, not where it was.** When each\nseat sends its own position -- a vehicle, a body, a pointer -- the newest\nsample you hold for another seat is already old when it arrives. Every frame,\ncarry it forward: its position plus its velocity times the sample's age, with\nits turn rate if you send one. When the next sample arrives, blend toward it\nover about 100ms rather than jumping. Stop carrying it forward after about\n250ms without a sample, and hold it where it is, so a dropped seat does not\ndrive off on its own. Send each sample with its time on the room's clock (the\nlast tick's `t` plus the time since that tick), so the other page knows how\nold it is.\n\n**Do not hold samples back to smooth them.** A buffer that draws other seats\nsome milliseconds in the past adds all of those milliseconds to what a person\nsees, however fast the delivery under it is.\n\n**Prediction is optional and it is the same file.** A page may `import`\n`/rules.js` itself and run `step` locally with its own input to draw ahead\nof the next tick, then snap to the view when it arrives. Start without it.\n\n## What we decide\n\nOurs, and no page can change it:\n\n- **who you are** -- the seat comes from a ticket the API signed, never from\n a message,\n- **what you can see** -- `view` is applied by the room, never by a page,\n- **the clock** -- `dt` is ours; nobody runs their own faster,\n- **the dice** -- `rng` is ours and seeded,\n- **which rules** -- the file at your host when the room opened. A republish\n never changes a match in progress; the next room gets the new file.\n\nYours: whether `step` is right. We do not know your rules. What we know is\nthat every seat is running the same ones.\n\n## What we keep\n\nThese rematch rules apply to ordinary rooms. Resource rooms permit one completed attempt.\n\n**`done` is how a round is written down, and a room that never ends keeps\nnothing.** When `done` returns something, the room reports it and we record the\nmatch: who held each seat, how long it ran, and the result whole. When `done`\ngoes falsy again the room has started another one, it gets the next sequence,\nand that one is recorded too. **So a page with rounds should end each round\nthrough `done` and let the next begin — not return `null` for ever.**\n\n```js\nexport function done(state) {\n // the ROUND ended, and the room plays on\n if (state.round.over) return { places: state.round.order };\n return null;\n}\n```\n\nA file that answers `null` for ever is a file we hold no history for. Measured\n2026-09-18: a page that had been played for hours in dozens of rounds had\n**zero** records, because its `done` said `return null; // the room never ends`.\n\n**Return `places`, not only `scores`.** `places` is the finishing order —\n`[3, 1, 2]` means seat 3 came first — and it is the one thing that means the\nsame in every page. A score is yours: two hundred is a triumph in one page and\na rout in another, so nothing can compare it with anything. Both are kept, and\n`winner` is accepted as shorthand for a single first place:\n\n| in the result | what we do with it |\n| --- | --- |\n| `places: [3, 1, 2]` | seat 3 first, seat 1 second, seat 2 third |\n| `winner: 2` | seat 2 first, everybody else unplaced |\n| `scores: { \"1\": 240, \"2\": 90 }` | kept per seat, compared with nothing |\n\nAnything else stays in the raw result and names no winner.\nRead your account's recorded outcomes with `mb.matches.list({limit:20})`.\nThe reply contains `{items,nextCursor}`.\nEach item includes your outcomes, match times, room, sequence, and available resource provenance.\nPass `nextCursor` to read the next page.\nOnly exact account bindings appear. Test rooms and inferred participation remain excluded.\nOther accounts' outcomes and raw result JSON remain private.\n\n## What happened in a room\n\n**A room writes down every tick as your rules saw it**: the tick's number, its\ntime, `dt`, the seats in order, every seat's input, and a digest of every view.\nWith the room's seed and the state where the log starts, that is enough to run\nthe room again, tick for tick. A room keeps the last hour.\n\n\nOnly the page's owner reads a room's log (`GET /me/rooms/{page_id}/{room}/log`).\nIt holds nothing your rules file was not already given.\n\n## Who is watching\n\nEach seat carries `online` and `seen` -- seconds since that person's page last\nasked about the room. Presence is the heartbeat of `look`; nothing to send.\n\n**Presence is not membership.** Somebody offline still holds their seat. Show\nit; do not free a seat because a tab closed.\n\n```js\nroom.seated.forEach((s) =>\n mark(s.seat, s.username, s.online ? \"here\" : `away ${Math.round(s.seen / 60)}m`));\n```\n\n## Refusals\n\nA refusal reaches the lobby as `state.refused.reason`:\n\n| `reason` | what happened | what to do |\n| --- | --- | --- |\n| `NO_SEAT` | the seat was lost before the socket opened | call `mb.live.play` again |\n| `OPENED_ELSEWHERE` | the same person opened this room in another window | say so; that window has the seat |\n| `ROOM_UNREACHABLE` | the room closed five times before the match began | offer `mb.live.play` again |\n| `ROOM_CLOSED` | the room has ended | show the result |\n| `BAD_ASK` | the room name or page id was malformed | a bug in the page |\n| `SERVICE_UNAVAILABLE` | live rooms are off on this deployment | say so; the turn-based room still works |\n\nAn `error` on a tick is not a refusal: the socket is open and the room is\ntelling you your file is broken.\n\nAnd `ROOM_FULL` (every seat taken), `ROOM_SIZE` (a room seats 2 to 16, and a\nnew one has to say) and `NOT_INVITED` (a private room its opener keeps to the\npeople they invite).\n\n## Not yet built, said plainly\n\n- `config.watch` and `config.hold` are read by nobody yet. Spectators cannot\n connect; a dropped seat is simply absent from `seats` until it returns.\n- `waiting` reaches every seat on the tick, and the room now tells the platform\n which seats a match has waited on for a minute. The inbox that shows it to\n somebody who is away is being built.\n- A key can be rotated; it has not been.\n\n## A whole page\n\nmindblown adds `window.mindblown` to every page when it publishes it, so the\npage loads nothing for it.\n\n```html\n<canvas id=\"c\" width=\"400\" height=\"300\"></canvas>\n<div id=\"lobby\"></div>\n<button id=\"play\">Play with somebody</button>\n<script>\nconst mb = window.mindblown;\nlet last = null;\n\nmb.live.lobby((state) => {\n const box = document.getElementById(\"lobby\");\n if (state.refused) { box.textContent = state.refused.needsAccount ? \"Sign in to play\" : state.refused.reason; return; }\n const here = state.present.map((p) => p.username || \"seat \" + p.seat).join(\", \");\n box.textContent = here + \" -- waiting for \" + (state.seats - state.present.length) + \". \" + state.link;\n});\n\nmb.live.match((channel) => {\n document.getElementById(\"lobby\").textContent = \"\";\n channel.on(\"tick\", (tick) => { last = tick; });\n let dy = 0;\n addEventListener(\"keydown\", (e) => { dy = e.key === \"ArrowUp\" ? -1 : e.key === \"ArrowDown\" ? 1 : dy; });\n addEventListener(\"keyup\", () => { dy = 0; });\n setInterval(() => channel.send({ dy }), 1000 / 20);\n requestAnimationFrame(function frame() { draw(last); requestAnimationFrame(frame); });\n});\n\ndocument.getElementById(\"play\").onclick = () => mb.live.play({ public: true, seats: 2 });\n</script>\n```\n\nA person who opens the link lands in the room with no click. A person who\npresses the button is put with somebody. Both see the same lobby, and the match\nstarts for both when the second one connects.\n\n## The old wire, until 31 December 2026\n\n`mb.room.look` and `mb.room.act`, and the server operations under them, were\nhow a turn-based match was played before the rules ran on the server. They are\ndeprecated as of 2026-09-15: every answer carries a `Deprecation` header, and\nthey keep answering for the pages already written on them. **Do not write a new\npage against anything in this section** -- the file under \"Turns on live\" does\nall of it, and the room enforces it.\n\n### A turn\n\n```js\nawait mb.room.act(room.name, {\n move: { played: \"ace\" }, // yours. We never read it\n state: { pile: 3 }, // patches what everybody sees\n mine: { note: \"hello\" }, // patches what only you see\n next: 2, // hand the turn to a seat\n hold: 30, // seconds before anybody may take it back\n close: true, // end the room\n});\n```\n\n| Field | Rule |\n| --- | --- |\n| `move` | recorded in the log as sent. Never read by us |\n| `state`, `mine` | **patches**: a key you send replaces that key, a key you leave out is untouched |\n| `next` | where the turn goes. Leave it out and it goes to the next seat. Only the seat holding the turn may direct it |\n| `hold` | seconds the next seat has before anybody seated may act. Reported back as `turn_until`. Leave it out and the turn waits as long as it needs |\n| `close` | ends the room. No further turns |\n\n**Send `hold` whenever you hand the turn to somebody who might not answer.** A\nseat holding the turn is holding the room; if they close the tab, it stops for\neverybody. Past `hold`, taking the turn is the same call as using it, and the\nlog shows who took it.\n\n**`next` is every order but plain rotation** -- a skip, a reversal, a second\nturn, or handing the turn to the person you are trading with so they can\nanswer.\n\n### What we decide\n\nThings a page must not decide for itself, because the other seats could not\ncheck it:\n\n```js\nawait mb.room.act(room.name, {\n roll: { dice: 2, sides: 6, as: \"throw\" }, // we throw them\n deck: { key: \"bank\", items: [...], shuffle: true }, // a supply nobody reads\n deal: { from: \"bank\", to: 3, key: \"hand\", count: 2 },\n give: { to: 1, key: \"hand\", items: [\"ore\"] }, // out of YOUR hand\n take: { from: 2, to: 1, key: \"hand\", count: 1 }, // nobody chooses which\n show: { key: \"hand\", as: \"hand2\" }, // turn YOUR hand face up\n});\n```\n\n| Field | What it does |\n| --- | --- |\n| `roll` | dice. The result lands in `state[as]` as `{ dice, total }`, public |\n| `deck` | loads a supply into the room. It is in no answer to anybody |\n| `deal` | moves the top `count` of a supply into a seat's hidden state |\n| `give` | moves named items out of **your own** hidden state to another seat |\n| `take` | moves `count` items nobody chooses from one seat to another |\n| `show` | copies **your own** hidden key into the public state |\n\n**A key we have written is a key you may not write.** Once `deal`, `give` or\n`take` has touched `hand`, a `mine` patch to `hand` is refused. Keys we never\ntouched stay yours.\n\n**A trade is two gives and a handover.** Give what you offer, `next` to the\nother person, they give back and hand the turn on.\n\n**Hidden dice are a deck, not a roll.** `roll` is public by design. For dice\nonly one seat may see, load a deck of faces and `deal` from it.\n\n#### The showdown\n\n**A reveal is ours or it settles nothing.** A page can print anything; `show`\nis the server publishing the hand it dealt, and the log says so.\n\n```js\nawait mb.room.act(room.name, { show: { key: \"dice\", as: \"dice\" + room.mine } });\n```\n\n- `as` gives every seat its own public key. Without it the hand lands under its\n own name and four seats showing at once overwrite each other.\n- **A bare `show` does not wait for your turn.** It moves nothing, patches\n nothing, and leaves the turn and its deadline where they are. A `show` with\n anything else beside it is an ordinary turn, refused with `NOT_YOUR_TURN`\n when it is not yours.\n- Showing a key you were never dealt is `NOT_HELD`.\n- A show is a copy; the hand stays in `private`.\n\n#### What is guaranteed\n\nOurs, and no page can talk us out of it: who you are (the seat comes from the\nsession), when you may act, what you can see, what a die landed on and how a\ndeck was shuffled, and the order of the log.\n\nYours: whether a legal-looking move is a right move. `state` is public and the\nlog is shared, so every copy of your page can check the table against it.\n\n### Staying up to date\n\n```js\nlet seq = room.seq;\nfor (;;) {\n const now = await mb.room.look(room.name, { since: seq, wait: 25 });\n seq = now.seq;\n now.moves.forEach(apply);\n}\n```\n\n`wait` holds the answer until the room moves -- somebody acts, somebody takes\na seat, the turn changes hands -- or the seconds run out; a quiet room answers\non the deadline with what it has. Thirty is the most we hold. Without `wait`,\npoll every second or two.\n\nThis was the old wire's poll. A match runs on the tick now; see\n[The tick](#the-tick).\n""---\nname: mindblown-multiplayer\ndescription: \"Build multiplayer browser games with Mindblown: live rooms with a lobby, seats, and rules.js running on the server at twenty ticks a second. Use when a game or page has two or more players at once, a shared board, turns, or needs a test seat for a second player. Publish it with the Mindblown skill.\"\n---\n<!-- GENERATED by scripts/build-plugin-skills.py from mindblown-web/src/pod/multiplayer.md. DO NOT EDIT. -->\n\n> Publish with the `mindblown` skill. When the mindblown tools are connected,\n> ask for a test seat with `create_test_seats` (`page_id`, `count`, `seats`):\n> it answers the same JSON as `POST /me/test-seats/<game_id>` below, and you\n> need no token. Without the tools, use that HTTP call with the token the\n> `mindblown` skill's sign-in gave you.\n\n# Playing together on mindblown\n\nA page where more than one person is in the same place at once -- a table, a\nmatch, a shared board, everybody moving at once or one at a time. This is the\npart of the SDK (`references/sdk.md` in the `mindblown` skill) for that, and only that. Everything here is\n`window.mindblown` (`mb` below), the same object the SDK describes.\n\n**Read this whole file before writing the first line of a page that seats two\npeople.** Two things are different from every other page: the rules of your\npage run in a file that runs on the server, and your page draws what the\nserver says rather than what it worked out itself. The rules in here are the\nones the server enforces, and a refusal will name this file.\n\n## Contents\n\n| Section | What it settles |\n| --- | --- |\n| [The shape](#the-shape) | what runs where |\n| [The lobby and the match](#the-lobby-and-the-match) | `play`, `lobby`, `match` -- how people get to the table |\n| [The room is the authority](#the-room-is-the-authority) | where every fact about a room comes from |\n| [Seats](#seats) | numbering, and who goes first |\n| [The invitation](#the-invitation) | the name, the link, `shareUrl` |\n| [Accounts](#accounts) | why a seat needs one, and what your page does about it |\n| [Testing it](#testing-it) | a second person, so you can open a page for two |\n| [`rules.js`](#rulesjs) | the five exports, their arguments, what they may not do |\n| [`mb.live`](#mblive) | the channel: sending, hearing ticks, closing |\n| [The tick](#the-tick) | what every seat receives, twenty times a second |\n| [Turns on live](#turns-on-live) | a turn-based match, whole |\n| [Drawing between ticks](#drawing-between-ticks) | interpolate what the room moves, carry forward what a seat moves |\n| [What we decide](#what-we-decide) | and what is yours |\n| [What we keep](#what-we-keep) | how a round becomes a record, and why `places` |\n| [What happened in a room](#what-happened-in-a-room) | every tick, kept; run your rules against it again |\n| [Who is watching](#who-is-watching) | presence |\n| [Names on screen](#names-on-screen) | `mb.recording.mask`, for names you draw in 3D |\n| [Refusals](#refusals) | every `reason` |\n| [A whole page](#a-whole-page) | the smallest complete example |\n| [The old wire](#the-old-wire-until-31-december-2026) | deprecated, and still answering |\n\n## The shape\n\nYour page ships two things:\n\n- **`rules.js`** at the root of your bundle, served at `https://<your-host>/rules.js`.\n The room fetches it when a room opens and runs it. It has no DOM, no\n network, no clock of its own. It is the rules, and nothing else.\n- **The page**, which draws the lobby and the match, and sends the seat's\n input. It decides nothing about the match, and nothing about how people get\n to it: which room, the join and the start are ours.\n\nThe room runs `step` twenty times a second and sends each seat what `view`\nsays that seat may see. Everybody draws the same match because everybody is\ndrawing the room's answer.\n\n**Send one input per tick, which is twenty a second.** The room keeps the LAST\ninput per seat and clears them as the tick runs, so a second message in the\nsame tick replaces the first and anything beyond one per tick is thrown away by\nconstruction. Thirty a second is what the limiter tolerates, not a rate worth\naiming at: it cannot make the match more responsive, and on a weak connection\nit is the traffic that makes it worse. Send at the tick rate or below, and\npredict locally between ticks.\n\n## The lobby and the match\n\n**We run the room. Your page draws it.** Which room, the join, who is present\nand when the match starts are the platform's. How all of it looks is yours.\n\n```js\nmb.live.lobby((state) => drawLobby(state)); // every change before the start\nmb.live.match((channel) => play(channel)); // once, when every seat is here\nplayButton.onclick = () => mb.live.play({ public: true, seats: 2 });\n```\n\nRegister both on load, every load. Then:\n\n- **A link joins by itself.** When the address carries `?room=`, we join that\n room the moment you register `match`. Do not read the address and do not\n show a start screen first: the person followed a link to somebody, and the\n lobby of that room is what they should see.\n- **A page can open already in its room.** Some places open your page for a\n group of people who are already together, and give all of them one room.\n The address then carries `?room=` from the first load, as a link does. Show\n that room's lobby at once. Do not show a menu for somebody alone: no solo\n start, no public match, no private room to open. Every join from that page\n sits in that room, whatever it asks for.\n- **`play(opts)` is your own button.** `{ public: true, seats: N }` puts them\n with people; `{ seats: N }` opens a room of their own to share. We write the\n room into the address, so a reload is the same seat.\n- **A full room starts.** We call `match` once, when every seat is connected,\n and not before. Never put a Ready button, an invite control or a share\n prompt in front of it -- nobody is missing.\n\n`state` is data, and every pixel of it is yours to draw:\n\n| Field | Meaning |\n| --- | --- |\n| `room` | the room's name |\n| `seats` | how many it holds |\n| `mine` | this person's seat, from 1 |\n| `present` | `[{ seat, username, mine }]` -- who is connected now |\n| `link` | the link to send, already made with `mb.shareUrl` |\n| `refused` | absent, or `{ reason, needsAccount }` -- show it; see [Refusals](#refusals) |\n\nThe channel `match` hands you is described under [`mb.live`](#mblive):\n`channel.room` is the room with everybody named, `channel.seat` is yours.\n\n```js\nleaveButton.onclick = () => mb.live.leave(); // closes the socket, frees the seat\n```\n\n**`leave` is a control, never a lifecycle event.** Closing a tab is going\noffline, not leaving; the match is still theirs. Do not call it on `pagehide`,\n`unload` or a reload.\n\n`mb.room.join` and `mb.live.join` are the calls under these, and they still\nanswer. Do not use them in a new page: every piece of arrival and start logic a\npage wrote around them is what this section replaced.\n\n## The room is the authority\n\n**Every fact about a room comes back from the room, and from nowhere else.**\n\n| What you want to know | Where it comes from |\n| --- | --- |\n| how many seats this table has | `room.seats` |\n| which seat is mine | `room.mine` |\n| whose turn it is | `room.turn` |\n| who else is here | `room.seated` |\n| what the table looks like | `room.state` |\n| what I am holding | `room.private` |\n\nNever from your own address, never from a message another page sent, never\nfrom what somebody typed, never from what your page asked for a moment ago.\nThose are one thing: a value somebody else can set.\n\n```js\nmb.live.match((channel) => layOutTable(channel.room.seats)); // the room's answer\n```\n\n`seats` is read when a room is opened and never again. A later arrival is\nseated at the size the room already has, whatever it asks for.\n\n**Or ask for a room with people in it, and we find one.**\n\n```js\nmb.live.play({ public: true, seats: 4 });\n```\n\n`public` means *put me somewhere with people*: we take the fullest open room of\nyour page that still has a seat, and open one when there is nowhere to sit. When\nyour address carries `?room=` and that room is public with a free seat, it is\nthe one we take -- the person followed a link to somebody, and that is who they\nsit with. `seats` is still the size, and is used only if this ask is the one\nthat opens a room. The answer is the same room you always get, so everything after it is\nunchanged: put `room.name` in a link and you can still invite anybody you like\ninto it.\n\n**A public room is public because you said so when you opened it.** Nobody\npicks one from a list; a person asks and the server seats them. Asking twice\nreturns the room you are in, so a reload is not a second seat.\n\n**Tell an arrival that somebody is waiting.** Before anybody presses your\nbutton, you can ask who is in a public room of your page right now with a seat\nfree:\n\n```js\nconst waiting = await mb.live.waiting();\n// [{ room: \"quiet_lake_41\", seats: 2, present: [{ username: \"ada\", waited_s: 40 }] }]\nif (waiting.length) say(`${waiting[0].present[0].username} is waiting -- play now`);\nplayButton.onclick = () => mb.live.play({ public: true, seats: 2 });\n```\n\nIt is for SHOWING, not for choosing. Join with the same `play({ public: true })`:\nwe seat the person in the first room of that list, which is the fullest. Never\npass one of these names as `room` to steer somebody into it, and never offer\nthe list as a menu -- that is matchmaking of your own, and it spreads people\nacross rooms. A named room is never in the list, the person asking is never in\nit, and an empty list means nobody is waiting; draw nothing then. People wait\nseconds, not minutes, so ask when the page opens and again when your start\nscreen comes back, not on a timer.\n\n**Asking for a room while you are in one gives you that room.** Pressing your\nown Play button twice is one room and not two. A page that wants a SECOND room\ncalls `mb.live.leave()` first, then `play` again.\n\n**A seat in a public room is held while you are there, and is free when you are\nnot.** Close the tab and your seat goes back to the pool within about a minute;\ncome back and you are seated with whoever is playing now, which is what asking\nfor a public room meant. This is the one place a seat behaves differently from\na named room, where the seat stays yours.\n\n**So do not write your own matchmaking, and delete it if you have.** You can\nsee only the room you were handed, never the pool, so the only move available\nto you is to refuse one and open another -- which spreads the people you were\ntrying to meet across more rooms. Checking whether the people in a room are\n\"really there\", keeping a list of rooms that went bad, or leaving and re-asking\nare all answers to a platform defect that is fixed: ask once and play in what\ncomes back.\n\n**A room has a maximum, not a starting line.** `seats` is the most people who\nmay sit down, and the room ticks from the first one: `step` is handed the seats\nconnected right now, so whether anything happens before the rest arrive is your\ndecision and not ours. Say it in two places -- `step` advances the state or\nstands still, and `waiting` names the seats the match cannot go on without. A\npage that cannot begin alone stands still and returns the empty seats from\n`waiting`, which is how the person who is missing is told. A page people drop\ninto plays on and returns `[]`, because nobody is holding anything up.\n\n## Seats\n\n**Seats are numbered from 1.** `mine`, `turn`, `seated[].seat` and `next` all\ncount from 1. There is no seat 0, and a `next` that names one is refused.\n\n**Seat 1 acts first**, from the moment the room opens.\n\n```js\nconst piece = room.mine === 1 ? \"X\" : \"O\"; // never seat === 0\nconst myTurn = room.turn === room.mine; // this, and nothing worked out from the board\n```\n\n`turn` is the one the server enforces. A page that derives whose move it is\nfrom its own board has a second opinion that can disagree, and when it does the\npage locks.\n\n## The invitation\n\n**The link is the invitation.** `state.link` is the room's link, ready to send:\nwhoever opens it joins that room, because a link joins by itself. Show it while\nthe room has an empty seat.\n\n```js\nmb.live.lobby((state) => {\n if (state.present && state.present.length < state.seats) showLink(state.link);\n});\n```\n\n**Send `mb.shareUrl(...)` and never `location.href`.** Your page runs inside a\nframe, and its own address carries a marker of ours. A link built from\n`location.href` opens the page without the frame -- no panel, no sign-in, no\nway into a seat. `shareUrl(extra)` gives the address the page is reachable at,\nwith our marker removed, your query kept, and what you pass set on top; `null`\nfor a key drops it.\n\n**Put the room in the link and nothing else.** Every other value in a link is a\nvalue the person you invited can edit before they open it.\n\nA name you choose yourself is one room everybody on the page shares -- right\nfor a single open table, wrong for anything private.\n\n**Or ask somebody by name, and we draw the card.**\n\n```js\nconst asked = await mb.room.invite(room.name);\nif (asked.sent) show(\"Invitation sent\"); // asked.sent is a NUMBER\nelse show(\"Nobody was asked\"); // asked.closed, the card was dismissed\n```\n\n`mb.room.invite(name)` opens OUR sheet over your page: two lists the person\nalready knows -- who they sat down with, who they follow -- and a field that\ntakes a username. It resolves when the card closes.\n\n**It takes no name and returns none.** You never send us an address, and the\nanswer is a count: `{ sent: 2 }`, or `{ closed: true }` when nothing went out.\nYour page learns that a card was opened and how many invitations left it. There\nis no list of people in this SDK and there will not be one.\n\n**There is no accept step.** Whoever was asked takes a seat, and taking it is\nthe acceptance. Keep the link from `mb.shareUrl` as well: an invitation is one\nway in and a link is the other.\n\n## Accounts\n\n**A guest can join an existing named room.** The default join gate permits\nthis. An invitation-only room refuses a guest. A guest cannot open a named\nroom or enter a resource room.\n\n**A guest uses a pass, not a sign-in.** Their name is `guest_` and four\ncharacters. The browser keeps the same guest for one day. `mb.viewer` still\nreports that the person is not signed in. Their round result is recorded.\n\n**A guest seat lasts while the guest is present.** After 60 seconds without\npresence, another person can take it. A named account seat remains durable.\nAll public seats retain the presence rule above.\n\n**You do not build a sign-in.** An ask to open a named room without an account\nreturns `refused.needsAccount`. The mindblown panel supplies the sign-in.\nAfter sign-in, the person joins as their account. Your page draws the refusal\nuntil that join succeeds.\n\n**They may close the ask.** Then they are looking at your page with no seat --\na real state, and it has to work. Calling `mb.live.play` again brings the ask\nback, so give them one control that means \"play with somebody\".\n\n**Reading stays open.** `look` works signed out: who is here and what everybody\nsees, with no seat and no hidden state.\n\n## Testing it\n\n**A page for two people cannot be tested by one person.** You take seat 1 and\nwait, and nothing happens, because nothing can. So ask for a **test seat**: a\nsecond person who exists for half an hour, in one room of one page.\n\n**Do not say a page for two works until two people have met in it.** Running\n`rules.js` on your own machine proves the rules and nothing else: not the\narrival from a link, not the join, not the start. Open two seat links, and the\npage works when both of them see every seat connected.\n\nTwo doors, and the page must be the asker's own either way.\n\n```\nPOST https://api.mindblown.ai/me/test-seats/<game_id> you hold their session\nAuthorization: Bearer <the owner's access token>\n\nPOST https://api.mindblown.ai/internal/test-seats/<game_id> you are their pod\nAuthorization: Bearer <the service token>\nX-Mindblown-Subject: <the subject token naming the person>\n\n{ \"count\": 1, \"seats\": 2 } the pod's body also names \"channel\", \"channel_id\"\n```\n\n`channel` and `channel_id` must name an address that has written to the bot.\nA lookup never creates a person.\n\nThe answer is one entry per test person:\n\n```json\n{\n \"room\": \"quiet-lake-41\",\n \"ttl_seconds\": 1800,\n \"seats\": [{\n \"account_id\": \"...\", \"username\": \"test_9f2c1ab7\", \"name\": \"Tester 1\",\n \"seat\": 2, \"room\": \"quiet-lake-41\", \"pass\": \"...\",\n \"url\": \"https://mindblown.ai/...?mb_seat=<pass>&room=quiet-lake-41\",\n \"expires_at\": \"...\"\n }]\n}\n```\n\n**Open the `url` in a second window and that window is the second person.** It\njoins, it is seen, it plays, it leaves. Your page needs no change and cannot\ntell the difference: a test seat comes through the same door as anybody else.\nPut the OWNER in the same `room` -- open your own page with `?room=<room>` --\nand the two of them are at one table.\n\n**What a test seat is.** A real account with a name and a seat, so everything\nyour page reads about a person is really there, `mb.viewer` included: it reads\nas signed in. It holds a `pass`, not a session -- all that can do is take a\nticket for its own seat.\n\n**What it is not.** It never appears in the people anybody played with, it is\nnever counted as somebody playing now, it has no inbox, and a match in a room\nthat held one is not written down. So a page is not made to look busy by being\ntested, and a test does not end up in anybody's history.\n\n**So a test seat cannot prove the RECORD** -- everything up to the end of a\nmatch, but not what we wrote down. Check that with real accounts.\n\n**Half an hour, then it is gone.** The account is deleted and its seat with it.\nAsk again if you need longer; there is no way to extend one.\n\n**Count what you need.** `count` is how many test people, `seats` is how big the\nroom is -- leave `seats` out and the room is built for your test people plus\nyou. Up to fifteen test seats, because a room holds sixteen.\n\n**It is for testing and it is not a player.** Do not use test seats to make a\npage look played, to fill a room a real person is waiting in, or to leave a\npage running. Nothing stops you; the numbers simply will not move, because\nnone of them counts a test seat.\n\n## `rules.js`\n\nAn ES module with five exports. Nothing about where it runs.\n\n```js\nexport const config = { seats: 2, watch: true, hold: 60 };\nexport function init(seats, rng, context) { return state; }\nexport function step(state, inputs, dt, rng, seats) { return state; }\nexport function view(state, seat) { return whatThisSeatSees; }\nexport function waiting(state, seats) { return []; } // seats the match cannot go on without\nexport function done(state) { return null; } // or how it ended\n```\n\n| Export | Called | Arguments | Returns |\n| --- | --- | --- | --- |\n| `config` | read by your page | -- | `{ seats, watch, hold }`. **Not yet enforced by the room**: the size is what `mb.room.join` opened the room with, and `watch` and `hold` are on the list below |\n| `init` | before the first step without saved state | `seats`: connected seat numbers; `rng`; frozen `context` | the starting state |\n| `step` | every tick | `state`; `inputs`: `{ [seat]: latestInput }` for seats that sent one this tick; `dt` in ms; `rng`; `seats` connected now | the next state, or nothing to mean \"I changed it in place\" |\n| `view` | every tick, once per seat | `state`, `seat` | what that seat receives. JSON. |\n| `waiting` | every tick, optional | `state`, `seats` connected | the seats the match cannot go on without. A turn-based file answers `[state.turn]`; a match that is simply on answers `[]`. It is how the platform knows to say \"waiting for you\" |\n| `done` | every tick | `state` | falsy while the match is on; anything else is how it ended, sent to every seat and **written down**. See [What we keep](#what-we-keep) |\n\n**Time and randomness come in as arguments.** `rng()` gives a number in\n`[0, 1)` from a generator the room seeded; `dt` is ours. Use them. During a\ntick the room also answers `Math.random()` from the same generator and\n`Date.now()` with the tick's time, so a file that reads them can still be run\nagain from the room's log (see [What happened in a room](#what-happened-in-a-room)).\n\n**`view` is where hidden information lives.** A hand, fog of war, a bluff:\nreturn to seat 3 only what seat 3 may know. The room sends nothing a `view`\ndid not return. Everything in `state` that `view` returns to everybody is\npublic.\n\n**Keep it small and keep it pure.** Under 512KB. No imports of anything but\nyour own code. A `step` that throws is reported to every seat on every tick\nas `error`, in your own words, and the match stands still until it stops.\n\n**`seats` is the list of seats connected right now**, in no particular\norder. A seat whose socket dropped is absent from it and sends no input; a\nseat that comes back is present again. `init` receives the seats present at\nthe first tick, which may be one -- a match that needs two waits in `step`\nuntil `seats.length` says so.\n\n**What `done` returns is recorded, and two shapes name a winner.** The room\nreports the end once and the platform keeps it -- the seats, the accounts\nbehind them, how long it ran. Say `{ winner: 2 }` or `{ places: [2, 1, 3] }`\nand the platform knows who finished where; add `{ scores: { \"2\": 40 } }` and it\nknows what each seat scored. Anything else is kept whole and names nobody,\nwhich is right for a match that has no winner. This is an offer and not a\nrequirement: the shape is yours, and these three are the parts we can read.\n\n## `mb.live`\n\n```js\nmb.live.match((channel) => { // see The lobby and the match\n channel.send(input); // this seat's latest input\n const off = channel.on(\"tick\", (tick) => draw(tick));\n channel.on(\"close\", ({ code, reason }) => ...);\n});\n```\n\nA `Channel`:\n\n| Field | Meaning |\n| --- | --- |\n| `seat` | your seat number, from 1 |\n| `room` | the room: `name`, `seats`, `seated` with everybody named |\n| `send(input)` | your input. Any JSON. Last write per tick wins, so one a tick is all a room can use -- see above |\n| `on(event, fn)` | `\"tick\"` and `\"close\"`. Returns a function that unsubscribes |\n| `close()` | closes the socket. The seat is still yours; `mb.live.leave()` gives it back |\n\n**The page never holds the socket.** The mindblown panel on your page opens\nit, with a ticket your page never sees, and relays what the room says. Your\npage speaks to `channel` and nothing else.\n\n### Immutable resources\n\nCreate a room from published documents when its rules need a fixed layout.\nEach resource belongs to this page and remains unchanged during the attempt.\n\n```js\nconst requestId = crypto.randomUUID();\nconst channel = await mb.live.create({\n seats: 2,\n resources: { vault: published.id },\n requestId,\n});\nchannel.on(\"tick\", (tick) => draw(tick));\n```\n\nCreation requires a real account and one through four published documents.\nTheir combined JSON must fit within 64 KiB.\nThe channel has the same methods as the one `match` hands you.\nKeep `requestId` for an explicit retry after a lost response.\nRetry the same request; different content produces `IDEMPOTENCY_CONFLICT`.\nAn identical retry returns the original room without another seat replacement.\nA superseded attempt can refuse its ticket with `NO_SEAT`.\nThe error preserves `requestId` and any room receipt already received.\n\nThe rules receive a frozen context before initialization:\n\n```js\nexport function init(seats, rng, context) {\n const vault = context.resources.vault;\n return { cells: [...vault.value.cells], moves: 0 };\n}\n```\n\nEach resource contains `id`, `kind`, `schemaVersion`, `hash`, and `value`.\nOrdinary rooms receive `{resources:{}}`.\nSaved state survives runtime recovery; recovery does not call `init` again.\nThe context stays inside the rules unless `view` returns part of it.\n\nA resource room permits one completed attempt.\nIts first `done` stops simulation and becomes the final result.\nReconnects receive the same final view and result.\nCreate another room for another attempt.\nOwner removal lets an already-started attempt finish.\nPlatform removal can stop an attempt.\nTemporary authorization failures pause simulation until access returns.\n\n## The tick\n\nEvery seat receives, twenty times a second:\n\n```js\n{\n t: 1789371672003, // the room's clock, ms\n tick: 4812, // counts up from 1\n dt: 50, // ms since the previous tick\n seats: [1, 2], // who is connected right now\n waiting: [2], // who the match cannot go on without, from waiting()\n view: { ... }, // what view(state, mySeat) returned\n done: null, // or what done(state) returned\n error: \"...\" // only when rules.js threw or could not be fetched\n}\n```\n\nWhen `error` is present there is no `view`. Show the sentence; it is yours.\n\nA late joiner's first tick is a whole view, so there is nothing to catch up\non. A room with sockets open and no input for three seconds stops ticking\nuntil the next input; your page sees the gap as a pause, not as a fault.\n\n**One socket per seat, and the newest window holds it.** A seat is a person and\njoining twice is the same seat, so a second tab, a reload that raced its own\nclose and a reconnect after a drop all ask for a seat that already has a socket.\nThe room hands it to the newest and tells the old one `{ closed: \"elsewhere\" }`\nbefore closing it. Two consequences worth designing for: `seats` never carries\nthe same number twice, so `seats.length` is a count of PEOPLE; and one person\ncannot fill two seats by opening two windows -- to test a match with two\nplayers you need two accounts.\n\n**The view is the budget, and it is the only one you are likely to reach.** The\nroom sends one to every seat twenty times a second, so a view is paid for\nsixteen hundred times a minute at eight seats. Two measurements, both from real\npages: a page that returns the whole board with the parts a seat cannot see set\nto `null` sent 5.9KB per seat per tick -- about 1MB a second at eight seats --\nand a page that culled by line of sight inside `view` sent 333 bytes, which is\n52KB a second. The difference is twenty-fold.\n\n**So cull in `view`, not in the page.** It already exists to decide what one\nseat may see, which makes it the only place that can leave something out\nhonestly -- a page that filters what it draws still downloaded the rest, and a\npage that could read it could cheat with it. If a visibility test is expensive,\nwork it out once per tick rather than once per pair. One page's went from 56\ntraces to 28, and the cost of a whole tick's views fell from 0.615ms to\n0.015ms.\n\n**What the room itself can carry is not the constraint you will meet first.**\nMeasured against the live room, with a page that culls inside `view`:\n\n| seats | inputs each | ticks a second | gap, typical | gap, worst |\n| --- | --- | --- | --- | --- |\n| 2 | 15 | 19.8 | 49ms | 68ms |\n| 8 | 15 | 20.1 | 50ms | 81ms |\n| 8 | 30 | 20.0 | 49ms | 81ms |\n| 16 | 30 | 20.0 | 50ms | 102ms |\n| 16 | 60 | 20.0 | 50ms | 83ms |\n\nEvery message on the wire in those runs was around 464 bytes, envelope and all,\nand it did not grow when the input rate doubled. The last row is twice the rate\nany page should send, at the seat cap, and it is 960 messages a second into one\nroom -- the ceiling the platform gives us -- with the clock still at 50ms. Rerun\nit yourself with `mindblown-live/probe.mjs`; a number in a document nobody can\nreproduce goes stale without anybody noticing.\n\n## Turns on live\n\nA turn-based match is a rules file whose `step` listens to one seat. There is\nno other machinery: `state.turn` is a number your `step` moves along, hidden\nhands are what `view` keeps back, and `waiting` names the seat whose move it\nis so the platform can say so. The room enforces all of it -- nobody moves out\nof turn, nobody moves twice, nobody sees a hand that is not theirs -- because\nthe room is the only thing running the file.\n\nNoughts and crosses, whole:\n\n```js\nexport const config = { seats: 2, watch: true, hold: 60 };\nexport function init(seats, rng) { return { cells: Array(9).fill(0), turn: 1, moves: 0 }; }\nconst LINES = [[0,1,2],[3,4,5],[6,7,8],[0,3,6],[1,4,7],[2,5,8],[0,4,8],[2,4,6]];\nconst winner = (c) => LINES.map(([a, b, d]) => c[a] && c[a] === c[b] && c[a] === c[d] ? c[a] : 0).find(Boolean) || 0;\nexport function step(state, inputs, dt, rng, seats) {\n if (winner(state.cells) || state.moves === 9) return state;\n const input = inputs[state.turn]; // one seat is heard\n if (!input || !Number.isInteger(input.cell) || input.cell < 0 || input.cell > 8) return state;\n if (state.cells[input.cell]) return state;\n state.cells[input.cell] = state.turn;\n state.moves++;\n state.turn = state.turn === 1 ? 2 : 1;\n return state;\n}\nexport function view(state, seat) { return { me: seat, cells: state.cells, turn: state.turn }; }\nexport function waiting(state) { return winner(state.cells) || state.moves === 9 ? [] : [state.turn]; }\nexport function done(state) {\n const w = winner(state.cells);\n return w ? { winner: w } : state.moves === 9 ? { draw: true } : null;\n}\n```\n\nA card table is the same shape with a `deck` array in `state`, shuffled with\n`rng` in `init`, dealt by `step`, and hidden by `view` -- return to seat 3 only\n`state.hands[3]`. A trade is `step` moving items between two hands when the\nseat holding the turn asks. A showdown is `view` no longer hiding. A deadline\nis a number in `state` compared with the tick's clock.\n\nThe page for a turn-based match sends its move once, on the tap, and draws on the\ntick. It does not need thirty inputs a second.\n\n**The old turn-based wire** -- `mb.room.look` and `mb.room.act` with `next`,\n`hold`, `deck`, `deal`, `give`, `take`, `show` -- is deprecated as of\n2026-09-15. It keeps answering for the pages already on it until 31 December\n2026 and is written down at the end of this file. New pages write the file\nabove.\n\n## Drawing between ticks\n\nTwenty ticks a second is not sixty frames. Draw at your frame rate. A page\nthat draws only on ticks looks like twenty frames a second, because it is.\nHow to draw between ticks depends on who moves the thing.\n\n**What the room moves, interpolate.** A board, a score, anything `step`\nmoves: keep the previous view and its `t`, and draw a point `dt`-proportional\nbetween the last two. It is drawn about one tick late, and that is right,\nbecause the room is the authority on it.\n\n**What another seat moves, draw where it is NOW, not where it was.** When each\nseat sends its own position -- a vehicle, a body, a pointer -- the newest\nsample you hold for another seat is already old when it arrives. Every frame,\ncarry it forward: its position plus its velocity times the sample's age, with\nits turn rate if you send one. When the next sample arrives, blend toward it\nover about 100ms rather than jumping. Stop carrying it forward after about\n250ms without a sample, and hold it where it is, so a dropped seat does not\ndrive off on its own. Send each sample with its time on the room's clock (the\nlast tick's `t` plus the time since that tick), so the other page knows how\nold it is.\n\n**Do not hold samples back to smooth them.** A buffer that draws other seats\nsome milliseconds in the past adds all of those milliseconds to what a person\nsees, however fast the delivery under it is.\n\n**Prediction is optional and it is the same file.** A page may `import`\n`/rules.js` itself and run `step` locally with its own input to draw ahead\nof the next tick, then snap to the view when it arrives. Start without it.\n\n## What we decide\n\nOurs, and no page can change it:\n\n- **who you are** -- the seat comes from a ticket the API signed, never from\n a message,\n- **what you can see** -- `view` is applied by the room, never by a page,\n- **the clock** -- `dt` is ours; nobody runs their own faster,\n- **the dice** -- `rng` is ours and seeded,\n- **which rules** -- the file at your host when the room opened. A republish\n never changes a match in progress; the next room gets the new file.\n\nYours: whether `step` is right. We do not know your rules. What we know is\nthat every seat is running the same ones.\n\n## What we keep\n\nThese rematch rules apply to ordinary rooms. Resource rooms permit one completed attempt.\n\n**`done` is how a round is written down, and a room that never ends keeps\nnothing.** When `done` returns something, the room reports it and we record the\nmatch: who held each seat, how long it ran, and the result whole. When `done`\ngoes falsy again the room has started another one, it gets the next sequence,\nand that one is recorded too. **So a page with rounds should end each round\nthrough `done` and let the next begin — not return `null` for ever.**\n\n```js\nexport function done(state) {\n // the ROUND ended, and the room plays on\n if (state.round.over) return { places: state.round.order };\n return null;\n}\n```\n\nA file that answers `null` for ever is a file we hold no history for. Measured\n2026-09-18: a page that had been played for hours in dozens of rounds had\n**zero** records, because its `done` said `return null; // the room never ends`.\n\n**Return `places`, not only `scores`.** `places` is the finishing order —\n`[3, 1, 2]` means seat 3 came first — and it is the one thing that means the\nsame in every page. A score is yours: two hundred is a triumph in one page and\na rout in another, so nothing can compare it with anything. Both are kept, and\n`winner` is accepted as shorthand for a single first place:\n\n| in the result | what we do with it |\n| --- | --- |\n| `places: [3, 1, 2]` | seat 3 first, seat 1 second, seat 2 third |\n| `winner: 2` | seat 2 first, everybody else unplaced |\n| `scores: { \"1\": 240, \"2\": 90 }` | kept per seat, compared with nothing |\n\nAnything else stays in the raw result and names no winner.\nRead your account's recorded outcomes with `mb.matches.list({limit:20})`.\nThe reply contains `{items,nextCursor}`.\nEach item includes your outcomes, match times, room, sequence, and available resource provenance.\nPass `nextCursor` to read the next page.\nOnly exact account bindings appear. Test rooms and inferred participation remain excluded.\nOther accounts' outcomes and raw result JSON remain private.\n\n## What happened in a room\n\n**A room writes down every tick as your rules saw it**: the tick's number, its\ntime, `dt`, the seats in order, every seat's input, and a digest of every view.\nWith the room's seed and the state where the log starts, that is enough to run\nthe room again, tick for tick. A room keeps the last hour.\n\n\nOnly the page's owner reads a room's log (`GET /me/rooms/{page_id}/{room}/log`).\nIt holds nothing your rules file was not already given.\n\n## Who is watching\n\nEach seat carries `online` and `seen` -- seconds since that person's page last\nasked about the room. Presence is the heartbeat of `look`; nothing to send.\n\n**Presence is not membership.** Somebody offline still holds their seat. Show\nit; do not free a seat because a tab closed.\n\n```js\nroom.seated.forEach((s) =>\n mark(s.seat, s.username, s.online ? \"here\" : `away ${Math.round(s.seen / 60)}m`));\n```\n\n## Names on screen\n\nA few views of a page are recorded, so its maker can watch how it is used.\nPeople's names and what they send each other never reach a recording: we black\nthem out wherever we can see them drawn -- HTML text, and `fillText` on a\ncanvas in the page.\n\nText you draw in 3D -- a name over a car, a chat line on a texture, a font\natlas -- we cannot see. A room page with a 3D canvas is then not recorded at\nall, unless you say where the names are:\n\n```js\nmb.recording.mask((canvas) => {\n // CSS pixels of this canvas; [] when no name is on screen.\n return labels.filter((l) => l.visible).map((l) => ({ x: l.x - 4, y: l.y - 4, width: l.w + 8, height: l.h + 8 }));\n});\n```\n\nReturn every rectangle a name or a message covers this frame. Return `null`\nto leave a frame out. Calling it costs nothing when the view is not recorded.\n\n## Refusals\n\nA refusal reaches the lobby as `state.refused.reason`:\n\n| `reason` | what happened | what to do |\n| --- | --- | --- |\n| `NO_SEAT` | the seat was lost before the socket opened | call `mb.live.play` again |\n| `OPENED_ELSEWHERE` | the same person opened this room in another window | say so; that window has the seat |\n| `ROOM_UNREACHABLE` | the room closed five times before the match began | offer `mb.live.play` again |\n| `ROOM_CLOSED` | the room has ended | show the result |\n| `BAD_ASK` | the room name or page id was malformed | a bug in the page |\n| `SERVICE_UNAVAILABLE` | live rooms are off on this deployment | say so; the turn-based room still works |\n\nAn `error` on a tick is not a refusal: the socket is open and the room is\ntelling you your file is broken.\n\nAnd `ROOM_FULL` (every seat taken), `ROOM_SIZE` (a room seats 2 to 16, and a\nnew one has to say) and `NOT_INVITED` (a private room its opener keeps to the\npeople they invite).\n\n## Not yet built, said plainly\n\n- `config.watch` and `config.hold` are read by nobody yet. Spectators cannot\n connect; a dropped seat is simply absent from `seats` until it returns.\n- `waiting` reaches every seat on the tick, and the room now tells the platform\n which seats a match has waited on for a minute. The inbox that shows it to\n somebody who is away is being built.\n- A key can be rotated; it has not been.\n\n## A whole page\n\nmindblown adds `window.mindblown` to every page when it publishes it, so the\npage loads nothing for it.\n\n```html\n<canvas id=\"c\" width=\"400\" height=\"300\"></canvas>\n<div id=\"lobby\"></div>\n<button id=\"play\">Play with somebody</button>\n<script>\nconst mb = window.mindblown;\nlet last = null;\n\nmb.live.lobby((state) => {\n const box = document.getElementById(\"lobby\");\n if (state.refused) { box.textContent = state.refused.needsAccount ? \"Sign in to play\" : state.refused.reason; return; }\n const here = state.present.map((p) => p.username || \"seat \" + p.seat).join(\", \");\n box.textContent = here + \" -- waiting for \" + (state.seats - state.present.length) + \". \" + state.link;\n});\n\nmb.live.match((channel) => {\n document.getElementById(\"lobby\").textContent = \"\";\n channel.on(\"tick\", (tick) => { last = tick; });\n let dy = 0;\n addEventListener(\"keydown\", (e) => { dy = e.key === \"ArrowUp\" ? -1 : e.key === \"ArrowDown\" ? 1 : dy; });\n addEventListener(\"keyup\", () => { dy = 0; });\n setInterval(() => channel.send({ dy }), 1000 / 20);\n requestAnimationFrame(function frame() { draw(last); requestAnimationFrame(frame); });\n});\n\ndocument.getElementById(\"play\").onclick = () => mb.live.play({ public: true, seats: 2 });\n</script>\n```\n\nA person who opens the link lands in the room with no click. A person who\npresses the button is put with somebody. Both see the same lobby, and the match\nstarts for both when the second one connects.\n\n## The old wire, until 31 December 2026\n\n`mb.room.look` and `mb.room.act`, and the server operations under them, were\nhow a turn-based match was played before the rules ran on the server. They are\ndeprecated as of 2026-09-15: every answer carries a `Deprecation` header, and\nthey keep answering for the pages already written on them. **Do not write a new\npage against anything in this section** -- the file under \"Turns on live\" does\nall of it, and the room enforces it.\n\n### A turn\n\n```js\nawait mb.room.act(room.name, {\n move: { played: \"ace\" }, // yours. We never read it\n state: { pile: 3 }, // patches what everybody sees\n mine: { note: \"hello\" }, // patches what only you see\n next: 2, // hand the turn to a seat\n hold: 30, // seconds before anybody may take it back\n close: true, // end the room\n});\n```\n\n| Field | Rule |\n| --- | --- |\n| `move` | recorded in the log as sent. Never read by us |\n| `state`, `mine` | **patches**: a key you send replaces that key, a key you leave out is untouched |\n| `next` | where the turn goes. Leave it out and it goes to the next seat. Only the seat holding the turn may direct it |\n| `hold` | seconds the next seat has before anybody seated may act. Reported back as `turn_until`. Leave it out and the turn waits as long as it needs |\n| `close` | ends the room. No further turns |\n\n**Send `hold` whenever you hand the turn to somebody who might not answer.** A\nseat holding the turn is holding the room; if they close the tab, it stops for\neverybody. Past `hold`, taking the turn is the same call as using it, and the\nlog shows who took it.\n\n**`next` is every order but plain rotation** -- a skip, a reversal, a second\nturn, or handing the turn to the person you are trading with so they can\nanswer.\n\n### What we decide\n\nThings a page must not decide for itself, because the other seats could not\ncheck it:\n\n```js\nawait mb.room.act(room.name, {\n roll: { dice: 2, sides: 6, as: \"throw\" }, // we throw them\n deck: { key: \"bank\", items: [...], shuffle: true }, // a supply nobody reads\n deal: { from: \"bank\", to: 3, key: \"hand\", count: 2 },\n give: { to: 1, key: \"hand\", items: [\"ore\"] }, // out of YOUR hand\n take: { from: 2, to: 1, key: \"hand\", count: 1 }, // nobody chooses which\n show: { key: \"hand\", as: \"hand2\" }, // turn YOUR hand face up\n});\n```\n\n| Field | What it does |\n| --- | --- |\n| `roll` | dice. The result lands in `state[as]` as `{ dice, total }`, public |\n| `deck` | loads a supply into the room. It is in no answer to anybody |\n| `deal` | moves the top `count` of a supply into a seat's hidden state |\n| `give` | moves named items out of **your own** hidden state to another seat |\n| `take` | moves `count` items nobody chooses from one seat to another |\n| `show` | copies **your own** hidden key into the public state |\n\n**A key we have written is a key you may not write.** Once `deal`, `give` or\n`take` has touched `hand`, a `mine` patch to `hand` is refused. Keys we never\ntouched stay yours.\n\n**A trade is two gives and a handover.** Give what you offer, `next` to the\nother person, they give back and hand the turn on.\n\n**Hidden dice are a deck, not a roll.** `roll` is public by design. For dice\nonly one seat may see, load a deck of faces and `deal` from it.\n\n#### The showdown\n\n**A reveal is ours or it settles nothing.** A page can print anything; `show`\nis the server publishing the hand it dealt, and the log says so.\n\n```js\nawait mb.room.act(room.name, { show: { key: \"dice\", as: \"dice\" + room.mine } });\n```\n\n- `as` gives every seat its own public key. Without it the hand lands under its\n own name and four seats showing at once overwrite each other.\n- **A bare `show` does not wait for your turn.** It moves nothing, patches\n nothing, and leaves the turn and its deadline where they are. A `show` with\n anything else beside it is an ordinary turn, refused with `NOT_YOUR_TURN`\n when it is not yours.\n- Showing a key you were never dealt is `NOT_HELD`.\n- A show is a copy; the hand stays in `private`.\n\n#### What is guaranteed\n\nOurs, and no page can talk us out of it: who you are (the seat comes from the\nsession), when you may act, what you can see, what a die landed on and how a\ndeck was shuffled, and the order of the log.\n\nYours: whether a legal-looking move is a right move. `state` is public and the\nlog is shared, so every copy of your page can check the table against it.\n\n### Staying up to date\n\n```js\nlet seq = room.seq;\nfor (;;) {\n const now = await mb.room.look(room.name, { since: seq, wait: 25 });\n seq = now.seq;\n now.moves.forEach(apply);\n}\n```\n\n`wait` holds the answer until the room moves -- somebody acts, somebody takes\na seat, the turn changes hands -- or the seconds run out; a quiet room answers\non the deadline with what it has. Thirty is the most we hold. Without `wait`,\npoll every second or two.\n\nThis was the old wire's poll. A match runs on the tick now; see\n[The tick](#the-tick).\n"Full snapshot data
{
"description": "Build multiplayer browser games with Mindblown: live rooms with a lobby, seats, and rules.js running on the server at twenty ticks a second. Use when a game or page has two or more players at once, a shared board, turns, or needs a test seat for a second player. Publish it with the Mindblown skill.",
"included_files": [],
"name": "mindblown-multiplayer",
"skill_md_contents": "---\nname: mindblown-multiplayer\ndescription: \"Build multiplayer browser games with Mindblown: live rooms with a lobby, seats, and rules.js running on the server at twenty ticks a second. Use when a game or page has two or more players at once, a shared board, turns, or needs a test seat for a second player. Publish it with the Mindblown skill.\"\n---\n<!-- GENERATED by scripts/build-plugin-skills.py from mindblown-web/src/pod/multiplayer.md. DO NOT EDIT. -->\n\n> Publish with the `mindblown` skill. When the mindblown tools are connected,\n> ask for a test seat with `create_test_seats` (`page_id`, `count`, `seats`):\n> it answers the same JSON as `POST /me/test-seats/<game_id>` below, and you\n> need no token. Without the tools, use that HTTP call with the token the\n> `mindblown` skill's sign-in gave you.\n\n# Playing together on mindblown\n\nA page where more than one person is in the same place at once -- a table, a\nmatch, a shared board, everybody moving at once or one at a time. This is the\npart of the SDK (`references/sdk.md` in the `mindblown` skill) for that, and only that. Everything here is\n`window.mindblown` (`mb` below), the same object the SDK describes.\n\n**Read this whole file before writing the first line of a page that seats two\npeople.** Two things are different from every other page: the rules of your\npage run in a file that runs on the server, and your page draws what the\nserver says rather than what it worked out itself. The rules in here are the\nones the server enforces, and a refusal will name this file.\n\n## Contents\n\n| Section | What it settles |\n| --- | --- |\n| [The shape](#the-shape) | what runs where |\n| [The lobby and the match](#the-lobby-and-the-match) | `play`, `lobby`, `match` -- how people get to the table |\n| [The room is the authority](#the-room-is-the-authority) | where every fact about a room comes from |\n| [Seats](#seats) | numbering, and who goes first |\n| [The invitation](#the-invitation) | the name, the link, `shareUrl` |\n| [Accounts](#accounts) | why a seat needs one, and what your page does about it |\n| [Testing it](#testing-it) | a second person, so you can open a page for two |\n| [`rules.js`](#rulesjs) | the five exports, their arguments, what they may not do |\n| [`mb.live`](#mblive) | the channel: sending, hearing ticks, closing |\n| [The tick](#the-tick) | what every seat receives, twenty times a second |\n| [Turns on live](#turns-on-live) | a turn-based match, whole |\n| [Drawing between ticks](#drawing-between-ticks) | interpolate what the room moves, carry forward what a seat moves |\n| [What we decide](#what-we-decide) | and what is yours |\n| [What we keep](#what-we-keep) | how a round becomes a record, and why `places` |\n| [What happened in a room](#what-happened-in-a-room) | every tick, kept; run your rules against it again |\n| [Who is watching](#who-is-watching) | presence |\n| [Names on screen](#names-on-screen) | `mb.recording.mask`, for names you draw in 3D |\n| [Refusals](#refusals) | every `reason` |\n| [A whole page](#a-whole-page) | the smallest complete example |\n| [The old wire](#the-old-wire-until-31-december-2026) | deprecated, and still answering |\n\n## The shape\n\nYour page ships two things:\n\n- **`rules.js`** at the root of your bundle, served at `https://<your-host>/rules.js`.\n The room fetches it when a room opens and runs it. It has no DOM, no\n network, no clock of its own. It is the rules, and nothing else.\n- **The page**, which draws the lobby and the match, and sends the seat's\n input. It decides nothing about the match, and nothing about how people get\n to it: which room, the join and the start are ours.\n\nThe room runs `step` twenty times a second and sends each seat what `view`\nsays that seat may see. Everybody draws the same match because everybody is\ndrawing the room's answer.\n\n**Send one input per tick, which is twenty a second.** The room keeps the LAST\ninput per seat and clears them as the tick runs, so a second message in the\nsame tick replaces the first and anything beyond one per tick is thrown away by\nconstruction. Thirty a second is what the limiter tolerates, not a rate worth\naiming at: it cannot make the match more responsive, and on a weak connection\nit is the traffic that makes it worse. Send at the tick rate or below, and\npredict locally between ticks.\n\n## The lobby and the match\n\n**We run the room. Your page draws it.** Which room, the join, who is present\nand when the match starts are the platform's. How all of it looks is yours.\n\n```js\nmb.live.lobby((state) => drawLobby(state)); // every change before the start\nmb.live.match((channel) => play(channel)); // once, when every seat is here\nplayButton.onclick = () => mb.live.play({ public: true, seats: 2 });\n```\n\nRegister both on load, every load. Then:\n\n- **A link joins by itself.** When the address carries `?room=`, we join that\n room the moment you register `match`. Do not read the address and do not\n show a start screen first: the person followed a link to somebody, and the\n lobby of that room is what they should see.\n- **A page can open already in its room.** Some places open your page for a\n group of people who are already together, and give all of them one room.\n The address then carries `?room=` from the first load, as a link does. Show\n that room's lobby at once. Do not show a menu for somebody alone: no solo\n start, no public match, no private room to open. Every join from that page\n sits in that room, whatever it asks for.\n- **`play(opts)` is your own button.** `{ public: true, seats: N }` puts them\n with people; `{ seats: N }` opens a room of their own to share. We write the\n room into the address, so a reload is the same seat.\n- **A full room starts.** We call `match` once, when every seat is connected,\n and not before. Never put a Ready button, an invite control or a share\n prompt in front of it -- nobody is missing.\n\n`state` is data, and every pixel of it is yours to draw:\n\n| Field | Meaning |\n| --- | --- |\n| `room` | the room's name |\n| `seats` | how many it holds |\n| `mine` | this person's seat, from 1 |\n| `present` | `[{ seat, username, mine }]` -- who is connected now |\n| `link` | the link to send, already made with `mb.shareUrl` |\n| `refused` | absent, or `{ reason, needsAccount }` -- show it; see [Refusals](#refusals) |\n\nThe channel `match` hands you is described under [`mb.live`](#mblive):\n`channel.room` is the room with everybody named, `channel.seat` is yours.\n\n```js\nleaveButton.onclick = () => mb.live.leave(); // closes the socket, frees the seat\n```\n\n**`leave` is a control, never a lifecycle event.** Closing a tab is going\noffline, not leaving; the match is still theirs. Do not call it on `pagehide`,\n`unload` or a reload.\n\n`mb.room.join` and `mb.live.join` are the calls under these, and they still\nanswer. Do not use them in a new page: every piece of arrival and start logic a\npage wrote around them is what this section replaced.\n\n## The room is the authority\n\n**Every fact about a room comes back from the room, and from nowhere else.**\n\n| What you want to know | Where it comes from |\n| --- | --- |\n| how many seats this table has | `room.seats` |\n| which seat is mine | `room.mine` |\n| whose turn it is | `room.turn` |\n| who else is here | `room.seated` |\n| what the table looks like | `room.state` |\n| what I am holding | `room.private` |\n\nNever from your own address, never from a message another page sent, never\nfrom what somebody typed, never from what your page asked for a moment ago.\nThose are one thing: a value somebody else can set.\n\n```js\nmb.live.match((channel) => layOutTable(channel.room.seats)); // the room's answer\n```\n\n`seats` is read when a room is opened and never again. A later arrival is\nseated at the size the room already has, whatever it asks for.\n\n**Or ask for a room with people in it, and we find one.**\n\n```js\nmb.live.play({ public: true, seats: 4 });\n```\n\n`public` means *put me somewhere with people*: we take the fullest open room of\nyour page that still has a seat, and open one when there is nowhere to sit. When\nyour address carries `?room=` and that room is public with a free seat, it is\nthe one we take -- the person followed a link to somebody, and that is who they\nsit with. `seats` is still the size, and is used only if this ask is the one\nthat opens a room. The answer is the same room you always get, so everything after it is\nunchanged: put `room.name` in a link and you can still invite anybody you like\ninto it.\n\n**A public room is public because you said so when you opened it.** Nobody\npicks one from a list; a person asks and the server seats them. Asking twice\nreturns the room you are in, so a reload is not a second seat.\n\n**Tell an arrival that somebody is waiting.** Before anybody presses your\nbutton, you can ask who is in a public room of your page right now with a seat\nfree:\n\n```js\nconst waiting = await mb.live.waiting();\n// [{ room: \"quiet_lake_41\", seats: 2, present: [{ username: \"ada\", waited_s: 40 }] }]\nif (waiting.length) say(`${waiting[0].present[0].username} is waiting -- play now`);\nplayButton.onclick = () => mb.live.play({ public: true, seats: 2 });\n```\n\nIt is for SHOWING, not for choosing. Join with the same `play({ public: true })`:\nwe seat the person in the first room of that list, which is the fullest. Never\npass one of these names as `room` to steer somebody into it, and never offer\nthe list as a menu -- that is matchmaking of your own, and it spreads people\nacross rooms. A named room is never in the list, the person asking is never in\nit, and an empty list means nobody is waiting; draw nothing then. People wait\nseconds, not minutes, so ask when the page opens and again when your start\nscreen comes back, not on a timer.\n\n**Asking for a room while you are in one gives you that room.** Pressing your\nown Play button twice is one room and not two. A page that wants a SECOND room\ncalls `mb.live.leave()` first, then `play` again.\n\n**A seat in a public room is held while you are there, and is free when you are\nnot.** Close the tab and your seat goes back to the pool within about a minute;\ncome back and you are seated with whoever is playing now, which is what asking\nfor a public room meant. This is the one place a seat behaves differently from\na named room, where the seat stays yours.\n\n**So do not write your own matchmaking, and delete it if you have.** You can\nsee only the room you were handed, never the pool, so the only move available\nto you is to refuse one and open another -- which spreads the people you were\ntrying to meet across more rooms. Checking whether the people in a room are\n\"really there\", keeping a list of rooms that went bad, or leaving and re-asking\nare all answers to a platform defect that is fixed: ask once and play in what\ncomes back.\n\n**A room has a maximum, not a starting line.** `seats` is the most people who\nmay sit down, and the room ticks from the first one: `step` is handed the seats\nconnected right now, so whether anything happens before the rest arrive is your\ndecision and not ours. Say it in two places -- `step` advances the state or\nstands still, and `waiting` names the seats the match cannot go on without. A\npage that cannot begin alone stands still and returns the empty seats from\n`waiting`, which is how the person who is missing is told. A page people drop\ninto plays on and returns `[]`, because nobody is holding anything up.\n\n## Seats\n\n**Seats are numbered from 1.** `mine`, `turn`, `seated[].seat` and `next` all\ncount from 1. There is no seat 0, and a `next` that names one is refused.\n\n**Seat 1 acts first**, from the moment the room opens.\n\n```js\nconst piece = room.mine === 1 ? \"X\" : \"O\"; // never seat === 0\nconst myTurn = room.turn === room.mine; // this, and nothing worked out from the board\n```\n\n`turn` is the one the server enforces. A page that derives whose move it is\nfrom its own board has a second opinion that can disagree, and when it does the\npage locks.\n\n## The invitation\n\n**The link is the invitation.** `state.link` is the room's link, ready to send:\nwhoever opens it joins that room, because a link joins by itself. Show it while\nthe room has an empty seat.\n\n```js\nmb.live.lobby((state) => {\n if (state.present && state.present.length < state.seats) showLink(state.link);\n});\n```\n\n**Send `mb.shareUrl(...)` and never `location.href`.** Your page runs inside a\nframe, and its own address carries a marker of ours. A link built from\n`location.href` opens the page without the frame -- no panel, no sign-in, no\nway into a seat. `shareUrl(extra)` gives the address the page is reachable at,\nwith our marker removed, your query kept, and what you pass set on top; `null`\nfor a key drops it.\n\n**Put the room in the link and nothing else.** Every other value in a link is a\nvalue the person you invited can edit before they open it.\n\nA name you choose yourself is one room everybody on the page shares -- right\nfor a single open table, wrong for anything private.\n\n**Or ask somebody by name, and we draw the card.**\n\n```js\nconst asked = await mb.room.invite(room.name);\nif (asked.sent) show(\"Invitation sent\"); // asked.sent is a NUMBER\nelse show(\"Nobody was asked\"); // asked.closed, the card was dismissed\n```\n\n`mb.room.invite(name)` opens OUR sheet over your page: two lists the person\nalready knows -- who they sat down with, who they follow -- and a field that\ntakes a username. It resolves when the card closes.\n\n**It takes no name and returns none.** You never send us an address, and the\nanswer is a count: `{ sent: 2 }`, or `{ closed: true }` when nothing went out.\nYour page learns that a card was opened and how many invitations left it. There\nis no list of people in this SDK and there will not be one.\n\n**There is no accept step.** Whoever was asked takes a seat, and taking it is\nthe acceptance. Keep the link from `mb.shareUrl` as well: an invitation is one\nway in and a link is the other.\n\n## Accounts\n\n**A guest can join an existing named room.** The default join gate permits\nthis. An invitation-only room refuses a guest. A guest cannot open a named\nroom or enter a resource room.\n\n**A guest uses a pass, not a sign-in.** Their name is `guest_` and four\ncharacters. The browser keeps the same guest for one day. `mb.viewer` still\nreports that the person is not signed in. Their round result is recorded.\n\n**A guest seat lasts while the guest is present.** After 60 seconds without\npresence, another person can take it. A named account seat remains durable.\nAll public seats retain the presence rule above.\n\n**You do not build a sign-in.** An ask to open a named room without an account\nreturns `refused.needsAccount`. The mindblown panel supplies the sign-in.\nAfter sign-in, the person joins as their account. Your page draws the refusal\nuntil that join succeeds.\n\n**They may close the ask.** Then they are looking at your page with no seat --\na real state, and it has to work. Calling `mb.live.play` again brings the ask\nback, so give them one control that means \"play with somebody\".\n\n**Reading stays open.** `look` works signed out: who is here and what everybody\nsees, with no seat and no hidden state.\n\n## Testing it\n\n**A page for two people cannot be tested by one person.** You take seat 1 and\nwait, and nothing happens, because nothing can. So ask for a **test seat**: a\nsecond person who exists for half an hour, in one room of one page.\n\n**Do not say a page for two works until two people have met in it.** Running\n`rules.js` on your own machine proves the rules and nothing else: not the\narrival from a link, not the join, not the start. Open two seat links, and the\npage works when both of them see every seat connected.\n\nTwo doors, and the page must be the asker's own either way.\n\n```\nPOST https://api.mindblown.ai/me/test-seats/<game_id> you hold their session\nAuthorization: Bearer <the owner's access token>\n\nPOST https://api.mindblown.ai/internal/test-seats/<game_id> you are their pod\nAuthorization: Bearer <the service token>\nX-Mindblown-Subject: <the subject token naming the person>\n\n{ \"count\": 1, \"seats\": 2 } the pod's body also names \"channel\", \"channel_id\"\n```\n\n`channel` and `channel_id` must name an address that has written to the bot.\nA lookup never creates a person.\n\nThe answer is one entry per test person:\n\n```json\n{\n \"room\": \"quiet-lake-41\",\n \"ttl_seconds\": 1800,\n \"seats\": [{\n \"account_id\": \"...\", \"username\": \"test_9f2c1ab7\", \"name\": \"Tester 1\",\n \"seat\": 2, \"room\": \"quiet-lake-41\", \"pass\": \"...\",\n \"url\": \"https://mindblown.ai/...?mb_seat=<pass>&room=quiet-lake-41\",\n \"expires_at\": \"...\"\n }]\n}\n```\n\n**Open the `url` in a second window and that window is the second person.** It\njoins, it is seen, it plays, it leaves. Your page needs no change and cannot\ntell the difference: a test seat comes through the same door as anybody else.\nPut the OWNER in the same `room` -- open your own page with `?room=<room>` --\nand the two of them are at one table.\n\n**What a test seat is.** A real account with a name and a seat, so everything\nyour page reads about a person is really there, `mb.viewer` included: it reads\nas signed in. It holds a `pass`, not a session -- all that can do is take a\nticket for its own seat.\n\n**What it is not.** It never appears in the people anybody played with, it is\nnever counted as somebody playing now, it has no inbox, and a match in a room\nthat held one is not written down. So a page is not made to look busy by being\ntested, and a test does not end up in anybody's history.\n\n**So a test seat cannot prove the RECORD** -- everything up to the end of a\nmatch, but not what we wrote down. Check that with real accounts.\n\n**Half an hour, then it is gone.** The account is deleted and its seat with it.\nAsk again if you need longer; there is no way to extend one.\n\n**Count what you need.** `count` is how many test people, `seats` is how big the\nroom is -- leave `seats` out and the room is built for your test people plus\nyou. Up to fifteen test seats, because a room holds sixteen.\n\n**It is for testing and it is not a player.** Do not use test seats to make a\npage look played, to fill a room a real person is waiting in, or to leave a\npage running. Nothing stops you; the numbers simply will not move, because\nnone of them counts a test seat.\n\n## `rules.js`\n\nAn ES module with five exports. Nothing about where it runs.\n\n```js\nexport const config = { seats: 2, watch: true, hold: 60 };\nexport function init(seats, rng, context) { return state; }\nexport function step(state, inputs, dt, rng, seats) { return state; }\nexport function view(state, seat) { return whatThisSeatSees; }\nexport function waiting(state, seats) { return []; } // seats the match cannot go on without\nexport function done(state) { return null; } // or how it ended\n```\n\n| Export | Called | Arguments | Returns |\n| --- | --- | --- | --- |\n| `config` | read by your page | -- | `{ seats, watch, hold }`. **Not yet enforced by the room**: the size is what `mb.room.join` opened the room with, and `watch` and `hold` are on the list below |\n| `init` | before the first step without saved state | `seats`: connected seat numbers; `rng`; frozen `context` | the starting state |\n| `step` | every tick | `state`; `inputs`: `{ [seat]: latestInput }` for seats that sent one this tick; `dt` in ms; `rng`; `seats` connected now | the next state, or nothing to mean \"I changed it in place\" |\n| `view` | every tick, once per seat | `state`, `seat` | what that seat receives. JSON. |\n| `waiting` | every tick, optional | `state`, `seats` connected | the seats the match cannot go on without. A turn-based file answers `[state.turn]`; a match that is simply on answers `[]`. It is how the platform knows to say \"waiting for you\" |\n| `done` | every tick | `state` | falsy while the match is on; anything else is how it ended, sent to every seat and **written down**. See [What we keep](#what-we-keep) |\n\n**Time and randomness come in as arguments.** `rng()` gives a number in\n`[0, 1)` from a generator the room seeded; `dt` is ours. Use them. During a\ntick the room also answers `Math.random()` from the same generator and\n`Date.now()` with the tick's time, so a file that reads them can still be run\nagain from the room's log (see [What happened in a room](#what-happened-in-a-room)).\n\n**`view` is where hidden information lives.** A hand, fog of war, a bluff:\nreturn to seat 3 only what seat 3 may know. The room sends nothing a `view`\ndid not return. Everything in `state` that `view` returns to everybody is\npublic.\n\n**Keep it small and keep it pure.** Under 512KB. No imports of anything but\nyour own code. A `step` that throws is reported to every seat on every tick\nas `error`, in your own words, and the match stands still until it stops.\n\n**`seats` is the list of seats connected right now**, in no particular\norder. A seat whose socket dropped is absent from it and sends no input; a\nseat that comes back is present again. `init` receives the seats present at\nthe first tick, which may be one -- a match that needs two waits in `step`\nuntil `seats.length` says so.\n\n**What `done` returns is recorded, and two shapes name a winner.** The room\nreports the end once and the platform keeps it -- the seats, the accounts\nbehind them, how long it ran. Say `{ winner: 2 }` or `{ places: [2, 1, 3] }`\nand the platform knows who finished where; add `{ scores: { \"2\": 40 } }` and it\nknows what each seat scored. Anything else is kept whole and names nobody,\nwhich is right for a match that has no winner. This is an offer and not a\nrequirement: the shape is yours, and these three are the parts we can read.\n\n## `mb.live`\n\n```js\nmb.live.match((channel) => { // see The lobby and the match\n channel.send(input); // this seat's latest input\n const off = channel.on(\"tick\", (tick) => draw(tick));\n channel.on(\"close\", ({ code, reason }) => ...);\n});\n```\n\nA `Channel`:\n\n| Field | Meaning |\n| --- | --- |\n| `seat` | your seat number, from 1 |\n| `room` | the room: `name`, `seats`, `seated` with everybody named |\n| `send(input)` | your input. Any JSON. Last write per tick wins, so one a tick is all a room can use -- see above |\n| `on(event, fn)` | `\"tick\"` and `\"close\"`. Returns a function that unsubscribes |\n| `close()` | closes the socket. The seat is still yours; `mb.live.leave()` gives it back |\n\n**The page never holds the socket.** The mindblown panel on your page opens\nit, with a ticket your page never sees, and relays what the room says. Your\npage speaks to `channel` and nothing else.\n\n### Immutable resources\n\nCreate a room from published documents when its rules need a fixed layout.\nEach resource belongs to this page and remains unchanged during the attempt.\n\n```js\nconst requestId = crypto.randomUUID();\nconst channel = await mb.live.create({\n seats: 2,\n resources: { vault: published.id },\n requestId,\n});\nchannel.on(\"tick\", (tick) => draw(tick));\n```\n\nCreation requires a real account and one through four published documents.\nTheir combined JSON must fit within 64 KiB.\nThe channel has the same methods as the one `match` hands you.\nKeep `requestId` for an explicit retry after a lost response.\nRetry the same request; different content produces `IDEMPOTENCY_CONFLICT`.\nAn identical retry returns the original room without another seat replacement.\nA superseded attempt can refuse its ticket with `NO_SEAT`.\nThe error preserves `requestId` and any room receipt already received.\n\nThe rules receive a frozen context before initialization:\n\n```js\nexport function init(seats, rng, context) {\n const vault = context.resources.vault;\n return { cells: [...vault.value.cells], moves: 0 };\n}\n```\n\nEach resource contains `id`, `kind`, `schemaVersion`, `hash`, and `value`.\nOrdinary rooms receive `{resources:{}}`.\nSaved state survives runtime recovery; recovery does not call `init` again.\nThe context stays inside the rules unless `view` returns part of it.\n\nA resource room permits one completed attempt.\nIts first `done` stops simulation and becomes the final result.\nReconnects receive the same final view and result.\nCreate another room for another attempt.\nOwner removal lets an already-started attempt finish.\nPlatform removal can stop an attempt.\nTemporary authorization failures pause simulation until access returns.\n\n## The tick\n\nEvery seat receives, twenty times a second:\n\n```js\n{\n t: 1789371672003, // the room's clock, ms\n tick: 4812, // counts up from 1\n dt: 50, // ms since the previous tick\n seats: [1, 2], // who is connected right now\n waiting: [2], // who the match cannot go on without, from waiting()\n view: { ... }, // what view(state, mySeat) returned\n done: null, // or what done(state) returned\n error: \"...\" // only when rules.js threw or could not be fetched\n}\n```\n\nWhen `error` is present there is no `view`. Show the sentence; it is yours.\n\nA late joiner's first tick is a whole view, so there is nothing to catch up\non. A room with sockets open and no input for three seconds stops ticking\nuntil the next input; your page sees the gap as a pause, not as a fault.\n\n**One socket per seat, and the newest window holds it.** A seat is a person and\njoining twice is the same seat, so a second tab, a reload that raced its own\nclose and a reconnect after a drop all ask for a seat that already has a socket.\nThe room hands it to the newest and tells the old one `{ closed: \"elsewhere\" }`\nbefore closing it. Two consequences worth designing for: `seats` never carries\nthe same number twice, so `seats.length` is a count of PEOPLE; and one person\ncannot fill two seats by opening two windows -- to test a match with two\nplayers you need two accounts.\n\n**The view is the budget, and it is the only one you are likely to reach.** The\nroom sends one to every seat twenty times a second, so a view is paid for\nsixteen hundred times a minute at eight seats. Two measurements, both from real\npages: a page that returns the whole board with the parts a seat cannot see set\nto `null` sent 5.9KB per seat per tick -- about 1MB a second at eight seats --\nand a page that culled by line of sight inside `view` sent 333 bytes, which is\n52KB a second. The difference is twenty-fold.\n\n**So cull in `view`, not in the page.** It already exists to decide what one\nseat may see, which makes it the only place that can leave something out\nhonestly -- a page that filters what it draws still downloaded the rest, and a\npage that could read it could cheat with it. If a visibility test is expensive,\nwork it out once per tick rather than once per pair. One page's went from 56\ntraces to 28, and the cost of a whole tick's views fell from 0.615ms to\n0.015ms.\n\n**What the room itself can carry is not the constraint you will meet first.**\nMeasured against the live room, with a page that culls inside `view`:\n\n| seats | inputs each | ticks a second | gap, typical | gap, worst |\n| --- | --- | --- | --- | --- |\n| 2 | 15 | 19.8 | 49ms | 68ms |\n| 8 | 15 | 20.1 | 50ms | 81ms |\n| 8 | 30 | 20.0 | 49ms | 81ms |\n| 16 | 30 | 20.0 | 50ms | 102ms |\n| 16 | 60 | 20.0 | 50ms | 83ms |\n\nEvery message on the wire in those runs was around 464 bytes, envelope and all,\nand it did not grow when the input rate doubled. The last row is twice the rate\nany page should send, at the seat cap, and it is 960 messages a second into one\nroom -- the ceiling the platform gives us -- with the clock still at 50ms. Rerun\nit yourself with `mindblown-live/probe.mjs`; a number in a document nobody can\nreproduce goes stale without anybody noticing.\n\n## Turns on live\n\nA turn-based match is a rules file whose `step` listens to one seat. There is\nno other machinery: `state.turn` is a number your `step` moves along, hidden\nhands are what `view` keeps back, and `waiting` names the seat whose move it\nis so the platform can say so. The room enforces all of it -- nobody moves out\nof turn, nobody moves twice, nobody sees a hand that is not theirs -- because\nthe room is the only thing running the file.\n\nNoughts and crosses, whole:\n\n```js\nexport const config = { seats: 2, watch: true, hold: 60 };\nexport function init(seats, rng) { return { cells: Array(9).fill(0), turn: 1, moves: 0 }; }\nconst LINES = [[0,1,2],[3,4,5],[6,7,8],[0,3,6],[1,4,7],[2,5,8],[0,4,8],[2,4,6]];\nconst winner = (c) => LINES.map(([a, b, d]) => c[a] && c[a] === c[b] && c[a] === c[d] ? c[a] : 0).find(Boolean) || 0;\nexport function step(state, inputs, dt, rng, seats) {\n if (winner(state.cells) || state.moves === 9) return state;\n const input = inputs[state.turn]; // one seat is heard\n if (!input || !Number.isInteger(input.cell) || input.cell < 0 || input.cell > 8) return state;\n if (state.cells[input.cell]) return state;\n state.cells[input.cell] = state.turn;\n state.moves++;\n state.turn = state.turn === 1 ? 2 : 1;\n return state;\n}\nexport function view(state, seat) { return { me: seat, cells: state.cells, turn: state.turn }; }\nexport function waiting(state) { return winner(state.cells) || state.moves === 9 ? [] : [state.turn]; }\nexport function done(state) {\n const w = winner(state.cells);\n return w ? { winner: w } : state.moves === 9 ? { draw: true } : null;\n}\n```\n\nA card table is the same shape with a `deck` array in `state`, shuffled with\n`rng` in `init`, dealt by `step`, and hidden by `view` -- return to seat 3 only\n`state.hands[3]`. A trade is `step` moving items between two hands when the\nseat holding the turn asks. A showdown is `view` no longer hiding. A deadline\nis a number in `state` compared with the tick's clock.\n\nThe page for a turn-based match sends its move once, on the tap, and draws on the\ntick. It does not need thirty inputs a second.\n\n**The old turn-based wire** -- `mb.room.look` and `mb.room.act` with `next`,\n`hold`, `deck`, `deal`, `give`, `take`, `show` -- is deprecated as of\n2026-09-15. It keeps answering for the pages already on it until 31 December\n2026 and is written down at the end of this file. New pages write the file\nabove.\n\n## Drawing between ticks\n\nTwenty ticks a second is not sixty frames. Draw at your frame rate. A page\nthat draws only on ticks looks like twenty frames a second, because it is.\nHow to draw between ticks depends on who moves the thing.\n\n**What the room moves, interpolate.** A board, a score, anything `step`\nmoves: keep the previous view and its `t`, and draw a point `dt`-proportional\nbetween the last two. It is drawn about one tick late, and that is right,\nbecause the room is the authority on it.\n\n**What another seat moves, draw where it is NOW, not where it was.** When each\nseat sends its own position -- a vehicle, a body, a pointer -- the newest\nsample you hold for another seat is already old when it arrives. Every frame,\ncarry it forward: its position plus its velocity times the sample's age, with\nits turn rate if you send one. When the next sample arrives, blend toward it\nover about 100ms rather than jumping. Stop carrying it forward after about\n250ms without a sample, and hold it where it is, so a dropped seat does not\ndrive off on its own. Send each sample with its time on the room's clock (the\nlast tick's `t` plus the time since that tick), so the other page knows how\nold it is.\n\n**Do not hold samples back to smooth them.** A buffer that draws other seats\nsome milliseconds in the past adds all of those milliseconds to what a person\nsees, however fast the delivery under it is.\n\n**Prediction is optional and it is the same file.** A page may `import`\n`/rules.js` itself and run `step` locally with its own input to draw ahead\nof the next tick, then snap to the view when it arrives. Start without it.\n\n## What we decide\n\nOurs, and no page can change it:\n\n- **who you are** -- the seat comes from a ticket the API signed, never from\n a message,\n- **what you can see** -- `view` is applied by the room, never by a page,\n- **the clock** -- `dt` is ours; nobody runs their own faster,\n- **the dice** -- `rng` is ours and seeded,\n- **which rules** -- the file at your host when the room opened. A republish\n never changes a match in progress; the next room gets the new file.\n\nYours: whether `step` is right. We do not know your rules. What we know is\nthat every seat is running the same ones.\n\n## What we keep\n\nThese rematch rules apply to ordinary rooms. Resource rooms permit one completed attempt.\n\n**`done` is how a round is written down, and a room that never ends keeps\nnothing.** When `done` returns something, the room reports it and we record the\nmatch: who held each seat, how long it ran, and the result whole. When `done`\ngoes falsy again the room has started another one, it gets the next sequence,\nand that one is recorded too. **So a page with rounds should end each round\nthrough `done` and let the next begin — not return `null` for ever.**\n\n```js\nexport function done(state) {\n // the ROUND ended, and the room plays on\n if (state.round.over) return { places: state.round.order };\n return null;\n}\n```\n\nA file that answers `null` for ever is a file we hold no history for. Measured\n2026-09-18: a page that had been played for hours in dozens of rounds had\n**zero** records, because its `done` said `return null; // the room never ends`.\n\n**Return `places`, not only `scores`.** `places` is the finishing order —\n`[3, 1, 2]` means seat 3 came first — and it is the one thing that means the\nsame in every page. A score is yours: two hundred is a triumph in one page and\na rout in another, so nothing can compare it with anything. Both are kept, and\n`winner` is accepted as shorthand for a single first place:\n\n| in the result | what we do with it |\n| --- | --- |\n| `places: [3, 1, 2]` | seat 3 first, seat 1 second, seat 2 third |\n| `winner: 2` | seat 2 first, everybody else unplaced |\n| `scores: { \"1\": 240, \"2\": 90 }` | kept per seat, compared with nothing |\n\nAnything else stays in the raw result and names no winner.\nRead your account's recorded outcomes with `mb.matches.list({limit:20})`.\nThe reply contains `{items,nextCursor}`.\nEach item includes your outcomes, match times, room, sequence, and available resource provenance.\nPass `nextCursor` to read the next page.\nOnly exact account bindings appear. Test rooms and inferred participation remain excluded.\nOther accounts' outcomes and raw result JSON remain private.\n\n## What happened in a room\n\n**A room writes down every tick as your rules saw it**: the tick's number, its\ntime, `dt`, the seats in order, every seat's input, and a digest of every view.\nWith the room's seed and the state where the log starts, that is enough to run\nthe room again, tick for tick. A room keeps the last hour.\n\n\nOnly the page's owner reads a room's log (`GET /me/rooms/{page_id}/{room}/log`).\nIt holds nothing your rules file was not already given.\n\n## Who is watching\n\nEach seat carries `online` and `seen` -- seconds since that person's page last\nasked about the room. Presence is the heartbeat of `look`; nothing to send.\n\n**Presence is not membership.** Somebody offline still holds their seat. Show\nit; do not free a seat because a tab closed.\n\n```js\nroom.seated.forEach((s) =>\n mark(s.seat, s.username, s.online ? \"here\" : `away ${Math.round(s.seen / 60)}m`));\n```\n\n## Names on screen\n\nA few views of a page are recorded, so its maker can watch how it is used.\nPeople's names and what they send each other never reach a recording: we black\nthem out wherever we can see them drawn -- HTML text, and `fillText` on a\ncanvas in the page.\n\nText you draw in 3D -- a name over a car, a chat line on a texture, a font\natlas -- we cannot see. A room page with a 3D canvas is then not recorded at\nall, unless you say where the names are:\n\n```js\nmb.recording.mask((canvas) => {\n // CSS pixels of this canvas; [] when no name is on screen.\n return labels.filter((l) => l.visible).map((l) => ({ x: l.x - 4, y: l.y - 4, width: l.w + 8, height: l.h + 8 }));\n});\n```\n\nReturn every rectangle a name or a message covers this frame. Return `null`\nto leave a frame out. Calling it costs nothing when the view is not recorded.\n\n## Refusals\n\nA refusal reaches the lobby as `state.refused.reason`:\n\n| `reason` | what happened | what to do |\n| --- | --- | --- |\n| `NO_SEAT` | the seat was lost before the socket opened | call `mb.live.play` again |\n| `OPENED_ELSEWHERE` | the same person opened this room in another window | say so; that window has the seat |\n| `ROOM_UNREACHABLE` | the room closed five times before the match began | offer `mb.live.play` again |\n| `ROOM_CLOSED` | the room has ended | show the result |\n| `BAD_ASK` | the room name or page id was malformed | a bug in the page |\n| `SERVICE_UNAVAILABLE` | live rooms are off on this deployment | say so; the turn-based room still works |\n\nAn `error` on a tick is not a refusal: the socket is open and the room is\ntelling you your file is broken.\n\nAnd `ROOM_FULL` (every seat taken), `ROOM_SIZE` (a room seats 2 to 16, and a\nnew one has to say) and `NOT_INVITED` (a private room its opener keeps to the\npeople they invite).\n\n## Not yet built, said plainly\n\n- `config.watch` and `config.hold` are read by nobody yet. Spectators cannot\n connect; a dropped seat is simply absent from `seats` until it returns.\n- `waiting` reaches every seat on the tick, and the room now tells the platform\n which seats a match has waited on for a minute. The inbox that shows it to\n somebody who is away is being built.\n- A key can be rotated; it has not been.\n\n## A whole page\n\nmindblown adds `window.mindblown` to every page when it publishes it, so the\npage loads nothing for it.\n\n```html\n<canvas id=\"c\" width=\"400\" height=\"300\"></canvas>\n<div id=\"lobby\"></div>\n<button id=\"play\">Play with somebody</button>\n<script>\nconst mb = window.mindblown;\nlet last = null;\n\nmb.live.lobby((state) => {\n const box = document.getElementById(\"lobby\");\n if (state.refused) { box.textContent = state.refused.needsAccount ? \"Sign in to play\" : state.refused.reason; return; }\n const here = state.present.map((p) => p.username || \"seat \" + p.seat).join(\", \");\n box.textContent = here + \" -- waiting for \" + (state.seats - state.present.length) + \". \" + state.link;\n});\n\nmb.live.match((channel) => {\n document.getElementById(\"lobby\").textContent = \"\";\n channel.on(\"tick\", (tick) => { last = tick; });\n let dy = 0;\n addEventListener(\"keydown\", (e) => { dy = e.key === \"ArrowUp\" ? -1 : e.key === \"ArrowDown\" ? 1 : dy; });\n addEventListener(\"keyup\", () => { dy = 0; });\n setInterval(() => channel.send({ dy }), 1000 / 20);\n requestAnimationFrame(function frame() { draw(last); requestAnimationFrame(frame); });\n});\n\ndocument.getElementById(\"play\").onclick = () => mb.live.play({ public: true, seats: 2 });\n</script>\n```\n\nA person who opens the link lands in the room with no click. A person who\npresses the button is put with somebody. Both see the same lobby, and the match\nstarts for both when the second one connects.\n\n## The old wire, until 31 December 2026\n\n`mb.room.look` and `mb.room.act`, and the server operations under them, were\nhow a turn-based match was played before the rules ran on the server. They are\ndeprecated as of 2026-09-15: every answer carries a `Deprecation` header, and\nthey keep answering for the pages already written on them. **Do not write a new\npage against anything in this section** -- the file under \"Turns on live\" does\nall of it, and the room enforces it.\n\n### A turn\n\n```js\nawait mb.room.act(room.name, {\n move: { played: \"ace\" }, // yours. We never read it\n state: { pile: 3 }, // patches what everybody sees\n mine: { note: \"hello\" }, // patches what only you see\n next: 2, // hand the turn to a seat\n hold: 30, // seconds before anybody may take it back\n close: true, // end the room\n});\n```\n\n| Field | Rule |\n| --- | --- |\n| `move` | recorded in the log as sent. Never read by us |\n| `state`, `mine` | **patches**: a key you send replaces that key, a key you leave out is untouched |\n| `next` | where the turn goes. Leave it out and it goes to the next seat. Only the seat holding the turn may direct it |\n| `hold` | seconds the next seat has before anybody seated may act. Reported back as `turn_until`. Leave it out and the turn waits as long as it needs |\n| `close` | ends the room. No further turns |\n\n**Send `hold` whenever you hand the turn to somebody who might not answer.** A\nseat holding the turn is holding the room; if they close the tab, it stops for\neverybody. Past `hold`, taking the turn is the same call as using it, and the\nlog shows who took it.\n\n**`next` is every order but plain rotation** -- a skip, a reversal, a second\nturn, or handing the turn to the person you are trading with so they can\nanswer.\n\n### What we decide\n\nThings a page must not decide for itself, because the other seats could not\ncheck it:\n\n```js\nawait mb.room.act(room.name, {\n roll: { dice: 2, sides: 6, as: \"throw\" }, // we throw them\n deck: { key: \"bank\", items: [...], shuffle: true }, // a supply nobody reads\n deal: { from: \"bank\", to: 3, key: \"hand\", count: 2 },\n give: { to: 1, key: \"hand\", items: [\"ore\"] }, // out of YOUR hand\n take: { from: 2, to: 1, key: \"hand\", count: 1 }, // nobody chooses which\n show: { key: \"hand\", as: \"hand2\" }, // turn YOUR hand face up\n});\n```\n\n| Field | What it does |\n| --- | --- |\n| `roll` | dice. The result lands in `state[as]` as `{ dice, total }`, public |\n| `deck` | loads a supply into the room. It is in no answer to anybody |\n| `deal` | moves the top `count` of a supply into a seat's hidden state |\n| `give` | moves named items out of **your own** hidden state to another seat |\n| `take` | moves `count` items nobody chooses from one seat to another |\n| `show` | copies **your own** hidden key into the public state |\n\n**A key we have written is a key you may not write.** Once `deal`, `give` or\n`take` has touched `hand`, a `mine` patch to `hand` is refused. Keys we never\ntouched stay yours.\n\n**A trade is two gives and a handover.** Give what you offer, `next` to the\nother person, they give back and hand the turn on.\n\n**Hidden dice are a deck, not a roll.** `roll` is public by design. For dice\nonly one seat may see, load a deck of faces and `deal` from it.\n\n#### The showdown\n\n**A reveal is ours or it settles nothing.** A page can print anything; `show`\nis the server publishing the hand it dealt, and the log says so.\n\n```js\nawait mb.room.act(room.name, { show: { key: \"dice\", as: \"dice\" + room.mine } });\n```\n\n- `as` gives every seat its own public key. Without it the hand lands under its\n own name and four seats showing at once overwrite each other.\n- **A bare `show` does not wait for your turn.** It moves nothing, patches\n nothing, and leaves the turn and its deadline where they are. A `show` with\n anything else beside it is an ordinary turn, refused with `NOT_YOUR_TURN`\n when it is not yours.\n- Showing a key you were never dealt is `NOT_HELD`.\n- A show is a copy; the hand stays in `private`.\n\n#### What is guaranteed\n\nOurs, and no page can talk us out of it: who you are (the seat comes from the\nsession), when you may act, what you can see, what a die landed on and how a\ndeck was shuffled, and the order of the log.\n\nYours: whether a legal-looking move is a right move. `state` is public and the\nlog is shared, so every copy of your page can check the table against it.\n\n### Staying up to date\n\n```js\nlet seq = room.seq;\nfor (;;) {\n const now = await mb.room.look(room.name, { since: seq, wait: 25 });\n seq = now.seq;\n now.moves.forEach(apply);\n}\n```\n\n`wait` holds the answer until the room moves -- somebody acts, somebody takes\na seat, the turn changes hands -- or the seconds run out; a quiet room answers\non the deadline with what it has. Thirty is the most we hold. Without `wait`,\npoll every second or two.\n\nThis was the old wire's poll. A match runs on the tick now; see\n[The tick](#the-tick).\n"
}SHA-256 of public snapshot: 0a96496b6a62ac76ad56c6eaf0e9ee0822837ef2affa081b70b6aabb56bf9fa4