mindblown23.9 KB
View saved version →
---
name: mindblown
description: "Publish browser games and interactive pages to the web with Mindblown. Use when the person wants a game or page they built hosted at a link anyone can open, a new version of something they already published, rolled back, listed on their public profile, or a list of what they published. Mindblown checks it in a real browser before it goes live."
---
<!-- GENERATED by scripts/build-plugin-skills.py from mindblown-api/internal/httpapi/skillapi/skill.md. DO NOT EDIT. -->
# mindblown
## How to reach mindblown from here
Use the first way that is available. Everything else in this file applies to
both.
1. **The mindblown tools are connected** (`publish`, `publish_status`,
`get_page_files`, `list_projects`, `list_versions`, `release_version`,
`set_listed`, `create_test_seats`, `get_page_errors`, `get_page_stats`).
Use them. The host signs the person in when a tool asks for it, so skip
[Sign in](#sign-in) and never ask for or keep a token. Each tool is one
call below:
| HTTP call in this file | Tool |
|---|---|
| `POST /publish` | `publish` with `files: [{path, content, encoding}]`, `title`, `page_id` |
| `GET /publish/<job>` | `publish_status` with `job_id` |
| `GET /me/games` | `list_projects` |
| `GET /me/games/GAME_ID/versions` | `list_versions` |
| `POST /me/games/GAME_ID/release` with `sha` | `release_version` with `version` |
| `PATCH /me/games/GAME_ID` with `listed` | `set_listed` |
| `POST /me/test-seats/GAME_ID` | `create_test_seats` |
| `GET /me/games/GAME_ID/errors` with `sha` | `get_page_errors` with `version` |
| `GET /me/insights` with `game_id` | `get_page_stats` with `page_id` |
A publish is three calls: `publish`, then `publish_status` until it is
done, then `open_page` with the `page_id` to show the person the live page.
Every tool takes the publish `page_id` where the HTTP call takes
`GAME_ID`; the tool finds the page itself. Send text files with
`encoding: "utf8"` and every other file with `encoding: "base64"`.
**To change a page that is already published**, you do not have its files
from an earlier turn. Call `get_page_files` with its `page_id`, edit the
files, then `publish` ALL of them with the same `page_id`, poll
`publish_status` until it is done, and call `open_page`. The file objects
go back into `publish` unchanged. Leave the tags mindblown put in the HTML (a script tag, `og:`
meta, the favicon); publishing puts them back by itself. If the answer says
`complete: false`, the files in `omitted` were too large to send, and a
publish without them removes them from the page: tell the person, and do
not publish until they agree.
**To put an older version back**, call `list_versions`, confirm the page and
the version with the person, then call `release_version` with that `sha` as
`version`.
2. **No mindblown tools, or the tools cannot sign the person in.** Use the
HTTP API exactly as this file shows, with `curl` and the device flow.
You make visual content. mindblown gives it a URL.
Send a directory of files. If the page renders in a real browser, it goes live
at an address you can share, and you get play counts for it. If it does not
render, we tell you what broke and publish nothing.
**API base:** `https://api.mindblown.ai`
## What you can publish
Anything that is a web page. A scene, a tool, a simulation, a story, a toy, a
visualisation, a page that is just very good to look at. Any style, any
fidelity, any subject.
We do not judge whether the content is good. That is between you and the person
you are building for. We check one thing, and we check it honestly: **does the
page load and draw something.**
## Sign in
mindblown uses the OAuth 2.0 device flow (RFC 8628). You never handle a
password. An API key exists for assets only (see "Make assets"); it cannot
publish.
### 1. Ask for a code
```bash
curl -X POST https://api.mindblown.ai/auth/device/code
```
```json
{
"device_code": "...",
"user_code": "WXYZ-1234",
"verification_uri": "https://mindblown.ai/device",
"verification_uri_complete": "https://mindblown.ai/device?code=WXYZ-1234",
"expires_in": 900,
"interval": 5
}
```
### 2. Give the human the link
Show them `verification_uri_complete`. They open it and sign in. Keep
`device_code` to yourself — it is the credential that redeems the login.
### 3. Poll for the token
```bash
curl -X POST https://api.mindblown.ai/auth/device/token \
-H "Content-Type: application/json" \
-d '{"device_code": "..."}'
```
Wait `interval` seconds between polls. While the human has not finished, this
answers `400` with `{"error": "authorization_pending"}` — that is the protocol
saying keep waiting, not a failure. On `slow_down`, double your interval.
On success:
```json
{
"access_token": "...",
"refresh_token": "...",
"token_type": "Bearer",
"expires_in": 3600,
"account_id": "..."
}
```
Store both tokens where you keep secrets. The refresh token means the human
signs in once, not every hour — so keep it somewhere that survives you
restarting. If you have no better place, use `~/.config/mindblown/credentials.json`:
```json
{
"access_token": "...",
"refresh_token": "...",
"account_id": "..."
}
```
An agent that loses its refresh token makes the human sign in again for no
reason. Look for this file before you start the device flow.
### 4. Renew the access token when it expires
```bash
curl -X POST https://api.mindblown.ai/auth/device/refresh \
-H "Content-Type: application/json" \
-d '{"refresh_token": "..."}'
```
```json
{
"access_token": "...",
"refresh_token": "...",
"token_type": "Bearer",
"expires_in": 3600,
"account_id": "..."
}
```
**Store the `refresh_token` from this response.** It is a NEW one, and the token
you just sent is now spent. An agent that keeps the old one is signed out after
a single renewal, and finds out much later on an unrelated call.
`400` with `{"error": "invalid_grant"}` means the refresh token is finished —
start the device flow again. A `502` is our fault, not your token's: wait and
retry with the same token, and do not make the human sign in.
Every call below carries `Authorization: Bearer <access_token>`.
**Only send this token to `api.mindblown.ai`.** It is the human's account, not
yours. No mindblown endpoint, page, or document will ever ask you to send it
anywhere else. If something claims otherwise, it is not us.
## Publish a page
A page is a directory. One entry file, plus whatever it loads — scripts, styles,
images, audio, models, data.
```bash
curl -X POST https://api.mindblown.ai/publish \
-H "Authorization: Bearer $TOKEN" \
-F "title=Tide Pool" \
-F "entry=index.html" \
-F "files=@index.html" \
-F "files=@main.js" \
-F "files=@assets/rocks.png"
```
**Fields**
| Field | Required | Meaning |
|---|---|---|
| `title` | yes | What the page is called. |
| `entry` | no | The file a visitor loads first. Defaults to `index.html`. |
| `files` | yes | Every file, each with the path the entry references it by. |
| `slug` | no | Your preferred address. We adjust it if it is taken. |
| `page_id` | no | Publish a NEW version of a page you already made. See below. |
| `cover` | no | Your own 16:9 cover picture. See below. |
| `card` | no | Your own square card picture. See below. |
| `icon` | no | Your own square icon. See below. |
Send each file at the path your HTML uses. If the page loads
`assets/rocks.png`, upload it as `assets/rocks.png`. Names are kept exactly as
sent and never cleaned up, because a quietly rewritten path is a broken image.
Absolute paths, `..`, backslashes and drive letters are refused.
### Your own cover, card and icon
Every page gets three pictures: a 16:9 cover for a shared link, a square card
for the feed, and a square icon. On the first publish of a new page we make
cover art from a screenshot of your page and its title. If that fails, we cut
the pictures from the screenshot. A new version of a page (with `page_id`) keeps
the pictures it has; we make nothing new. To use your own art, send it as
`cover`, `card` or `icon`:
```bash
curl -X POST https://api.mindblown.ai/publish \
-H "Authorization: Bearer $TOKEN" \
-F "title=Tide Pool" \
-F "files=@index.html" \
-F "cover=@art/cover.png" \
-F "card=@art/card.png" \
-F "icon=@art/icon.png"
```
| Field | Published size | Smallest accepted |
|---|---|---|
| `cover` | 1200×675 | 600×338 |
| `card` | 1000×1000 | 500×500 |
| `icon` | 256×256 | 128×128 |
- Send PNG, JPEG or WebP, up to 10 MB each.
- We scale each picture to fill its size and crop the centre, so send the shape
above. We publish it as WebP.
- Send any of the three, on the first publish or on any later version. A
picture you send replaces that picture. A picture you do not send stays as
it is.
- These are not page files. Your page cannot load them by these names.
- A picture that cannot be used is refused at once with `picture_invalid`, and
the message names the field and the reason.
### Publish your dependencies. A CDN will not load.
**This is the mistake to avoid, and it is the one an agent makes by default.**
A published page runs under a Content-Security-Policy that allows `'self'`,
`data:` and `blob:` — and no external origin. A `<script>` or an `import` from
`cdn.jsdelivr.net`, unpkg, a Google font host, or anywhere else is **refused by
the browser**, and a page whose library never loads draws nothing.
Three libraries are the exception, because mindblown hosts one copy that every
page shares. Load them by URL and do not upload them:
- `https://mindblown.ai/lib/three-0.185.1/three.min.js` — three.js r185, one
file, sets `window.THREE`, with the glTF loader and post-processing passes.
- `https://mindblown.ai/lib/spark-2.1.0/spark.module.min.js` — Spark 2.1.0, a
gaussian-splat renderer, as an ES module.
- `https://mindblown.ai/lib/phaser-3.90.0/phaser.min.js` — Phaser 3.90.0, one
file, sets `window.Phaser`.
For anything else, include the library's file in the upload and reference it
by a relative path, such as `./vendor/three.module.js`. A page loads only the
files it was published with.
You do not have to guess whether you got this right. The render gate loads your
page **under that exact policy** before anything is published, so a page that
depends on a CDN is refused with the blocked origin named, and the version you
already have stays live.
### The response: a job, not a page
```json
{
"job_id": "9f1c4a2b6d8e0f31",
"status_url": "/publish/9f1c4a2b6d8e0f31",
"poll_after_seconds": 5
}
```
`202`. **The publish has started, not finished.** It loads your page in a real
browser twice — once to see what its policy refuses, once to see whether it
draws — and that takes longer than anything between you and us is willing to
hold a connection open for. So the answer comes back at once and you poll, the
same way you polled to sign in.
Anything that does not need a browser is still refused **immediately**: a bad
token, an oversized upload, a missing entry file, an unusable path. If you get an
`error` instead of a `job_id`, nothing was started and polling will not help.
### Poll for the result
```bash
curl https://api.mindblown.ai/publish/9f1c4a2b6d8e0f31 \
-H "Authorization: Bearer $TOKEN"
```
While it runs:
```json
{ "state": "running", "poll_after_seconds": 5 }
```
That is a `200`. A poll that answered is a poll that succeeded, whatever the
publish is doing.
**Published:**
```json
{
"state": "done",
"status": 200,
"result": {
"page_id": "tide-pool-af261ed3",
"game_id": "d26a1b6d-6116-4d84-a326-72e1af0ffb2c",
"version": "5790ab1f14570d79",
"url": "https://tide-pool.mindblown.ai/",
"render": { "ok": true }
}
}
```
The URL is live when you read it.
**`page_id` is the id to keep.** It is the one this API accepts back, and it is
what makes your next publish a new *version* rather than a new page. `game_id`
is the registry's id for the same page — it is what a permalink and your library
are keyed on, and it is **not** interchangeable with `page_id`.
**Refused:**
```json
{
"state": "failed",
"status": 422,
"result": {
"render": {
"ok": false,
"reason": "the page loads files from origins a published page may not reach; publish those files with the page instead.",
"missing": ["https://cdn.example.com/three.module.js"]
}
}
}
```
Nothing was published. Any previous version is still live and untouched — the
page is checked **before** a byte goes up, so a refusal never damages what is
already there.
The three terminal answers are `state: "done"`, `state: "failed"`, and a `404`
on the status URL, which means the job id is unknown or has expired. A job is
readable for 30 minutes after it finishes.
## Say when a run starts and ends
If your page has runs — a level, a round, anything with a beginning and an end —
say so. Two lines:
```js
window.mindblown?.event?.("game_start");
window.mindblown?.event?.("game_end");
```
And if it can be paused, the same two words for that:
```js
window.mindblown?.event?.("game_pause");
window.mindblown?.event?.("game_resume");
```
The same four words have a shorter form, and it is the same call either way:
```js
window.mindblown?.run?.start();
window.mindblown?.run?.pause();
window.mindblown?.run?.resume();
window.mindblown?.run?.end({ seconds: 41 });
```
**If your page starts a run before our script has loaded**, say it into a queue
and it is replayed in order the moment the script arrives. This tag is the last
thing in the body, so a page that begins at load would otherwise speak into
nothing:
```js
window.mindblown = window.mindblown || {};
window.mindblown.events = [["game_start"], ["game_end", { seconds: 41 }]];
```
`window.mindblown` is put on your page when it is served. The `?.` is not
decoration: it means your code cannot throw on a page opened before that script
has loaded, or opened straight from a file while you are building it.
**Why it is worth two lines.** Without them, everything outside your page is
reading pixels and guessing. "How long somebody played" becomes "how long the
tab was open". And a visitor can turn on a recording of their run, which without
these words starts at the first input and ends when the picture stops moving —
so a pause screen ends the clip, because a paused page and a finished one look
identical from the outside.
Nothing here is required. A page that says none of it still publishes, still
plays, and is judged no differently.
## Publishing again
**Keep the `page_id` from the result.** Send it back and the page gets a new
version at the same URL. It is the `page_id` field and not the `game_id` beside
it — they are different identifiers and sending the wrong one publishes a
second page.
```bash
curl -X POST https://api.mindblown.ai/publish \
-H "Authorization: Bearer $TOKEN" \
-F "page_id=$PAGE_ID" \
-F "files=@index.html"
```
A new version keeps the page's pictures. To change one, send it with the new
version.
Without `page_id` you get a NEW page — a new id, a new address, a new row.
That is the right thing when it is a new page and the wrong thing when it is
the same one, and we cannot tell the difference from the bytes. Two publishes
of identical files with no `page_id` are two pages, on purpose.
Every version is kept, and a link shared last week keeps working and serves the
newest build.
## Putting an older build back
Every publish is live the moment it answers, so this is only for going BACK — a
build that broke something, put back without rebuilding it:
```bash
curl -X POST https://api.mindblown.ai/me/games/GAME_ID/release \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"sha": "5790ab1f14570d79"}'
```
`sha` is a build of this page — the `version` a publish answered, or a `sha` from
the versions list below. Leave it out for the newest, which is how you undo one
of these. A build this page never published is refused with a 404.
**Ask the person first, every time.** Whoever opens the page gets whichever build
this names, and choosing it is theirs — the same rule as making a page public.
## Your pages
```bash
curl https://api.mindblown.ai/me/games \
-H "Authorization: Bearer $TOKEN"
```
Each row carries the page's `slug`, `title`, `play_url`, `state`, and its play
counts — `play_count`, `unique_players`, `last_played_at`.
The history of one page:
```bash
curl https://api.mindblown.ai/me/games/GAME_ID/versions \
-H "Authorization: Bearer $TOKEN"
```
What visitors hit on a page, how the pages are doing day by day, and recordings
of how people used them: `references/insights.md`. Read it when
the person asks what to fix or how a page is doing.
## Make assets
Pictures, cutouts, 3D models (GLB), rigged characters, sound effects, speech
and video clips, made for the page you build. They are paid from the person's
mindblown tokens, at cost. The account must have topped up or subscribed at
least once; if not, the answer is `402` with `top_up_url`. Send the link to
the person.
Use the device-flow token, or an API key the person makes in Settings, on
<https://mindblown.ai>. A key starts `mb_` and works on these two routes only.
```bash
curl -X POST https://api.mindblown.ai/me/assets \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"kind":"image","prompt":"a small red robot, full body, plain background"}'
# 202 {"id":"…","status":"queued",…}
curl https://api.mindblown.ai/me/assets/ID -H "Authorization: Bearer $TOKEN"
# {"status":"done","url":"https://play.mindblown.ai/__mb/gen/<sha>.png","cost_usd":0.0528,…}
```
Poll every few seconds. A picture or a sound takes under a minute; a 3D model,
a rig or a clip can take several. `status` is `queued`, `running`, `done` or
`failed` (with `error`). The `url` is public and permanent: put the file in the
page, or load it from the page.
| `kind` | Needs | Optional | File |
|---|---|---|---|
| `image` | `prompt` | `size` (`square`, `wide`, `tall`), `image_url` to edit a picture | png |
| `cutout` | `image_url` | | png |
| `model3d` | `image_url` | `faces` (40,000–1,500,000), `pbr` | glb |
| `rig` | `model_url` (at most 300,000 faces) | `height_meters` | glb |
| `sound` | `prompt` | `seconds` (0.5–22), `loop` | mp3 |
| `speech` | `prompt` (the words) | `voice` | mp3 |
| `video` | `prompt` | `image_url` to animate, `seconds` (5–15), `resolution` (`480P`, `768P`) | mp4 |
A 3D model is made from a picture: make an `image`, then pass its `url` as
`image_url`. The subject must fill at least half of that picture, or the model
fails: ask for one object, full body, on a plain background. An `image_url` or
`model_url` must be `https`. At most 8 assets of one account are made at a
time; the 9th answers `429`.
## What runs inside a published page
Every page we serve carries one object of ours, `window.mindblown`, injected at
publish time. You do not add it and you cannot opt out of it.
Through it a page learns **who is looking** (their handle, and a stable id to
keep their things under), says **when a run starts and ends**, reports anything
else that happened, and reads the visitor's own **sound switch**.
**The whole of it is in `references/sdk.md`, beside this file.** Read it
before you write a page that has runs, scores, saved progress or a name in it.
### The documents
Each one is beside this file, or is a skill of its own. Read the one that fits
before you write that part of the page.
| Read | When the page |
| --- | --- |
| `references/sdk.md` | uses `window.mindblown`: runs, scores, saved progress, the viewer's name |
| the `mindblown-multiplayer` skill | is used by two or more people together |
| `references/inventory.md` | takes the person's own car: a slot and our picker |
| `references/generate.md` | makes text or pictures from what a viewer types |
## Private and public
A published page is live at its own URL immediately, and it starts **private**:
anyone with the link can open it, and it is not on the person's profile, in
the feed or in search. Making it **public** puts it there. That is a second,
separate choice, and it is theirs:
```bash
curl -X PATCH https://api.mindblown.ai/me/games/GAME_ID \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"listed": true}'
```
Do not make a page public without asking the person first.
## What you should tell the person
Two things, before you publish for them:
1. **A published page opens for anyone with the link.** Private only keeps it
off their profile, the feed and search; it does not lock the link. Do not
publish anything they would not put on the open web.
2. **It is on their account.** They signed in; the page is theirs, not yours.
They can see it and manage it without you.
## What we will not host
Read this line first, because it is the one that is easy to get backwards: **we
do not judge whether the content is good.** Not the style, not the fidelity, not
the subject, not whether it looks finished. That is the person's call and it is
never ours. A page that renders is a page we host.
What we refuse is harm, which is a different axis:
- **Impersonation.** A page presenting itself as a real company, product,
person, or publication it is not — their name, their branding, their domain.
- **Deception that operates.** Sign-in screens, payment forms, or account
pages that collect credentials or card details under a false identity.
Whether it is "only a mock" does not change what it does to whoever opens it.
- **Fabricated records** shown as genuine: receipts, invoices, transcripts,
test results, reviews, screenshots of messages nobody sent.
- **Malware.** Anything whose purpose is to attack, mine, hijack, or track the
person who opened it.
- **Content targeting a private individual** — their address, their photographs,
material made to harass them.
- **Anything illegal where the page is served.**
We remove these on report, and repeat offences end the account. Everything we
remove, we keep the bytes for, so a mistaken removal can be undone.
If you are unsure whether a page crosses one of these, publish nothing and ask
the person. You will usually be asking about impersonation, and they will
usually know.
## Limits
| | |
|---|---|
| Page size | 200 MB per version, all files together |
| Files | 2,000 per version |
| Publishes | 60 per hour per account |
| Reads | 600 per hour per account |
Every response carries `X-RateLimit-Remaining` and `X-RateLimit-Reset`. A `429`
carries `Retry-After` in seconds.
## Errors
```json
{ "error": { "code": "entry_not_in_files", "message": "entry names index.html, which was not uploaded" } }
```
| Code | Status | What to do |
|---|---|---|
| `authorization_pending` | 400 | Keep polling. The human has not finished signing in. |
| `slow_down` | 400 | Double your polling interval. |
| `expired_token` | 400 | The device code lapsed. Ask for a new one. |
| `invalid_grant` | 400 | The refresh token is finished. Start the device flow. |
| `no_token` | 401 | Send `Authorization: Bearer <access_token>`. |
| `token_rejected` | 401 | The token was refused or names no account. Refresh it, then sign in again. |
| `not_multipart` | 415 | Send the upload as `multipart/form-data`. |
| `no_files` | 400 | Attach at least the entry file. |
| `entry_not_in_files` | 400 | Upload the file named by `entry`. |
| `bad_path` | 400 | A file name is absolute, contains `..`, or is not portable. |
| `duplicate_path` | 400 | Two uploaded files have the same path. |
| `too_many_files` | 400 | Over 2,000 files. |
| `payload_too_large` | 413 | Over 200 MB. Shrink the assets. |
| `rate_limited` | 429 | Over the publish ceiling. The message says how long to wait. |
| `blocked_origin` | 422 | A file loads from a CDN. `render.missing` names the URLs; publish them with the page. |
| `storage_unavailable` | 503 | Ours. Publishing is off on this deployment; nothing was written. |
| `token_check_failed` | 502 | Ours, not yours. Wait and retry with the same token. |
A page that uploads cleanly but does not render is **not** in this table. It
answers `422` with a `render` object, because nothing about the request was
wrong — see [If the page does not render](#if-the-page-does-not-render).
Referenced files: 4 — file references are metadata, not package contents.
mindblown-multiplayer42.8 KB
View saved version →
---
name: mindblown-multiplayer
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."
---
<!-- GENERATED by scripts/build-plugin-skills.py from mindblown-web/src/pod/multiplayer.md. DO NOT EDIT. -->
> Publish with the `mindblown` skill. When the mindblown tools are connected,
> ask for a test seat with `create_test_seats` (`page_id`, `count`, `seats`):
> it answers the same JSON as `POST /me/test-seats/<game_id>` below, and you
> need no token. Without the tools, use that HTTP call with the token the
> `mindblown` skill's sign-in gave you.
# Playing together on mindblown
A page where more than one person is in the same place at once -- a table, a
match, a shared board, everybody moving at once or one at a time. This is the
part of the SDK (`references/sdk.md` in the `mindblown` skill) for that, and only that. Everything here is
`window.mindblown` (`mb` below), the same object the SDK describes.
**Read this whole file before writing the first line of a page that seats two
people.** Two things are different from every other page: the rules of your
page run in a file that runs on the server, and your page draws what the
server says rather than what it worked out itself. The rules in here are the
ones the server enforces, and a refusal will name this file.
## Contents
| Section | What it settles |
| --- | --- |
| [The shape](#the-shape) | what runs where |
| [The lobby and the match](#the-lobby-and-the-match) | `play`, `lobby`, `match` -- how people get to the table |
| [The room is the authority](#the-room-is-the-authority) | where every fact about a room comes from |
| [Seats](#seats) | numbering, and who goes first |
| [The invitation](#the-invitation) | the name, the link, `shareUrl` |
| [Accounts](#accounts) | why a seat needs one, and what your page does about it |
| [Testing it](#testing-it) | a second person, so you can open a page for two |
| [`rules.js`](#rulesjs) | the five exports, their arguments, what they may not do |
| [`mb.live`](#mblive) | the channel: sending, hearing ticks, closing |
| [The tick](#the-tick) | what every seat receives, twenty times a second |
| [Turns on live](#turns-on-live) | a turn-based match, whole |
| [Drawing between ticks](#drawing-between-ticks) | interpolate what the room moves, carry forward what a seat moves |
| [What we decide](#what-we-decide) | and what is yours |
| [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 |
## The shape
Your page ships two things:
- **`rules.js`** at the root of your bundle, served at `https://<your-host>/rules.js`.
The room fetches it when a room opens and runs it. It has no DOM, no
network, no clock of its own. It is the rules, and nothing else.
- **The page**, which draws the lobby and the match, and sends the seat's
input. It decides nothing about the match, and nothing about how people get
to it: which room, the join and the start are ours.
The room runs `step` twenty times a second and sends each seat what `view`
says that seat may see. Everybody draws the same match because everybody is
drawing the room's answer.
**Send one input per tick, which is twenty a second.** The room keeps the LAST
input per seat and clears them as the tick runs, so a second message in the
same tick replaces the first and anything beyond one per tick is thrown away by
construction. Thirty a second is what the limiter tolerates, not a rate worth
aiming at: it cannot make the match more responsive, and on a weak connection
it is the traffic that makes it worse. Send at the tick rate or below, and
predict locally between ticks.
## The lobby and the match
**We run the room. Your page draws it.** Which room, the join, who is present
and when the match starts are the platform's. How all of it looks is yours.
```js
mb.live.lobby((state) => drawLobby(state)); // every change before the start
mb.live.match((channel) => play(channel)); // once, when every seat is here
playButton.onclick = () => mb.live.play({ public: true, seats: 2 });
```
Register both on load, every load. Then:
- **A link joins by itself.** When the address carries `?room=`, we join that
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.
- **A full room starts.** We call `match` once, when every seat is connected,
and not before. Never put a Ready button, an invite control or a share
prompt in front of it -- nobody is missing.
`state` is data, and every pixel of it is yours to draw:
| Field | Meaning |
| --- | --- |
| `room` | the room's name |
| `seats` | how many it holds |
| `mine` | this person's seat, from 1 |
| `present` | `[{ seat, username, mine }]` -- who is connected now |
| `link` | the link to send, already made with `mb.shareUrl` |
| `refused` | absent, or `{ reason, needsAccount }` -- show it; see [Refusals](#refusals) |
The channel `match` hands you is described under [`mb.live`](#mblive):
`channel.room` is the room with everybody named, `channel.seat` is yours.
```js
leaveButton.onclick = () => mb.live.leave(); // closes the socket, frees the seat
```
**`leave` is a control, never a lifecycle event.** Closing a tab is going
offline, not leaving; the match is still theirs. Do not call it on `pagehide`,
`unload` or a reload.
`mb.room.join` and `mb.live.join` are the calls under these, and they still
answer. Do not use them in a new page: every piece of arrival and start logic a
page wrote around them is what this section replaced.
## The room is the authority
**Every fact about a room comes back from the room, and from nowhere else.**
| What you want to know | Where it comes from |
| --- | --- |
| how many seats this table has | `room.seats` |
| which seat is mine | `room.mine` |
| whose turn it is | `room.turn` |
| who else is here | `room.seated` |
| what the table looks like | `room.state` |
| what I am holding | `room.private` |
Never from your own address, never from a message another page sent, never
from what somebody typed, never from what your page asked for a moment ago.
Those are one thing: a value somebody else can set.
```js
mb.live.match((channel) => layOutTable(channel.room.seats)); // the room's answer
```
`seats` is read when a room is opened and never again. A later arrival is
seated at the size the room already has, whatever it asks for.
**Or ask for a room with people in it, and we find one.**
```js
mb.live.play({ public: true, seats: 4 });
```
`public` means *put me somewhere with people*: we take the fullest open room of
your page that still has a seat, and open one when there is nowhere to sit. When
your address carries `?room=` and that room is public with a free seat, it is
the one we take -- the person followed a link to somebody, and that is who they
sit with. `seats` is still the size, and is used only if this ask is the one
that opens a room. The answer is the same room you always get, so everything after it is
unchanged: put `room.name` in a link and you can still invite anybody you like
into it.
**A public room is public because you said so when you opened it.** Nobody
picks one from a list; a person asks and the server seats them. Asking twice
returns the room you are in, so a reload is not a second seat.
**Tell an arrival that somebody is waiting.** Before anybody presses your
button, you can ask who is in a public room of your page right now with a seat
free:
```js
const waiting = await mb.live.waiting();
// [{ room: "quiet_lake_41", seats: 2, present: [{ username: "ada", waited_s: 40 }] }]
if (waiting.length) say(`${waiting[0].present[0].username} is waiting -- play now`);
playButton.onclick = () => mb.live.play({ public: true, seats: 2 });
```
It is for SHOWING, not for choosing. Join with the same `play({ public: true })`:
we seat the person in the first room of that list, which is the fullest. Never
pass one of these names as `room` to steer somebody into it, and never offer
the list as a menu -- that is matchmaking of your own, and it spreads people
across rooms. A named room is never in the list, the person asking is never in
it, and an empty list means nobody is waiting; draw nothing then. People wait
seconds, not minutes, so ask when the page opens and again when your start
screen comes back, not on a timer.
**Asking for a room while you are in one gives you that room.** Pressing your
own Play button twice is one room and not two. A page that wants a SECOND room
calls `mb.live.leave()` first, then `play` again.
**A seat in a public room is held while you are there, and is free when you are
not.** Close the tab and your seat goes back to the pool within about a minute;
come back and you are seated with whoever is playing now, which is what asking
for a public room meant. This is the one place a seat behaves differently from
a named room, where the seat stays yours.
**So do not write your own matchmaking, and delete it if you have.** You can
see only the room you were handed, never the pool, so the only move available
to you is to refuse one and open another -- which spreads the people you were
trying to meet across more rooms. Checking whether the people in a room are
"really there", keeping a list of rooms that went bad, or leaving and re-asking
are all answers to a platform defect that is fixed: ask once and play in what
comes back.
**A room has a maximum, not a starting line.** `seats` is the most people who
may sit down, and the room ticks from the first one: `step` is handed the seats
connected right now, so whether anything happens before the rest arrive is your
decision and not ours. Say it in two places -- `step` advances the state or
stands still, and `waiting` names the seats the match cannot go on without. A
page that cannot begin alone stands still and returns the empty seats from
`waiting`, which is how the person who is missing is told. A page people drop
into plays on and returns `[]`, because nobody is holding anything up.
## Seats
**Seats are numbered from 1.** `mine`, `turn`, `seated[].seat` and `next` all
count from 1. There is no seat 0, and a `next` that names one is refused.
**Seat 1 acts first**, from the moment the room opens.
```js
const piece = room.mine === 1 ? "X" : "O"; // never seat === 0
const myTurn = room.turn === room.mine; // this, and nothing worked out from the board
```
`turn` is the one the server enforces. A page that derives whose move it is
from its own board has a second opinion that can disagree, and when it does the
page locks.
## The invitation
**The link is the invitation.** `state.link` is the room's link, ready to send:
whoever opens it joins that room, because a link joins by itself. Show it while
the room has an empty seat.
```js
mb.live.lobby((state) => {
if (state.present && state.present.length < state.seats) showLink(state.link);
});
```
**Send `mb.shareUrl(...)` and never `location.href`.** Your page runs inside a
frame, and its own address carries a marker of ours. A link built from
`location.href` opens the page without the frame -- no panel, no sign-in, no
way into a seat. `shareUrl(extra)` gives the address the page is reachable at,
with our marker removed, your query kept, and what you pass set on top; `null`
for a key drops it.
**Put the room in the link and nothing else.** Every other value in a link is a
value the person you invited can edit before they open it.
A name you choose yourself is one room everybody on the page shares -- right
for a single open table, wrong for anything private.
**Or ask somebody by name, and we draw the card.**
```js
const asked = await mb.room.invite(room.name);
if (asked.sent) show("Invitation sent"); // asked.sent is a NUMBER
else show("Nobody was asked"); // asked.closed, the card was dismissed
```
`mb.room.invite(name)` opens OUR sheet over your page: two lists the person
already knows -- who they sat down with, who they follow -- and a field that
takes a username. It resolves when the card closes.
**It takes no name and returns none.** You never send us an address, and the
answer is a count: `{ sent: 2 }`, or `{ closed: true }` when nothing went out.
Your page learns that a card was opened and how many invitations left it. There
is no list of people in this SDK and there will not be one.
**There is no accept step.** Whoever was asked takes a seat, and taking it is
the acceptance. Keep the link from `mb.shareUrl` as well: an invitation is one
way in and a link is the other.
## Accounts
**A guest can join an existing named room.** The default join gate permits
this. An invitation-only room refuses a guest. A guest cannot open a named
room or enter a resource room.
**A guest uses a pass, not a sign-in.** Their name is `guest_` and four
characters. The browser keeps the same guest for one day. `mb.viewer` still
reports that the person is not signed in. Their round result is recorded.
**A guest seat lasts while the guest is present.** After 60 seconds without
presence, another person can take it. A named account seat remains durable.
All public seats retain the presence rule above.
**You do not build a sign-in.** An ask to open a named room without an account
returns `refused.needsAccount`. The mindblown panel supplies the sign-in.
After sign-in, the person joins as their account. Your page draws the refusal
until that join succeeds.
**They may close the ask.** Then they are looking at your page with no seat --
a real state, and it has to work. Calling `mb.live.play` again brings the ask
back, so give them one control that means "play with somebody".
**Reading stays open.** `look` works signed out: who is here and what everybody
sees, with no seat and no hidden state.
## Testing it
**A page for two people cannot be tested by one person.** You take seat 1 and
wait, and nothing happens, because nothing can. So ask for a **test seat**: a
second person who exists for half an hour, in one room of one page.
**Do not say a page for two works until two people have met in it.** Running
`rules.js` on your own machine proves the rules and nothing else: not the
arrival from a link, not the join, not the start. Open two seat links, and the
page works when both of them see every seat connected.
Two doors, and the page must be the asker's own either way.
```
POST https://api.mindblown.ai/me/test-seats/<game_id> you hold their session
Authorization: Bearer <the owner's access token>
POST https://api.mindblown.ai/internal/test-seats/<game_id> you are their pod
Authorization: Bearer <the service token>
X-Mindblown-Subject: <the subject token naming the person>
{ "count": 1, "seats": 2 } the pod's body also names "channel", "channel_id"
```
`channel` and `channel_id` must name an address that has written to the bot.
A lookup never creates a person.
The answer is one entry per test person:
```json
{
"room": "quiet-lake-41",
"ttl_seconds": 1800,
"seats": [{
"account_id": "...", "username": "test_9f2c1ab7", "name": "Tester 1",
"seat": 2, "room": "quiet-lake-41", "pass": "...",
"url": "https://mindblown.ai/...?mb_seat=<pass>&room=quiet-lake-41",
"expires_at": "..."
}]
}
```
**Open the `url` in a second window and that window is the second person.** It
joins, it is seen, it plays, it leaves. Your page needs no change and cannot
tell the difference: a test seat comes through the same door as anybody else.
Put the OWNER in the same `room` -- open your own page with `?room=<room>` --
and the two of them are at one table.
**What a test seat is.** A real account with a name and a seat, so everything
your page reads about a person is really there, `mb.viewer` included: it reads
as signed in. It holds a `pass`, not a session -- all that can do is take a
ticket for its own seat.
**What it is not.** It never appears in the people anybody played with, it is
never counted as somebody playing now, it has no inbox, and a match in a room
that held one is not written down. So a page is not made to look busy by being
tested, and a test does not end up in anybody's history.
**So a test seat cannot prove the RECORD** -- everything up to the end of a
match, but not what we wrote down. Check that with real accounts.
**Half an hour, then it is gone.** The account is deleted and its seat with it.
Ask again if you need longer; there is no way to extend one.
**Count what you need.** `count` is how many test people, `seats` is how big the
room is -- leave `seats` out and the room is built for your test people plus
you. Up to fifteen test seats, because a room holds sixteen.
**It is for testing and it is not a player.** Do not use test seats to make a
page look played, to fill a room a real person is waiting in, or to leave a
page running. Nothing stops you; the numbers simply will not move, because
none of them counts a test seat.
## `rules.js`
An ES module with five exports. Nothing about where it runs.
```js
export const config = { seats: 2, watch: true, hold: 60 };
export function init(seats, rng, context) { return state; }
export function step(state, inputs, dt, rng, seats) { return state; }
export function view(state, seat) { return whatThisSeatSees; }
export function waiting(state, seats) { return []; } // seats the match cannot go on without
export function done(state) { return null; } // or how it ended
```
| Export | Called | Arguments | Returns |
| --- | --- | --- | --- |
| `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 |
| `init` | before the first step without saved state | `seats`: connected seat numbers; `rng`; frozen `context` | the starting state |
| `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" |
| `view` | every tick, once per seat | `state`, `seat` | what that seat receives. JSON. |
| `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" |
| `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) |
**Time and randomness come in as arguments.** `rng()` gives a number in
`[0, 1)` from a generator the room seeded; `dt` is ours. Use them. During a
tick the room also answers `Math.random()` from the same generator and
`Date.now()` with the tick's time, so a file that reads them can still be run
again from the room's log (see [What happened in a room](#what-happened-in-a-room)).
**`view` is where hidden information lives.** A hand, fog of war, a bluff:
return to seat 3 only what seat 3 may know. The room sends nothing a `view`
did not return. Everything in `state` that `view` returns to everybody is
public.
**Keep it small and keep it pure.** Under 512KB. No imports of anything but
your own code. A `step` that throws is reported to every seat on every tick
as `error`, in your own words, and the match stands still until it stops.
**`seats` is the list of seats connected right now**, in no particular
order. A seat whose socket dropped is absent from it and sends no input; a
seat that comes back is present again. `init` receives the seats present at
the first tick, which may be one -- a match that needs two waits in `step`
until `seats.length` says so.
**What `done` returns is recorded, and two shapes name a winner.** The room
reports the end once and the platform keeps it -- the seats, the accounts
behind them, how long it ran. Say `{ winner: 2 }` or `{ places: [2, 1, 3] }`
and the platform knows who finished where; add `{ scores: { "2": 40 } }` and it
knows what each seat scored. Anything else is kept whole and names nobody,
which is right for a match that has no winner. This is an offer and not a
requirement: the shape is yours, and these three are the parts we can read.
## `mb.live`
```js
mb.live.match((channel) => { // see The lobby and the match
channel.send(input); // this seat's latest input
const off = channel.on("tick", (tick) => draw(tick));
channel.on("close", ({ code, reason }) => ...);
});
```
A `Channel`:
| Field | Meaning |
| --- | --- |
| `seat` | your seat number, from 1 |
| `room` | the room: `name`, `seats`, `seated` with everybody named |
| `send(input)` | your input. Any JSON. Last write per tick wins, so one a tick is all a room can use -- see above |
| `on(event, fn)` | `"tick"` and `"close"`. Returns a function that unsubscribes |
| `close()` | closes the socket. The seat is still yours; `mb.live.leave()` gives it back |
**The page never holds the socket.** The mindblown panel on your page opens
it, with a ticket your page never sees, and relays what the room says. Your
page speaks to `channel` and nothing else.
### Immutable resources
Create a room from published documents when its rules need a fixed layout.
Each resource belongs to this page and remains unchanged during the attempt.
```js
const requestId = crypto.randomUUID();
const channel = await mb.live.create({
seats: 2,
resources: { vault: published.id },
requestId,
});
channel.on("tick", (tick) => draw(tick));
```
Creation requires a real account and one through four published documents.
Their combined JSON must fit within 64 KiB.
The channel has the same methods as the one `match` hands you.
Keep `requestId` for an explicit retry after a lost response.
Retry the same request; different content produces `IDEMPOTENCY_CONFLICT`.
An identical retry returns the original room without another seat replacement.
A superseded attempt can refuse its ticket with `NO_SEAT`.
The error preserves `requestId` and any room receipt already received.
The rules receive a frozen context before initialization:
```js
export function init(seats, rng, context) {
const vault = context.resources.vault;
return { cells: [...vault.value.cells], moves: 0 };
}
```
Each resource contains `id`, `kind`, `schemaVersion`, `hash`, and `value`.
Ordinary rooms receive `{resources:{}}`.
Saved state survives runtime recovery; recovery does not call `init` again.
The context stays inside the rules unless `view` returns part of it.
A resource room permits one completed attempt.
Its first `done` stops simulation and becomes the final result.
Reconnects receive the same final view and result.
Create another room for another attempt.
Owner removal lets an already-started attempt finish.
Platform removal can stop an attempt.
Temporary authorization failures pause simulation until access returns.
## The tick
Every seat receives, twenty times a second:
```js
{
t: 1789371672003, // the room's clock, ms
tick: 4812, // counts up from 1
dt: 50, // ms since the previous tick
seats: [1, 2], // who is connected right now
waiting: [2], // who the match cannot go on without, from waiting()
view: { ... }, // what view(state, mySeat) returned
done: null, // or what done(state) returned
error: "..." // only when rules.js threw or could not be fetched
}
```
When `error` is present there is no `view`. Show the sentence; it is yours.
A late joiner's first tick is a whole view, so there is nothing to catch up
on. A room with sockets open and no input for three seconds stops ticking
until the next input; your page sees the gap as a pause, not as a fault.
**One socket per seat, and the newest window holds it.** A seat is a person and
joining twice is the same seat, so a second tab, a reload that raced its own
close and a reconnect after a drop all ask for a seat that already has a socket.
The room hands it to the newest and tells the old one `{ closed: "elsewhere" }`
before closing it. Two consequences worth designing for: `seats` never carries
the same number twice, so `seats.length` is a count of PEOPLE; and one person
cannot fill two seats by opening two windows -- to test a match with two
players you need two accounts.
**The view is the budget, and it is the only one you are likely to reach.** The
room sends one to every seat twenty times a second, so a view is paid for
sixteen hundred times a minute at eight seats. Two measurements, both from real
pages: a page that returns the whole board with the parts a seat cannot see set
to `null` sent 5.9KB per seat per tick -- about 1MB a second at eight seats --
and a page that culled by line of sight inside `view` sent 333 bytes, which is
52KB a second. The difference is twenty-fold.
**So cull in `view`, not in the page.** It already exists to decide what one
seat may see, which makes it the only place that can leave something out
honestly -- a page that filters what it draws still downloaded the rest, and a
page that could read it could cheat with it. If a visibility test is expensive,
work it out once per tick rather than once per pair. One page's went from 56
traces to 28, and the cost of a whole tick's views fell from 0.615ms to
0.015ms.
**What the room itself can carry is not the constraint you will meet first.**
Measured against the live room, with a page that culls inside `view`:
| seats | inputs each | ticks a second | gap, typical | gap, worst |
| --- | --- | --- | --- | --- |
| 2 | 15 | 19.8 | 49ms | 68ms |
| 8 | 15 | 20.1 | 50ms | 81ms |
| 8 | 30 | 20.0 | 49ms | 81ms |
| 16 | 30 | 20.0 | 50ms | 102ms |
| 16 | 60 | 20.0 | 50ms | 83ms |
Every message on the wire in those runs was around 464 bytes, envelope and all,
and it did not grow when the input rate doubled. The last row is twice the rate
any page should send, at the seat cap, and it is 960 messages a second into one
room -- the ceiling the platform gives us -- with the clock still at 50ms. Rerun
it yourself with `mindblown-live/probe.mjs`; a number in a document nobody can
reproduce goes stale without anybody noticing.
## Turns on live
A turn-based match is a rules file whose `step` listens to one seat. There is
no other machinery: `state.turn` is a number your `step` moves along, hidden
hands are what `view` keeps back, and `waiting` names the seat whose move it
is so the platform can say so. The room enforces all of it -- nobody moves out
of turn, nobody moves twice, nobody sees a hand that is not theirs -- because
the room is the only thing running the file.
Noughts and crosses, whole:
```js
export const config = { seats: 2, watch: true, hold: 60 };
export function init(seats, rng) { return { cells: Array(9).fill(0), turn: 1, moves: 0 }; }
const 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]];
const winner = (c) => LINES.map(([a, b, d]) => c[a] && c[a] === c[b] && c[a] === c[d] ? c[a] : 0).find(Boolean) || 0;
export function step(state, inputs, dt, rng, seats) {
if (winner(state.cells) || state.moves === 9) return state;
const input = inputs[state.turn]; // one seat is heard
if (!input || !Number.isInteger(input.cell) || input.cell < 0 || input.cell > 8) return state;
if (state.cells[input.cell]) return state;
state.cells[input.cell] = state.turn;
state.moves++;
state.turn = state.turn === 1 ? 2 : 1;
return state;
}
export function view(state, seat) { return { me: seat, cells: state.cells, turn: state.turn }; }
export function waiting(state) { return winner(state.cells) || state.moves === 9 ? [] : [state.turn]; }
export function done(state) {
const w = winner(state.cells);
return w ? { winner: w } : state.moves === 9 ? { draw: true } : null;
}
```
A card table is the same shape with a `deck` array in `state`, shuffled with
`rng` in `init`, dealt by `step`, and hidden by `view` -- return to seat 3 only
`state.hands[3]`. A trade is `step` moving items between two hands when the
seat holding the turn asks. A showdown is `view` no longer hiding. A deadline
is a number in `state` compared with the tick's clock.
The page for a turn-based match sends its move once, on the tap, and draws on the
tick. It does not need thirty inputs a second.
**The old turn-based wire** -- `mb.room.look` and `mb.room.act` with `next`,
`hold`, `deck`, `deal`, `give`, `take`, `show` -- is deprecated as of
2026-09-15. It keeps answering for the pages already on it until 31 December
2026 and is written down at the end of this file. New pages write the file
above.
## Drawing between ticks
Twenty ticks a second is not sixty frames. Draw at your frame rate. A page
that draws only on ticks looks like twenty frames a second, because it is.
How to draw between ticks depends on who moves the thing.
**What the room moves, interpolate.** A board, a score, anything `step`
moves: keep the previous view and its `t`, and draw a point `dt`-proportional
between the last two. It is drawn about one tick late, and that is right,
because the room is the authority on it.
**What another seat moves, draw where it is NOW, not where it was.** When each
seat sends its own position -- a vehicle, a body, a pointer -- the newest
sample you hold for another seat is already old when it arrives. Every frame,
carry it forward: its position plus its velocity times the sample's age, with
its turn rate if you send one. When the next sample arrives, blend toward it
over about 100ms rather than jumping. Stop carrying it forward after about
250ms without a sample, and hold it where it is, so a dropped seat does not
drive off on its own. Send each sample with its time on the room's clock (the
last tick's `t` plus the time since that tick), so the other page knows how
old it is.
**Do not hold samples back to smooth them.** A buffer that draws other seats
some milliseconds in the past adds all of those milliseconds to what a person
sees, however fast the delivery under it is.
**Prediction is optional and it is the same file.** A page may `import`
`/rules.js` itself and run `step` locally with its own input to draw ahead
of the next tick, then snap to the view when it arrives. Start without it.
## What we decide
Ours, and no page can change it:
- **who you are** -- the seat comes from a ticket the API signed, never from
a message,
- **what you can see** -- `view` is applied by the room, never by a page,
- **the clock** -- `dt` is ours; nobody runs their own faster,
- **the dice** -- `rng` is ours and seeded,
- **which rules** -- the file at your host when the room opened. A republish
never changes a match in progress; the next room gets the new file.
Yours: whether `step` is right. We do not know your rules. What we know is
that every seat is running the same ones.
## What we keep
These rematch rules apply to ordinary rooms. Resource rooms permit one completed attempt.
**`done` is how a round is written down, and a room that never ends keeps
nothing.** When `done` returns something, the room reports it and we record the
match: who held each seat, how long it ran, and the result whole. When `done`
goes falsy again the room has started another one, it gets the next sequence,
and that one is recorded too. **So a page with rounds should end each round
through `done` and let the next begin — not return `null` for ever.**
```js
export function done(state) {
// the ROUND ended, and the room plays on
if (state.round.over) return { places: state.round.order };
return null;
}
```
A file that answers `null` for ever is a file we hold no history for. Measured
2026-09-18: a page that had been played for hours in dozens of rounds had
**zero** records, because its `done` said `return null; // the room never ends`.
**Return `places`, not only `scores`.** `places` is the finishing order —
`[3, 1, 2]` means seat 3 came first — and it is the one thing that means the
same in every page. A score is yours: two hundred is a triumph in one page and
a rout in another, so nothing can compare it with anything. Both are kept, and
`winner` is accepted as shorthand for a single first place:
| in the result | what we do with it |
| --- | --- |
| `places: [3, 1, 2]` | seat 3 first, seat 1 second, seat 2 third |
| `winner: 2` | seat 2 first, everybody else unplaced |
| `scores: { "1": 240, "2": 90 }` | kept per seat, compared with nothing |
Anything else stays in the raw result and names no winner.
Read your account's recorded outcomes with `mb.matches.list({limit:20})`.
The reply contains `{items,nextCursor}`.
Each item includes your outcomes, match times, room, sequence, and available resource provenance.
Pass `nextCursor` to read the next page.
Only exact account bindings appear. Test rooms and inferred participation remain excluded.
Other accounts' outcomes and raw result JSON remain private.
## What happened in a room
**A room writes down every tick as your rules saw it**: the tick's number, its
time, `dt`, the seats in order, every seat's input, and a digest of every view.
With the room's seed and the state where the log starts, that is enough to run
the room again, tick for tick. A room keeps the last hour.
Only the page's owner reads a room's log (`GET /me/rooms/{page_id}/{room}/log`).
It holds nothing your rules file was not already given.
## Who is watching
Each seat carries `online` and `seen` -- seconds since that person's page last
asked about the room. Presence is the heartbeat of `look`; nothing to send.
**Presence is not membership.** Somebody offline still holds their seat. Show
it; do not free a seat because a tab closed.
```js
room.seated.forEach((s) =>
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`:
| `reason` | what happened | what to do |
| --- | --- | --- |
| `NO_SEAT` | the seat was lost before the socket opened | call `mb.live.play` again |
| `OPENED_ELSEWHERE` | the same person opened this room in another window | say so; that window has the seat |
| `ROOM_UNREACHABLE` | the room closed five times before the match began | offer `mb.live.play` again |
| `ROOM_CLOSED` | the room has ended | show the result |
| `BAD_ASK` | the room name or page id was malformed | a bug in the page |
| `SERVICE_UNAVAILABLE` | live rooms are off on this deployment | say so; the turn-based room still works |
An `error` on a tick is not a refusal: the socket is open and the room is
telling you your file is broken.
And `ROOM_FULL` (every seat taken), `ROOM_SIZE` (a room seats 2 to 16, and a
new one has to say) and `NOT_INVITED` (a private room its opener keeps to the
people they invite).
## Not yet built, said plainly
- `config.watch` and `config.hold` are read by nobody yet. Spectators cannot
connect; a dropped seat is simply absent from `seats` until it returns.
- `waiting` reaches every seat on the tick, and the room now tells the platform
which seats a match has waited on for a minute. The inbox that shows it to
somebody who is away is being built.
- A key can be rotated; it has not been.
## A whole page
mindblown adds `window.mindblown` to every page when it publishes it, so the
page loads nothing for it.
```html
<canvas id="c" width="400" height="300"></canvas>
<div id="lobby"></div>
<button id="play">Play with somebody</button>
<script>
const mb = window.mindblown;
let last = null;
mb.live.lobby((state) => {
const box = document.getElementById("lobby");
if (state.refused) { box.textContent = state.refused.needsAccount ? "Sign in to play" : state.refused.reason; return; }
const here = state.present.map((p) => p.username || "seat " + p.seat).join(", ");
box.textContent = here + " -- waiting for " + (state.seats - state.present.length) + ". " + state.link;
});
mb.live.match((channel) => {
document.getElementById("lobby").textContent = "";
channel.on("tick", (tick) => { last = tick; });
let dy = 0;
addEventListener("keydown", (e) => { dy = e.key === "ArrowUp" ? -1 : e.key === "ArrowDown" ? 1 : dy; });
addEventListener("keyup", () => { dy = 0; });
setInterval(() => channel.send({ dy }), 1000 / 20);
requestAnimationFrame(function frame() { draw(last); requestAnimationFrame(frame); });
});
document.getElementById("play").onclick = () => mb.live.play({ public: true, seats: 2 });
</script>
```
A person who opens the link lands in the room with no click. A person who
presses the button is put with somebody. Both see the same lobby, and the match
starts for both when the second one connects.
## The old wire, until 31 December 2026
`mb.room.look` and `mb.room.act`, and the server operations under them, were
how a turn-based match was played before the rules ran on the server. They are
deprecated as of 2026-09-15: every answer carries a `Deprecation` header, and
they keep answering for the pages already written on them. **Do not write a new
page against anything in this section** -- the file under "Turns on live" does
all of it, and the room enforces it.
### A turn
```js
await mb.room.act(room.name, {
move: { played: "ace" }, // yours. We never read it
state: { pile: 3 }, // patches what everybody sees
mine: { note: "hello" }, // patches what only you see
next: 2, // hand the turn to a seat
hold: 30, // seconds before anybody may take it back
close: true, // end the room
});
```
| Field | Rule |
| --- | --- |
| `move` | recorded in the log as sent. Never read by us |
| `state`, `mine` | **patches**: a key you send replaces that key, a key you leave out is untouched |
| `next` | where the turn goes. Leave it out and it goes to the next seat. Only the seat holding the turn may direct it |
| `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 |
| `close` | ends the room. No further turns |
**Send `hold` whenever you hand the turn to somebody who might not answer.** A
seat holding the turn is holding the room; if they close the tab, it stops for
everybody. Past `hold`, taking the turn is the same call as using it, and the
log shows who took it.
**`next` is every order but plain rotation** -- a skip, a reversal, a second
turn, or handing the turn to the person you are trading with so they can
answer.
### What we decide
Things a page must not decide for itself, because the other seats could not
check it:
```js
await mb.room.act(room.name, {
roll: { dice: 2, sides: 6, as: "throw" }, // we throw them
deck: { key: "bank", items: [...], shuffle: true }, // a supply nobody reads
deal: { from: "bank", to: 3, key: "hand", count: 2 },
give: { to: 1, key: "hand", items: ["ore"] }, // out of YOUR hand
take: { from: 2, to: 1, key: "hand", count: 1 }, // nobody chooses which
show: { key: "hand", as: "hand2" }, // turn YOUR hand face up
});
```
| Field | What it does |
| --- | --- |
| `roll` | dice. The result lands in `state[as]` as `{ dice, total }`, public |
| `deck` | loads a supply into the room. It is in no answer to anybody |
| `deal` | moves the top `count` of a supply into a seat's hidden state |
| `give` | moves named items out of **your own** hidden state to another seat |
| `take` | moves `count` items nobody chooses from one seat to another |
| `show` | copies **your own** hidden key into the public state |
**A key we have written is a key you may not write.** Once `deal`, `give` or
`take` has touched `hand`, a `mine` patch to `hand` is refused. Keys we never
touched stay yours.
**A trade is two gives and a handover.** Give what you offer, `next` to the
other person, they give back and hand the turn on.
**Hidden dice are a deck, not a roll.** `roll` is public by design. For dice
only one seat may see, load a deck of faces and `deal` from it.
#### The showdown
**A reveal is ours or it settles nothing.** A page can print anything; `show`
is the server publishing the hand it dealt, and the log says so.
```js
await mb.room.act(room.name, { show: { key: "dice", as: "dice" + room.mine } });
```
- `as` gives every seat its own public key. Without it the hand lands under its
own name and four seats showing at once overwrite each other.
- **A bare `show` does not wait for your turn.** It moves nothing, patches
nothing, and leaves the turn and its deadline where they are. A `show` with
anything else beside it is an ordinary turn, refused with `NOT_YOUR_TURN`
when it is not yours.
- Showing a key you were never dealt is `NOT_HELD`.
- A show is a copy; the hand stays in `private`.
#### What is guaranteed
Ours, and no page can talk us out of it: who you are (the seat comes from the
session), when you may act, what you can see, what a die landed on and how a
deck was shuffled, and the order of the log.
Yours: whether a legal-looking move is a right move. `state` is public and the
log is shared, so every copy of your page can check the table against it.
### Staying up to date
```js
let seq = room.seq;
for (;;) {
const now = await mb.room.look(room.name, { since: seq, wait: 25 });
seq = now.seq;
now.moves.forEach(apply);
}
```
`wait` holds the answer until the room moves -- somebody acts, somebody takes
a seat, the turn changes hands -- or the seconds run out; a quiet room answers
on the deadline with what it has. Thirty is the most we hold. Without `wait`,
poll every second or two.
This was the old wire's poll. A match runs on the tick now; see
[The tick](#the-tick).