---
name: diskroom-disk
description: >-
  Write, debug or port a disk for diskroom — one JavaScript file that runs in
  every participant's browser inside a sandboxed frame and talks to the other
  copies of itself through room.send / room.on. Use whenever the task mentions
  diskroom, a "disk", or a small multiplayer game or toy for it.
---

# Writing a disk for diskroom

## What you are writing

One file of JavaScript. A room hands that file to every participant's browser,
each browser runs its own copy inside a sandboxed frame, and the platform
relays messages between those copies. That is the entire platform.

Four consequences decide everything else in this document:

- **Your code runs once per player, on that player's machine.** There is no
  copy on the server, no referee, no authority. Nothing is "the server" unless
  your disk picks a player to be one.
- **The platform relays bytes and nothing more.** No ticks, no state, no
  synchronisation, no matchmaking, no scores, no saves. Whatever a game needs
  beyond message passing, your file contains.
- **No build step and no dependencies.** No `import`, no npm, no bundler, no
  CDN — the frame's policy will not load them (see [The sandbox](#the-sandbox)).
  Whatever you need, you write.
- **The bytes are the identity.** A disk is named by the blake3 hash of its
  file. Change one character and it is a different disk with a different hash,
  not a new version of the old one.

Disks are small by design: one file, no dependencies, a hard ceiling of 256 KB.

## The shape of the file

A header comment, then code:

```js
/**
 * @disk     claim
 * @author   your-name
 * @version  1
 * @players  2-8
 * @about    Claim squares on a shared grid. The host owns the board.
 * @tags     board, turn-based
 * @image    https://example.com/claim.png
 */

// ...your code from here on
```

The header is not decoration: it is the record the platform stores, and it is
parsed by the same code in the browser and on the server. The rules are strict
and every violation is a refusal to publish, naming the line:

| Rule | Why it bites |
|---|---|
| `/**` is the **very first byte** of the file | not a blank line, not a comment, not a shebang before it |
| every line inside starts with `*` | and reads `@key value` |
| `@disk @author @version @players @about` are required | `@tags` and `@image` are optional |
| an unknown key is an error | `@vesrion` is refused, not ignored |
| a key may appear once | |
| `@players` reads `min-max` | `2-8`. A single number is not accepted |
| `@disk` ≤ 32 chars, `@author` ≤ 32, `@version` ≤ 16, `@about` ≤ 280 | no control characters in any of them |
| `@tags` ≤ 8 tags, ≤ 24 chars each | comma-separated, lowercased for you |
| `@image` is an absolute HTTPS URL, ≤ 2048 chars, with no credentials in it | it is exposed only after the disk is verified, and only through the configured signed image mirror; the disk itself never fetches it |
| `1 ≤ min ≤ max ≤ 8` | 8 is the room ceiling |
| `@players` is a claim, not a rule | the lobby shows it beside who is actually there; nothing seats a room by it |
| the header is written in **English** | every letter must be an ASCII one, in every field |
| `@disk` is letters, digits and `_` | `claim_two` — it is a name, like a nickname or a room's |
| the rest take ordinary punctuation | spaces and `-` `.` `'` in a name or a version, a whole sentence in `@about`. Anything outside that is a refusal naming the line |
| no profanity in `@disk`, `@author`, `@about`, `@tags` | the server refuses the upload; it does not tell you which word |

`@author` is a claim, not a credential — diskroom has no accounts and nothing
verifies it. Do not build anything on top of a name.

## The whole API

```js
room.me       // { id, nick, avatar } — you. null until the disk runs in a room
room.players  // [{ id, nick, avatar }] — everyone in the room right now
room.isHost   // whether the platform currently calls you the host

room.send(payload)                    // to everyone else in the room
room.send(payload, { to: playerId })  // to one player

room.on('message',    (from, payload) => {})   // from is a player id
room.on('join',       (player) => {})
room.on('leave',      (player) => {})
room.on('hostchange', (hostId) => {})          // an id, not a player
```

That is all of it. `id` is a number, `nick` is the name the person chose (≤ 16
characters), `avatar` is an index into the platform's avatar palette.

`'join'` and `'leave'` hand you a whole player, because the one who left is
already out of `room.players` by then — and that event is where you drop
whatever you had filed under their id. `'hostchange'` hands you a bare id — the
new host is in the roster, so look them up there if you need the name.

`room.on` keeps **one handler per event**. Registering a second `'message'`
handler replaces the first — fan out inside your own handler.

### Six things that look obvious and are not

| It looks like | What actually happens |
|---|---|
| `room.send` reaches everyone | everyone **except you**. Apply your own action locally; nothing comes back to echo it |
| the payload is a structured clone | it is `JSON.stringify` / `JSON.parse`. `Map` and `Set` arrive as `{}`, `Date` as a string, `undefined` and functions vanish. The one exception: a `Uint8Array` or `ArrayBuffer` travels as raw bytes and arrives as a `Uint8Array` |
| `join` means their disk started | it means they walked into the room. Each participant presses "Run disk" for themselves, whenever they like — nobody can start it on somebody else's machine — and a message sent to somebody who has not pressed it is dropped |
| `leave` means the tab closed | it means they left the room; the page reconnects after a dropped connection, which looks like `leave` then `join`, with a **new id**. A disk that stayed mounted sees `room.me`, `room.players` and `room.isHost` synchronised in place |
| `room.me` is always there | a disk runs outside a room in two places — the studio's **Test locally** and **Run solo** on the disk's own page — and in both `room.me` is `null`, `players` is `[]`, `isHost` is `false`. A disk that throws there shows a blank frame and says nothing about why |
| the host is a role the platform manages | it is a bare fact: whoever the platform currently names. It changes the moment the current host leaves — mid-game, without asking you |

### What the delivery guarantees are

- **FIFO per sender.** Two messages from the same player arrive in the order
  they were sent.
- **One room order.** The room accepts messages from every player one at a time.
  Machines that receive the same messages receive them in that order. You cannot
  predict which of two concurrent sends the room will accept first.
- **At most once.** A dropped connection loses messages, and **nothing is ever
  replayed** — not for a reconnecting player, not for a newcomer.
- **A slow player costs everyone the message.** The room hands a message to all
  of its recipients or to none of them, so a browser that has stopped taking
  messages does not lose them by itself: the send is refused for the whole room
  until its queue drains. That is what keeps the order above one order — but it
  means any single message can be missing for everybody. Never let one message
  be the only thing that carries a change of state.

| Limit | Value | What happens at the edge |
|---|---|---|
| payload size | 4 KiB encoded | `room.send` throws `RangeError` |
| send rate | 60/s sustained, 120 burst | over the ceiling the message is **dropped**, your code sees no error |
| what one player sends | 512 KiB/s **after fan-out** | the same: dropped, no error |
| what a whole room sends | 1 MiB/s after fan-out | the same, and it is shared by everyone in the room |
| players per room | 8 | |
| pending messages per player | 256 | while the queue is full the room refuses sends **for everyone**; a player whose browser takes nothing at all for half a second is disconnected |

Twenty sends a second per player is a comfortable game. Forty-five is the wall,
not a target.

**"After fan-out" is the thing to hold on to.** `room.send` without a `to` is
copied to every other seat, so in a full room one 4 KiB message is 28 KiB on the
wire and is charged as 28 KiB. The same message with a `to` is charged as 4 KiB.

That is why the two rate ceilings are not both available at once. In a full room:

| your payload | charged per message | what actually limits you |
|---|---|---|
| 512 B | 3.5 KiB | the send rate — 60/s |
| 1.2 KiB | 8.4 KiB | both, at once |
| 4 KiB | 28 KiB | the byte ceiling — **18/s**, not 60 |

So the trade is explicit: the larger the payload, the fewer of them a second.
Small and often beats large and rare, and a delta beats a snapshot.

If your disk keeps its state in one place and one player broadcasts it to the
rest, that player is the one who meets these ceilings — the others are nearly
silent. Size the snapshot against 512 KiB/s divided by the number of listeners,
not against what one message may weigh.

## The sandbox

The frame lives on its own origin, has an opaque origin of its own, and carries
this policy:

```
default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline';
img-src data: blob:; media-src data: blob:; font-src data:; connect-src 'none';
form-action 'none'; base-uri 'none'; frame-src 'none'; worker-src blob:;
frame-ancestors <the platform>
```

| You have | You do not have |
|---|---|
| the whole DOM, CSS, canvas, WebGL, WebAudio | the network: no `fetch`, `XMLHttpRequest`, `WebSocket`, `EventSource`, `sendBeacon` |
| `requestAnimationFrame`, timers, `performance.now()` | storage: no cookies, `localStorage`, `sessionStorage`, `indexedDB`, `CacheStorage` |
| pointer lock, gamepad, sound | fullscreen — only the platform's stage can take the viewport; `eval` and `new Function` — there is no `unsafe-eval` |
| workers created from a `blob:` URL | `import`, dynamic `import()`, external scripts, stylesheets, fonts |
| images and audio from `data:` and `blob:` | the clipboard, geolocation, camera, microphone |
| your own globals, your own everything | the page around you: the platform, its session, the address bar |

Practical fallout:

- **Inline your assets** as `data:` URIs, generate them, or draw them.
- **A worker still has no network.** It inherits the same policy.
- **Fullscreen keeps the platform's border and label on screen.** A disk can
  never own the whole viewport, by design.
- **Sound is not blocked, autoplay policy still applies** — start audio from a
  user gesture.
- **Every violation is counted in front of the person running your disk**, in
  the studio and in the room. A disk that trips the counter constantly looks
  like one that keeps trying to get out.
- `console.log` from the frame shows up in the room's DISK CONSOLE panel (lines
  clipped at 2000 characters, last 500 kept) and in DevTools.

## The five rules that decide whether a disk survives real people

**1. Everyone starts at their own moment.** There is no "match start". Someone
will run your disk five minutes in, when a game is already going.

**2. Nothing is replayed, so whoever knows has to tell.** A newcomer sees an
empty screen until a player who already has the state sends it. That handshake
is your job:

```js
// A disk that has just started asks; whoever knows answers privately.
room.send({ t: 'hello' });

room.on('message', (from, msg) => {
  if (msg.t === 'hello' && room.isHost) {
    room.send({ t: 'state', board, turn }, { to: from });
  } else if (msg.t === 'state') {
    board = msg.board;
    turn = msg.turn;
    draw();
  }
});
```

If your state does not fit one payload, cut it into pieces — and **send them on
a timer, not in a loop**. A loop long enough to empty the burst allowance has
its tail dropped silently, and the newcomer is left holding half a state with
no error to explain it. Give each piece an index so the receiver can tell a
continuation from a repeat, and drain them at a rate that leaves room for
whatever else your disk is saying meanwhile:

```js
const catchUp = [];                       // pieces waiting to go out

room.on('message', (from, msg) => {
  if (msg.t === 'hello') {
    for (const piece of split(state)) catchUp.push({ to: from, piece });
  }
});

setInterval(() => {                       // one piece a tick — 20/s
  const next = catchUp.shift();
  if (next) room.send({ t: 'state', ...next.piece }, { to: next.to });
}, 50);
```

**3. Pick your authority yourself.** The simplest arrangement that works: the
host owns the state, everyone else sends intents and applies what comes back.
Handle the host leaving — `hostchange` can fire in the middle of a turn:

```js
room.on('hostchange', (hostId) => {
  // The new host keeps the state it happens to have. If that is not good
  // enough for your game, make the new host ask the room to confirm it.
  if (room.isHost) console.log('I own the board now');
});
```

**4. Delivery order is not authority.** The room chooses one order, but a sender
does not receive its own message and usually applies its action locally first.
Two players claiming the same square at the same instant can therefore still
disagree unless one player decides (rule 3), or the operation is commutative and
order stops mattering.

**5. Keep the event loop free.** The frame sends a heartbeat every 500 ms; go
quiet for three seconds and the platform shows NOT RESPONDING next to a "Kill
script" button, which is exactly what a person will press. Chunk long work
across frames instead of blocking.

## Testing what you wrote

1. Open the studio at `/disk/new`, paste the file in. The manifest panel
   parses it as you type; a mistake names the line.
2. **Test locally** mounts the disk in a real sandboxed frame with no room
   around it. Check the DISK CONSOLE panel and the blocked-calls counter. A
   published disk gets the same run from **Run solo** on its own page.
3. **Multiplayer lab** starts two independent copies — up to four — and relays
   their opaque payloads in the browser, by the room's rules: a delay you pick
   for the whole lab, each sender with a leg of its own, the ceilings above
   charged the way the server charges them. Send from both sides, add a late
   player, restart one copy, make the host leave. The lab stands in the studio
   and on a disk's page alike.

   **Cut link** is the one a disk fails: one seat's socket drops while its disk
   keeps running, everything it sends into the dead socket is lost with no
   error anywhere, and **Reconnect** brings the seat back under a **new player
   id**. If your disk files state under `room.me.id`, this is where you learn.
4. Then a real room: **Publish**, create a room with the disk, open the room
   link in a second window (a separate browser profile gives you a second
   nickname), and press "Run disk" in both. Try it: run the disk in one window
   first and only then in the other — that is the late-join path, and it is the
   one that breaks. One browser profile holds six room sockets at once, which
   tabs left open from testing reach sooner than you would think; the room says
   so in words rather than reconnecting for ever.

Before you hand the file over, check every line of this:

- the header parses and `@about` says what the disk does in one line;
- it does not throw when `room.me` is `null`;
- a player who starts the disk late ends up seeing the same game;
- the host leaving does not freeze the room;
- no more than ~20 sends a second per player, and a state handshake paced
  rather than looped;
- no external URL in executable code — `@image` in the manifest is the sole
  exception; after the disk is verified, the platform may send that image
  through its configured signed mirror;
- no `eval`, no `new Function`, no `import`;
- the console is not a firehose.

## Publishing

The studio's **Publish** sends the source; the server parses the header again
itself and hashes the bytes. The disk is then in the library under that hash.

There is no editing: a changed file is a different hash and a different disk,
and neither of the platform's own marks survives that. A verification mark says
somebody from diskroom read those bytes; a PROMOTED badge says only that
diskroom put the disk at the top of the shelf and nothing about the code. Both
are attached to bytes, and an edit is other bytes.

Any card in the library opens in the studio as a copy, which is how to read a
disk somebody else wrote — including the ones diskroom publishes itself.

## When it does not work

| What you see | What it usually is |
|---|---|
| blank frame, empty console | the disk threw at startup — very often on `room.me` being `null` in the studio |
| works alone, nothing happens with two players | you are waiting for your own message to come back. `room.send` never echoes |
| the newcomer sees an empty game | no `hello`/state handshake; nothing is replayed for anyone |
| the newcomer gets *part* of the state | the pieces went out in a loop and ran past the burst. Pace them |
| messages stop under load | over 60/s, or over 512 KiB/s once multiplied by the listeners: dropped silently. Throttle, or shrink the payload |
| messages stop only in a **full** room | the fan-out is the multiplier — the same code is under the ceiling with three players and over it with eight |
| `RangeError` from `room.send` | the payload passed 4 KiB. Send a delta, not the world |
| NOT RESPONDING | you blocked the event loop for over three seconds |
| the room hangs on RECONNECTING | a dead network — or, if it names one, the ceiling of six room sockets per browser profile, which is easy to reach with tabs open from testing |
| the blocked-calls counter climbs | something in the file reaches for the network, storage or `eval` — often a stray analytics or font URL |
| `Map`/`Set` arrive empty | payloads are JSON. Send arrays or plain objects |

## A complete disk

A small game that is nonetheless real: a shared grid, first click wins the
square, the host owns the board, and a late joiner is caught up.

```js
/**
 * @disk     claim
 * @author   example
 * @version  1
 * @players  1-8
 * @about    Claim squares on a shared grid. First click wins; the host owns the board.
 * @tags     example, board
 */

const SIZE = 8;
let board = new Array(SIZE * SIZE).fill(null);   // cell index -> player id

// ── the screen ─────────────────────────────────────────────────────────────
document.body.style.cssText =
  'margin:0;height:100%;background:#1f1f1f;display:flex;align-items:center;justify-content:center';

const grid = document.createElement('div');
grid.style.cssText =
  'display:grid;gap:3px;width:min(90vmin,90vw);aspect-ratio:1;' +
  `grid-template-columns:repeat(${SIZE},1fr)`;
document.body.appendChild(grid);

const cells = [];
for (let i = 0; i < SIZE * SIZE; i++) {
  const cell = document.createElement('button');
  cell.style.cssText = 'border:0;border-radius:3px;background:#2f2f2f;cursor:pointer';
  cell.onclick = () => claim(i);
  grid.appendChild(cell);
  cells.push(cell);
}

const colour = (id) => (id === null ? '#2f2f2f' : `hsl(${(id * 67) % 360} 60% 55%)`);
const draw = () => cells.forEach((cell, i) => (cell.style.background = colour(board[i])));

// `room.me` is null in the studio, where there is no room to be in.
const myId = () => (room.me ? room.me.id : -1);

// ── the rules ──────────────────────────────────────────────────────────────
// One authority decides, or two players clicking the same square at the same
// moment leave the room with two different boards.

function take(cell, who) {
  if (board[cell] !== null) return false;   // first click wins
  board[cell] = who;
  draw();
  return true;
}

function claim(cell) {
  if (room.isHost || !room.me) {
    // The host decides its own click, and tells the room only if it landed.
    if (take(cell, myId())) room.send({ t: 'took', cell, who: myId() });
  } else {
    room.send({ t: 'claim', cell, who: myId() });
  }
}

room.on('message', (from, msg) => {
  if (msg.t === 'claim') {
    // Everyone hears the request; only the host acts on it.
    if (room.isHost && take(msg.cell, msg.who)) {
      room.send({ t: 'took', cell: msg.cell, who: msg.who });
    }
  } else if (msg.t === 'took') {
    take(msg.cell, msg.who);
  } else if (msg.t === 'hello') {
    // Somebody's disk has just started. Nothing is replayed for them, so
    // whoever holds the board hands it over — privately, not to the room.
    if (room.isHost) room.send({ t: 'board', board }, { to: from });
  } else if (msg.t === 'board') {
    board = msg.board;
    draw();
  }
});

// A player leaving does not free their squares — that is a rule of this game,
// and it is stated rather than left to chance.
room.on('leave', (player) => console.log(player.nick + ' left; their squares stay'));

room.on('hostchange', (hostId) => {
  if (room.isHost) console.log('the board is mine to keep now');
});

draw();
// Ask before anyone asks us: on a test run there is no room and this goes
// nowhere, which is fine.
room.send({ t: 'hello' });
```
