# Building Bits: the full context for an agent

This document is the whole of what an agent needs to know to build a Bit that is perfectly at home on
Rabbit. It describes the environment a Bit runs in, the APIs Rabbit installs into it, how Rabbit reads and
presents the document, and the lines that cannot be crossed. Everything here comes from the running
platform. Where this document says something works, the code supports it; where it says something is
blocked, the sandbox blocks it.

Rabbit is where a creator keeps what they build with AI and hears back from the people they send it to. A
**Bit** is one thing on Rabbit: a small, self-contained interactive creation, a game, a toy, a tool, a
visualization, a tiny site. Most Bits are one HTML document that Rabbit hosts, and nearly all of this guide is
about those. A Bit can also be a page hosted anywhere else and added as a link; it stays where it is, and
section 3 says the one part of Rabbit it can still reach. The person asking for one is usually not technical.
The deliverable is always something finished and playable, never a scaffold or a demo.

## 1. What a Bit is, physically

- Exactly one complete HTML document. Starts with `<!doctype html>`, ends with `</html>`. No markdown
  fences or commentary before or after.
- At most 2 MB (`2 * 1024 * 1024` bytes). This is a hard gate: a document over it is rejected before it
  can be posted. Aim far under; most great Bits are tens of kilobytes.
- Must parse as HTML: a doctype or at least one real element of its own. A text file renamed
  `.html` fails the gate.
- Fully inline, with one exception for the font. Styles in `<style>`, behaviour in `<script>`, art drawn with
  CSS, SVG, canvas or emoji, assets as `data:` URIs if they must exist at all. No CDN scripts, no network images, no
  `fetch` to external services, no analytics. (A `fetch` from the sandbox's `"null"` origin fails most CORS
  checks, a CDN is a dependency that can vanish, and the served document is cached immutable per version. A
  Bit that carries what it needs loads instantly and never breaks later.)
- A web font is the one subresource a Bit may load. One `<link>` to a font provider, two or three weights,
  and `display=swap` so the first paint never waits on it. This works from the opaque origin: the `/play`
  response constrains what the document may *do*, not what it may reach, and font providers send the CORS
  header a null origin needs. Every `font-family` must still end in a real fallback stack. This is the only
  part of a Bit that depends on the network being there, and a face that does not arrive should cost the look
  and never the layout.
- Served verbatim, plus shims. Rabbit stores the document and serves it through `/play/[id]` with a few
  small bridge scripts spliced in (sections 3–6). Nothing else is rewritten.

## 2. The sandbox: what the document runs inside

A hosted Bit uses the same sandbox tokens in its iframe attribute and the `/play` response's
`Content-Security-Policy: sandbox` directive: `allow-scripts`, `allow-forms`, `allow-downloads`,
`allow-popups`, and `allow-popups-to-escape-sandbox`.
The document runs in an **opaque origin** (`window.origin === "null"`), isolated from Rabbit's cookies
and native storage.

### Works normally

- JavaScript, CSS (animations, transforms, custom properties), canvas 2D, WebGL, SVG, Web Audio,
  `requestAnimationFrame`, timers, pointer/touch/mouse/keyboard events.
- `localStorage` / `sessionStorage`, **via Rabbit's shim only** (section 4). The native accessors throw in
  an opaque origin; the shim is installed before the Bit's first script and behaves like the real thing.
- Geolocation, delegated, and the only capability that prompts (`allow="geolocation *"`). A denial must
  degrade gracefully.
- `history.replaceState` / hash routing within the document.
- Links using `target="_blank"` and `window.open` can open new tabs. Those tabs do not inherit the
  Bit sandbox. Normal browser popup blocking still applies; open them from a user gesture.
- Native form submission (GET and POST). Use `target="_blank"` to open the response separately;
  otherwise the response replaces the Bit document inside its frame.
- Downloads, including exports created with a Blob URL and an `<a download>` link. Trigger them from
  a user gesture; normal browser download rules still apply.
- Opening a file the Bit made in a new tab: `window.open(URL.createObjectURL(blob))`, or a
  `target="_blank"` link to a Blob URL. Rabbit shows it in a tab of its own (a Blob URL from the
  sandbox can't open as a page by itself), so a PDF can be printed from the browser's viewer. PDFs,
  PNG/JPEG/GIF/WebP/AVIF/BMP images and plain text open as pages; any other type is saved as a
  download. Give the Blob its real `type`. `window.print()` stays blocked (no `allow-modals`).

### Blocked or unavailable, never rely on these

| Capability | What happens |
| --- | --- |
| `alert` / `confirm` / `prompt` | Do nothing (no `allow-modals`). Build in-page dialogs instead. |
| Navigating the top page | Blocked (no `allow-top-navigation`). |
| Cookies, `document.cookie` | None, opaque origin. |
| Native Web Storage accessors | Throw, but the shim stands in, so ordinary storage code works. |
| IndexedDB, Cache API, service workers | Unavailable in an opaque origin. Use the storage shim. |
| Camera, microphone, MIDI, sensors | Denied by omission from the permissions policy. |
| Clipboard | Reading is not granted. Writing is: `navigator.clipboard.writeText` inside a user gesture (delegated as `clipboard-write *`), but a browser may still refuse it, so wrap the write and leave the text selectable as the fallback. |
| Fullscreen, pointer lock | Not granted. |
| Reaching Rabbit itself | Impossible by design, except through `window.rabbit` (section 3), the one door: shared scores, one shared world, and private worlds for a group, and nothing else. A page hosted elsewhere gets the scores alone, through `/rabbit.js` (section 3). |

Two subtler consequences:

- Never gate on `event.isTrusted`. Rabbit's touch gamepad and on-screen keyboard reach the Bit as
  synthetic `KeyboardEvent`s (section 5); a Bit that ignores untrusted events is unplayable by touch.
- There is no backend and no way to add one, but there are three shared primitives. Rabbit provides
  shared numbers (`window.rabbit.scores`), one shared world (`window.rabbit.state`), and private worlds for a
  group of people who chose each other (`window.rabbit.spaces`), all in section 3. Between them they carry most
  of what "other people" means: a leaderboard, a chat room, a shared board, a wall of drawings, a poll, a
  family's trip plan. Reach for them when the ask depends on other players. Everything past
  them is closed: no accounts of your own, no server, no third-party API, no live outside data. When the ask
  needs one of those, build the self-contained version that keeps the fun: fake the data, play against the
  machine, or keep it local. Do not build a screen that waits on something that will never answer.

## 3. `window.rabbit`, the state a Bit shares between players

Rabbit installs `window.rabbit` into every hosted Bit before its first script runs, with three namespaces on
it. It is the whole of what a Bit can share between the people playing it.

A page hosted elsewhere and added to Rabbit as a link gets `rabbit.scores` alone, never `state` or `spaces`, by
loading one script in its `<head>`, before its own scripts:

```html
<script src="https://rabbit.town/rabbit.js"></script>
```

Played on Rabbit, its scores land on the Bit's Leaderboard exactly as a hosted Bit's do, with the same calls,
replies and caps (section 3a). Opened on its own, outside Rabbit, every call answers
`{ ok: false, reason: "not_in_rabbit" }` at once, so the page plays the same there and can say "Play on Rabbit
to keep a score". Rabbit believes the page's calls only from the frame it made and the page's own origin, so a
page that redirects to another address answers from that one and its scores are not kept.

- `rabbit.scores`: numbers such as a high score, a streak or a tournament point.
- `rabbit.state`: one shared world every viewer reads and writes, such as a chat room, a board, a canvas or a
  tally. Declared operations alone change it; Rabbit pushes other people's changes to you.
- `rabbit.spaces`: private groups of signed-in people (a family, a team, friends on a trip), and a private
  world for each use of the Bit inside one. The same declared operations change it, and only the group can
  read it.

All three are for state *about other people*. A private best or a saved level belongs in
`localStorage` (section 4), which is per-viewer, has no cap worth thinking about, and needs no account.

Three rules hold across all three namespaces:

- Every call resolves; none ever reject. No `.catch` needed. Failures come back as `{ ok: false, reason }`.
- Writing needs a signed-in viewer; reading `scores` and `state` does not (everything in `spaces` does).
  `"signed_out"` is the **ordinary case**, not an error. A Bit must stay fully playable signed out and must
  never nag; Rabbit shows the sign-in nudge itself when a call fails that way.
- Guard the namespace (`if (window.rabbit?.scores)`, `if (window.rabbit?.state)`) so the Bit also works
  outside Rabbit. Call on
  meaningful moments, never per frame: around 60 bridge calls a minute per load, with a 10-second timeout each.

### 3a. `rabbit.scores`, shared high scores and leaderboards

#### The scores API

```js
// Keep the greatest value ever submitted (late or duplicate submits can never lower a best):
await rabbit.scores.submit(4200);                    // key defaults to "score"
await rabbit.scores.submit(87, { key: "level-2" });

// Replace the value outright (tournament-style, "latest wins"):
await rabbit.scores.set(13, { key: "streak" });

// Top-N leaderboard (works signed out; default limit 10, max 50):
const board = await rabbit.scores.list({ limit: 10 });
// -> { ok: true, entries: [{ rank, name, value, isMe }], me: { rank, value } | null }

// This viewer's own rows:
const mine = await rabbit.scores.mine();
```

#### The contract

- Values are finite numbers only. One row per (Bit, viewer, key).
- Keys match `[a-z0-9_-]{1,32}`, default `"score"`. At most **16 distinct keys** per viewer per Bit,
  the key is an extensibility mechanism (a score per mode, a best per level), not a KV store.
- Writes return `{ ok: true, value, newBest }`; `value` is what is now stored (a `submit` below the
  existing best keeps the old one, and `newBest` is `false`).
- Failure reasons: `"signed_out"`, `"invalid"`, `"rate_limited"`, `"timeout"`, `"error"`.

Bits that keep a score but never call the bridge still work. Rabbit detects the score and offers the
creator a leaderboard hookup, but a Bit built natively should wire the bridge itself when a shared number
fits the idea.

### 3b. `rabbit.state`, one shared world

Every viewer of the Bit reads and writes **the same state**. It is a flat map of keys to values, and Rabbit
never reads what is in a value: the Bit owns whatever schema it puts there. That is what makes a chat room, a
shared board, a guestbook, a co-drawn canvas or a poll possible in a single HTML file.

**A Bit does not write keys directly.** It **declares operations** in its own document, and a viewer may do
nothing but send one of those. The server checks the arguments against the declaration, fills them into the
declared mutations, and applies the result. This is the whole reason one shared world is safe: a hostile
viewer can only do what the declaration allows, so there is no "wipe the board" unless an op says so.

#### Declare the ops

One block, anywhere in the document. It is not JavaScript and nothing executes it; Rabbit reads it when the
Bit is saved.

```html
<script type="application/rabbit-ops">
{
  "stateVersion": 1,
  "ops": {
    "colorPixel": {
      "args": {
        "x": { "type": "int", "min": 0, "max": 63 },
        "y": { "type": "int", "min": 0, "max": 63 },
        "color": { "type": "enum", "values": ["red", "blue", "green", "black"] }
      },
      "do": [{ "set": "px:{x},{y}", "value": "{color}" }]
    },
    "sendMessage": {
      "args": { "text": { "type": "string", "maxLength": 280 } },
      "do": [{ "append": "chat", "value": "{text}" }]
    },
    "vote": {
      "args": { "option": { "type": "enum", "values": ["a", "b", "c"] } },
      "do": [
        { "bump": "tally:{option}", "by": 1 },
        { "set": "voted:{actor}", "value": "{option}" }
      ]
    }
  }
}
</script>
```

**Argument types** are flat scalars only, no objects, no arrays:

| Type | Options |
| --- | --- |
| `int`, `float` | `min`, `max` |
| `string` | `maxLength` (up to 4096) |
| `enum` | `values` (1–64 strings) |
| `bool` | none |

**Mutations:** Rabbit implements these four and nothing else:

| Mutation | Effect |
| --- | --- |
| `{ "set": key, "value": v }` | Write a value at a key |
| `{ "bump": key, "by": n }` | Add to a number, creating it at zero. `by` is a number or one `{arg}` |
| `{ "append": list, "value": v }` | Write at a key Rabbit mints, so two writers never collide |
| `{ "remove": key }` | Delete a key |

**`{...}` tokens** in a key or a value are filled in: `{argName}` for any argument you declared, and
`{actor}` for **the viewer, from their session**. `{actor}` is the one name you get for free and the one a
viewer cannot fake. `"voted:{actor}"` is a key only that person's ops can write.

A key written as text by one op and as a number by another is **refused when the Bit is saved**, not at play
time. So is an undeclared argument, a malformed token, or more than 32 ops.

#### The state API

```js
// Do one declared op. Resolves after your own change has come back through
// subscribe, so reading the world straight afterwards is always correct:
const r = await rabbit.state.do("colorPixel", { x: 12, y: 30, color: "red" });
// -> { ok: true, seq } | { ok: false, reason }

// Watch the world. The FIRST call gives you all of it, flagged reset:
const token = rabbit.state.subscribe((changed, info) => {
  if (info.reset) world.clear();          // a cold start, or the world was wiped
  for (const e of changed) {
    if (e.removed) world.delete(e.key);
    else world.set(e.key, e.num ?? e.text);   // e.by is the writer's name
  }
  draw();
});

// Stop watching (a Bit that never subscribes costs no polling at all):
rabbit.state.unsubscribe(token);

// The caps, as data, answered instantly, no round trip:
const caps = await rabbit.state.limits();
// -> { ok: true, keyLimit, valueLimit, maxKeysPerBit, pageLimit }
```

#### Live updates are Rabbit's job, not yours

**Do not write a polling loop.** `subscribe` is the whole of it: Rabbit polls on your behalf, pushes what
changed, backs off while the world is quiet, asks immediately after your own write, and **stops entirely
while the tab is hidden**. A hand-rolled `setInterval` gets all of that wrong and costs every viewer.

Because `do()` resolves only after your own change has been pushed to you, there is no separate "apply it
locally too" step. Do not update your own view in advance. Write, and let it arrive like everybody
else's. One code path, and your screen can never disagree with theirs.

#### The caps

| Cap | Value |
| --- | --- |
| Keys per Bit | 10,000 |
| Value length | 4096 characters (control characters refused; tab/newline/return fine) |
| Key length | 128 characters |
| Ops per Bit | 32, with up to 8 arguments and 4 mutations each |

**10,000 keys is the one to design against.** A key per cell means a 96×96 grid fits and a 256×256 grid does
not. Pick the smaller board, or hold a row per key instead of a cell. **Call `limits()` rather than
hard-coding these numbers**; they can move, and a Bit that reads them stays correct.

#### The rules that matter

- Render a value with `textContent`, never `innerHTML`. Values are text other people wrote. Injecting one
  as markup is a stored cross-site script that runs with the victim's bridge access and can act as them.
  Same for `insertAdjacentHTML`, `outerHTML`, `document.write`, and a `src`/`href` built from a value. Build
  elements and set `.textContent`.
- Never trust a value's own claim about who wrote it. `by` comes from Rabbit, off the session. A name
  inside a value is text somebody typed.
- Any key can vanish or change under you at any time. Somebody else's op, moderation, or a
  `stateVersion` reset. Handle `removed`, and treat a missing key as absent rather than as an error.
- Declare every op the Bit calls. Calling one you forgot answers `{ ok: false, reason: "unknown_op" }`,
  which is the single most likely reason a shared Bit does nothing.
- The value's schema is yours to keep compatible; Rabbit will never migrate it. Put a version field in any
  JSON payload (`{ v: 1, … }`), wrap every parse in try/skip, and treat an unreadable value as absent. On a
  breaking change, either move to a new key prefix (`card2:`) or bump `stateVersion`, which **deletes the
  whole shared world on the next save**. That deletion is deliberate and irreversible.
- `reason: "stale"` means the Bit was edited while somebody was playing. Rabbit is already reloading the
  frame; do nothing.
- The world belongs to the Bit and follows it across edits.
- A Bit with a shared world needs a signed-out story: it is still readable, so show it, and put "sign in on
  Rabbit to join in" where the input would be rather than hiding the whole feature.

### 3c. `rabbit.spaces`, private worlds for a group

`rabbit.state` is one world for everybody, signed out included. When the idea is private, a family's holiday
plan, a team's tracker, a friends' trip wishlist, use a **Space** instead.

- A **Space** is a group of signed-in people with one owner. Somebody makes it and shares a code; others
  join with the code. One person can be in many Spaces. A Space is reusable across Bits on purpose.
- An **instance** is one use of *this* Bit inside one Space, such as "Tenerife 2026". Each instance has a
  private world of its own: the same flat map of keys as `rabbit.state`, read and written only by the
  Space's members. The same Bit can run many times in one Space, and each instance starts empty.
- Rabbit checks membership on every read and write. Ids are requests, never keys: an id from another Space,
  another Bit or another person reads as `not_found`.

Nothing carries over between instances or between Bits. A second Bit that uses the same Space starts with no
instances and an empty world.

#### Where the ops may run

The same `application/rabbit-ops` block, the same ops. Add `"worlds"` to say where they may run:

```json
{ "stateVersion": 1, "worlds": ["space"], "ops": { "...": "..." } }
```

`["space"]` keeps the ops out of the public world, so a call to `rabbit.state` can never put private things
where everybody reads them. Use it whenever an op holds something personal: a name, a child's age, a plan.
Leave `worlds` out and the ops run in both. `stateVersion` resets every world the Bit keeps, private ones
included: a bump empties every instance in every Space (the instances themselves stay).

#### The Spaces API

```js
// Spaces. Every call needs a signed-in viewer; signed out, all of them answer "signed_out".
const made = await rabbit.spaces.create({ name: "Tenerife trip" });
// -> { ok, space: { id, name, role: "owner", members: 1 }, inviteCode: "7K2M-...", inviteExpiresAt }
const mine = await rabbit.spaces.list();          // -> { ok, spaces: [{ id, name, role, members }] }
const joined = await rabbit.spaces.join({ code }); // -> { ok, space, claim? }  (either kind of code, see below)
const people = await rabbit.spaces.members({ spaceId }); // -> { ok, members: [{ id, actor, name, role, isMe }] }
// `actor` is what {actor} becomes for that member, so "vote:{actor}" keys can be matched to people.

// Owner only:
await rabbit.spaces.invite({ spaceId, days: 14 });  // a NEW code; the old one stops working, members stay
await rabbit.spaces.closeInvite({ spaceId });        // no code works until the next invite()
await rabbit.spaces.rename({ spaceId, name });
await rabbit.spaces.remove({ spaceId, memberId });   // memberId is a members() id
await rabbit.spaces.transfer({ spaceId, memberId }); // hand the Space to another member
await rabbit.spaces.delete({ spaceId, confirm: space.name }); // the exact name; deletes every instance in every Bit

// Anybody but the owner:
await rabbit.spaces.leave({ spaceId });              // the owner transfers first, or deletes

// This Bit's instances in one Space:
const { instances } = await rabbit.spaces.instances.list({ spaceId }); // [{ id, spaceId, title, createdAt }]
const trip = await rabbit.spaces.instances.create({ spaceId, title: "Tenerife 2026" });
await rabbit.spaces.instances.rename({ spaceId, instanceId, title });  // its maker or the owner
await rabbit.spaces.instances.delete({ spaceId, instanceId });         // its maker or the owner

// People inside one instance, tied to accounts (see "Person codes" below):
await rabbit.spaces.personCode({ spaceId, instanceId, person: "p7", label: "Alda Dís", days: 14 }); // owner only
// -> { ok, personCode: "Q4TR-…", codeExpiresAt }   works once
await rabbit.spaces.claims({ spaceId, instanceId });
// -> { ok, claims: [{ person, label, claimed, memberId?, actor?, name?, isMe, codeExpiresAt? }] }
await rabbit.spaces.unclaim({ spaceId, instanceId, person: "p7" }); // the person who claimed, or the owner

// One instance's world. The same shape as rabbit.state, so one apply function serves both:
const world = rabbit.spaces.state({ spaceId, instanceId: trip.instance.id });
const token = world.subscribe((changed, info) => {
  if (info.closed) return leaveSharedView(info.closed); // "not_found" or "signed_out": stop, do not retry
  if (info.reset) map.clear();
  for (const e of changed) e.removed ? map.delete(e.key) : map.set(e.key, e.num ?? e.text);
  draw();
});
const r = await world.do("vote", { activity: "siam-park", choice: "want" }); // -> { ok, seq } | { ok: false, reason }
world.unsubscribe(token);
const caps = await rabbit.spaces.limits();
```

Failure reasons: `"signed_out"`, `"forbidden"` (only the owner may, or the owner tried to leave),
`"not_found"` (no such Space or instance *for this person*), `"invalid"`, `"unknown_op"`, `"limit_reached"`,
`"cap_reached"`, `"stale"`, `"rate_limited"`, `"timeout"`, `"error"`. A watch stops by itself when the person
leaves, is removed, signs out or the instance is deleted: `subscribe` then gets one last call with
`info.closed` set.

#### Person codes: who is who

A Bit keeps the people of a trip in its own world (`person:{id}` rows), and most of them may not have a Rabbit
account yet. A typed name proves nothing about the account that later joins. A **person code** ties one of
those rows to one account.

- There are **two kinds of code**, and `join({ code })` takes either.
  - The **group code** (from `create` and `invite`) lets anybody who has it into the Space. Many people can use
    it.
  - A **person code** (from `personCode`) is made by the owner for one person in one instance. It works once.
    It lets its holder into the Space if they are not in yet, **and** ties their account to that person.
- The order does not matter. Somebody who joined with the group code can still use their person code later,
  and somebody with only a person code gets in with it. Using the group code never uses up a person code.
- After a person code, `join` answers `claim: { instanceId, person, label }`. Open that instance with that
  person picked.
- `claims()` lists every person the owner made a code for: `claimed: true` with the account's `name`,
  `memberId` and `actor`, or `claimed: false` (with `codeExpiresAt` while the code still works).
- Show the account's Rabbit `name` beside the person's name once claimed ("Alda Dís · joined as Alda Dís
  Sigurðardóttir"), so the owner can see the right person took it.
- `person` must be your own row id, the one your ops write keys with: 1 to 64 of letters, digits and
  `_ . : -`. `label` is the name the owner sees on the code, 1 to 60 characters.
- A claimed person cannot get a new code until they are let go with `unclaim` (by the owner, or by the account
  itself). Leaving or being removed from the Space lets go of everybody that account claimed.
- A claim says who an account is in **your** world. It gives no extra rights: every member can still run every
  op. If only "Alda" may change Alda's answers, write them under `{actor}` and compare with the claim's
  `actor`.

#### The rules for a private world

- **The Bit works alone first.** Open straight into a local mode that needs no account. Offer the group as
  a choice beside it ("Play on this device", "Play with your group"), never as a gate.
- **Never choose a Space for the person.** Not the last one used, not the only one. List their Spaces and let
  them pick, or make one. A code they typed is their choice: after `join`, go straight into that Space. With a
  person code, open the `claim` instance. With a group code, `instances.list()` for it: open the instance when
  there is exactly one, offer to make one when there is none, and let them pick when there are more. A Space
  chosen in another Bit means nothing here. You may remember the instance they last opened
  *in this Bit* (in `localStorage`), but only with the active Space and instance always on screen and one tap
  to switch.
- **Always show where they are**: the Space's name and the instance's title, wherever the shared view is.
  A person must never wonder whether their family's plan is private.
- **A code is shown once.** Show it with a copy control as soon as `create`, `invite` or `personCode` returns
  it. To show it again, make a new one; say that the old one stops working.
- **Offer a person code beside each person the owner adds**, and one join box that takes either kind of code.
- **Keep a typed code through sign-in.** Signed out, `join` answers `signed_out`, and signing in reloads the Bit.
  Keep the code in `localStorage` until `join` succeeds or the person clears it, and try it again once the Bit
  loads signed in.
- **People without accounts are data, not members.** A child, a grandparent who will never sign in: keep them
  as profiles inside the world (`append` a nickname). The account that writes is always `{actor}`; an adult
  voting for a child writes a value that names the child's profile.
- **Ask for as little as you need.** A nickname and an age range, not a full name and a birthday. Everything
  in a world is visible to every member, and members can change.
- Render every value with `textContent`, exactly as in `rabbit.state`. Members are people too.
- Watch one instance at a time. Rabbit watches at most three per load.

#### The Spaces caps

| Cap | Value |
| --- | --- |
| Spaces a person owns | 10 (and belongs to 50) |
| People in a Space | 50 |
| Instances of this Bit in a Space | 20 (50 across all Bits) |
| Keys per instance | 5,000 |
| Space name, instance title | 60 characters |
| Invite code | 16 characters, works 1 to 30 days (default 14) |

## 4. Storage: progress that survives refresh

`localStorage` and `sessionStorage` work as standard: reads, writes, `key(n)`, `length`, indexed
access and `Object.keys`. Rabbit shims them and persists the state per Bit, per viewer, per device.
Progress written to `localStorage` is restored before the Bit's first script runs on the next visit.

- Budget: 64 KB total for the serialized snapshot (both areas together). Over the cap, storage keeps
  working in memory but stops persisting; a refresh restores the last under-cap snapshot. Save
  small: progress, settings, a best score. Never assets or level data that belong in the document.
- Strings only, like real Web Storage. `JSON.stringify` structured state.
- Use `localStorage` for progress and private bests, `sessionStorage` for within-visit state.
- Do not use IndexedDB or the Cache API. They are unavailable (section 2).

## 5. Input: touch first, keyboard bridged

Bits are played on phones held upright first, and in a wide desktop window second. **Taps and drags are the
primary input; mouse and keyboard are a bonus.** Big touch targets, generous hit areas, and a first tap that
already does something.

### The touch gamepad, and how to earn a good one

A Bit that plays by keyboard is unplayable on a phone by itself, so Rabbit overlays a **virtual gamepad**: a
directional stick and on-screen buttons that hold real keys down inside the Bit. Which controls appear is
read from the Bit's own markup by a static scan, so the code has to be written where the scanner can see it:

- Listen with the literal event name: `addEventListener('keydown', …)` / `addEventListener('keyup', …)` or
  `onkeydown = …`. The scanner anchors on the string `keydown`/`keyup`/`keypress`.
- Compare keys against **string literals**: `e.key === 'ArrowLeft'`, `case 'w':`, `keys[' ']`,
  `e.code === 'KeyX'`. Arrow names or WASD in a key-shaped context become the stick; space, Enter, letters,
  digits, Shift and Escape become buttons (up to 5 offered automatically; creators can arrange up to 8).
- Listen for `keyup` as well as `keydown` when movement should be held. The stick holds a key down
  while deflected, and only a Bit with a `keyup` listener is treated as tracking held keys.
- Attach key listeners on `window` or `document`. Bridged events are dispatched at the focused element or
  `document.body` and bubble up like events from a physical keyboard, but a listener on a specific canvas that
  needs focus first will miss them.
- Never check `event.isTrusted`. Every bridged event is synthetic.

A Bit that plays by pointer alone needs none of this. Pointer events cover touch, pen and mouse at once, so
wiring `pointerdown`/`pointermove` is the whole story. A Bit that offers both (drag to
move, arrows as a shortcut) gets the best of both worlds, but only where holding a key is how it is played.
**The scan cannot tell a shortcut from a control scheme**, so a tap-first Bit, cards, tools, pages,
anything where every action already has a button on screen, must carry **no key listeners at all**: number
keys "for convenience" on a card game read as its controls, and the phone draws a gamepad over the cards.
Never leave an empty `keyup` listener to satisfy the rule above; an empty listener changes what the scan
sees and does nothing for anybody.

### Scan caps: keep the top of the document meaningful

Every markup heuristic (keys, scores, colour) reads only the **first 300,000 characters** (colour: first
250,000 bytes). Put `<meta>` tags, styles and gameplay scripts before any large inline data blob. A Bit
whose key handling sits behind a megabyte of base64 gets no gamepad.

## 6. Layout: the stage a Bit is given

- Fill the viewport, and the page never scrolls. Ever. The Bit owns the whole iframe: `height: 100%`
  layouts (or `100dvh`), and `html, body { overflow: hidden; height: 100% }` so nothing can nudge the page
  itself into scrolling. A Bit that scrolls reads as a webpage, not a thing being played, and a stray
  scrollbar on a phone eats the stage. A long list *inside* the Bit scrolls its own fixed-height box; the
  document never does. Size everything to fit the viewport it is given rather than to the content it holds.
  The player frames it; the document never needs its own chrome, margins or "back" affordances.
- Portrait phone first, desktop second. The stage is about 390 CSS px wide on a phone and about
  1300×750, landscape, in a desktop window, and desktop shows the document at its natural size, with no
  scaling to save it. One layout has to hold across both, so lay it out from the viewport itself (`100%`,
  `dvh`, flex, grid) rather than from a width: give a column of reading or controls a `max-width` and centre
  it instead of letting lines run the full 1300, and let a game or a canvas take whatever aspect it is
  handed.
- A fixed-shape field is sized from the smaller side, never from the width. The desktop stage is wide and
  short, and it is not rescued: a square board written as `width: 100%; aspect-ratio: 1` is 1300px tall in a
  750px window and the bottom 550px of it, the controls, the score, the thing being played, is gone.
  `min(100vw, 100vh)`, or a height-led box, fits both stages at full size. The phone hides this bug, because
  the phone is the one that gets scaled.
- Pin fixed chrome to the edge it belongs to, never to the centre plus an offset. A pause button at
  `right: 50%; transform: translateX(230px)` sits 230px right of centre. It fits in a desktop window but lies past
  the edge of a 390px phone, where the button exists and cannot be reached. `right: 12px` means the same
  thing on every width. Any pixel offset from centre wider than ~180px is chrome a phone cannot see.
- The stage has rounded corners, and they cut. The maker's stage and the desktop player both clip the
  document to a 20px radius, so anything flush into a corner loses part of itself: a score badge at
  `top: 0; left: 0` arrives with its corner sliced off. Keep fixed chrome at least 12px in from both edges it
  touches, and never let a readable number or word sit in the outermost 20px of a corner.
- Nothing may cross anything else. Two elements in the same place is the most common way a Bit that runs
  is still broken: a title with a moving game object swinging through it, a score badge over the thing it
  counts, two pieces of chrome pinned to the same corner. Give the furniture its own band of the stage: a
  header row that the play area starts below, not a heading floating on top of the field, and check the
  **whole travel** of anything that moves, including every point after its start. A pendulum, a falling piece
  and a
  scrolling background each sweep a path, and every pixel of that path has to be clear of text.
- Use the stage you were given. A play field with the action at the top, the target at the bottom and
  400px of empty ground between them is a Bit that looks unfinished, and on a phone it is most of the screen.
  Lay the field out to fill the space between the chrome, and if the idea needs less room, centre it
  and let the background carry the rest rather than leaving a hole in the middle.
- Overflowing the phone stage triggers a costly rescue. A fit bridge measures the laid-out document and
  the player scales it down (up to a 1600 px logical viewport) so nothing is clipped, but rescue means
  everything renders at half size or less, and only a phone gets it. Responsive layout renders full size; it
  always wins. Beware the corollary: one stray element wider than the viewport (an unwrapped `<pre>`, an
  oversized canvas) shrinks the whole Bit. Measurement happens at `DOMContentLoaded` and `load`.
- Set the viewport meta: `<meta name="viewport" content="width=device-width, initial-scale=1">`.
- No hover-dependent interactions. Hover may enrich, never gate.
- Leave the page's own scrolling alone unless a play surface needs it. `touch-action: none` and a
  `preventDefault` on a pointer belong on the element being dragged or played on, never on `document` or
  `body`. A Bit that is read rather than played must never intercept a scroll, and a game that fills the
  stage has nothing to scroll anyway.
- Respect `prefers-reduced-motion` by calming motion, not by removing feedback.
- Canvas games should size from the actual viewport (`innerWidth`/`innerHeight`, with `devicePixelRatio`
  for crispness) rather than a fixed 800×600. Size them on `resize` as well as at load, since the same document is
  handed a tall phone and a wide window.

## 7. What Rabbit reads out of the document

Rabbit presents every Bit through a still poster, a tinted stage, an install icon and search, all derived
from the document itself. A well-built Bit feeds these on purpose:

- `<title>`, set a real one. It is the fallback name in several surfaces.
- Stage colour, declare `<meta name="theme-color" content="…">`, or a solid `background` /
  `background-color` on `html`, `body` or `:root` (inline or in a `<style>` block; one level of `var()`
  indirection is followed). Accepted forms: hex, `rgb()`/`hsl()`/`oklch()` and friends, or a common colour
  name. This tints the player stage, the manifest and the launcher icon. A Bit that declares no colour gets
  a neutral frame.
- Visible text language, machine-detected from the rendered text and used to gate the feed (a viewer
  who reads only English is not shown an Icelandic Bit). Write the Bit's visible text in the language the
  asker used.
- Tags, assigned by Rabbit's own analysis of the content, never by the creator. A first screen that
  says what the thing is (a title on screen, self-describing labels) tags better than a blank canvas.
- The poster never plays. The feed shows a still image, either auto-generated or replaced by the creator,
  so the document cannot rely on motion to introduce itself, and needs no splash screen either.

The caption and short name come from the creator's one line about the Bit (120 characters, from which Rabbit
derives a name of at most 50). For a native creation, they come from the original ask. A creator may also write a longer
account of up to 500 characters, which the feed card keeps behind a `more`. The document does not set any of
these.

## 8. The platform around a Bit

What a Bit plugs into, for free:

- Anyone can play, signed out. Playing is the one action that never requires an account, so the core
  loop must work with no sign-in, no profile, no saved state.
- Likes, comments, saves, shares, Rabbit's chrome, outside the frame. The document never reimplements
  them.
- Updates: the creator's own future updates start from the current document plus a new ask, and so do
  the builds people try in a Bit's room. Clean, readable source is a courtesy to every one of them.
- Install to home screen: Rabbit generates the manifest and icon; theme colour and title feed them.
- Verification: an uploaded/AI-built Bit is verified by construction because Rabbit hosts the exact bytes.
- Versioned serving: each edit serves under a fresh content-versioned URL, cached immutable. The
  document should be deterministic: no build steps, nothing environment-dependent.

## 9. The document's own design system

Every Bit opens its `<style>` with a `:root` block naming its design as custom properties: the palette,
the corner radius, the shadow, the one transition, and everything after points at those tokens. Never the
same colour twice as a literal; never a second radius where one would do.

```css
:root {
  --bg: #10101c;      --surface: #1c1c2e;  --ink: #f2f2f8;
  --accent: #ffd166;  --accent-2: #ef476f;
  --radius: 14px;
  --shadow: 0 10px 30px -12px rgb(0 0 0 / 0.5);
  --snap: 0.18s cubic-bezier(0.4, 0, 0.2, 1);
}
```

Three reasons, all load-bearing:

- One hand. A palette declared once and reused everywhere is what makes a Bit look drawn rather than
  assembled. Ad-hoc colours scattered through the document are how a design drifts apart in the middle of
  being written.
- Shorter document. Definitions reused instead of repeated make a smaller file, and a smaller file makes a
  faster build. The clock on a build is the length of the document.
- Cheap changes. "Make it pink" against `--accent: #ffd166` is a one-line change; against thirty scattered
  literals it is a rewrite of the document.

## 10. Quality bar: what "finished" means here

The hard rules the native builder is held to, which apply to anything that wants to feel at home:

- Instantly alive. Playable within two seconds. No loading screen, no wall of instructions, no start
  button unless the idea needs one. The first tap should already do something.
- Ship it working. No console errors, no dead buttons, no state the player can wedge themselves into.
  A restart from any end state. And ship it clean: a line you corrected is deleted, not abandoned; dead
  statements and half-written leftovers travel into every later version of the Bit.
- Commit to one strong visual direction and carry it through. Colour, motion and generous type do a lot
  with no assets; a Bit with personality beats a neutral one.
- Give every input a satisfying reaction through transitions, a little physics or feedback on each tap.
- The words in a Bit are Rabbit's words. Short, plain, lower-key than a game usually writes: an
  instruction of a handful of words beats a sentence, and a title does not need a tagline under it. No em
  dashes, no emoji, no exclamation marks, and no jokes at the player's expense. "Tap to drop" is right;
  "tap to drop, how high before it topples?" is two ideas, a punctuation mark Rabbit does not use, and a
  line long enough to collide with whatever is beside it.
- Sound waits for a gesture. Autoplay policies hold everywhere: create/resume the `AudioContext` inside
  the first user gesture, and make sound an enhancement, never a requirement.
- Where the ask is vague, choose the most playful concrete reading and build it with confidence.
- Stay inside the frame. Nothing navigates away, nothing pops up, nothing asks for permissions beyond
  what the idea needs (geolocation is the only one that can even be asked).

## 11. Quick reference

| Constraint | Value |
| --- | --- |
| Document | One complete HTML file, `<!doctype html>` … `</html>` |
| Max size | 2 MB (hard gate; aim far under) |
| Stage | ~390 px portrait phone first, ~1300×750 desktop window second (natural size, no scaling); fill the viewport; no scrolling to play |
| Auto-fit ceiling | 1600 px logical width, scaled down on a phone only (avoid needing it) |
| Heuristic scan depth | First 300 k chars (keys/scores), 250 k bytes (colour) |
| Storage | `localStorage`/`sessionStorage` via shim; 64 KB persisted snapshot |
| Scores keys | `[a-z0-9_-]{1,32}`, ≤ 16 per viewer per Bit, numbers only |
| Scores list | Default 10, max 50 entries; reads work signed out |
| Scores on a linked page | `<script src="https://rabbit.town/rabbit.js"></script>` in `<head>`; scores only; `not_in_rabbit` outside Rabbit |
| Scores rate | ~60 bridge calls/min, 10 s timeout; calls resolve, never reject |
| State ops | Declared in a `<script type="application/rabbit-ops">` block; ≤ 32 ops, ≤ 8 args, ≤ 4 mutations each |
| State mutations | `set`, `bump`, `append`, `remove`, nothing else; `{arg}` and `{actor}` fill keys and values |
| State keys | ≤ 10,000 per Bit, ≤ 128 chars each (a key per cell fits 96×96, not 256×256) |
| State value | 4096 chars, no control characters; opaque to Rabbit |
| State live | `subscribe` and Rabbit pushes; **never write a polling loop**. Reads work signed out |
| State calls | `do()` resolves after your own change arrives, never apply it locally too |
| Spaces | `rabbit.spaces`: signed in only; a person picks the Space and the instance, never the Bit; `"worlds": ["space"]` keeps ops private |
| Touch pad buttons | ≤ 5 auto-detected, ≤ 8 creator-arranged; keys a–z, 0–9, space, Enter, Shift, Escape |
| Permissions | Geolocation (browser prompt) and clipboard writes inside a user gesture; all else denied |
| Blocked | Popups, modals (`alert` family), native form submit, top navigation, cookies, IndexedDB, external navigation |
| Network | One web-font `<link>` (always with a fallback stack); everything else inline, no CDN scripts, no network images, no `fetch` |
