# The runtime script: storage, sign-in, demo gate, items, leaderboards

> Online page: https://cassette.gg/agents/runtime-sdk.md. Fetch it again before you act on it; don't work from a saved copy.

**Two objects: the script tag gives you the launcher, and the client is what it resolves with.**

- **The global `Cassette`** has six things and nothing else: `init`, `ready`, `started`, `progress`, `track` and
  `section`. It starts the session and reports the loading screen.
- **The client** — `const cassette = await Cassette.init()` (or `await Cassette.ready`) — has the rest: `entitlement`,
  `checkout`, `consume`, `requireLicense`, `leaderboards`, `achievements`, `storage`. Every section below that uses
  `cassette.…` means this object.
- **There is no `Cassette.storage`, and calling a client member on the global fails** — it throws and writes the same
  message to the console, so a `try/catch` can't turn it into silence. With the drop-in, the `localStorage` you
  already use *is* the player's storage; with `v2.js` it is `(await Cassette.init()).storage`.

`localStorage` read before `ready` resolves is empty (and a write then lands on top of the real save): start the game inside `ready`.

Cassette runs every game in an isolated frame with no origin of its own. That keeps players and other games safe, and
it means **the browser's storage doesn't exist there**: `localStorage`, `sessionStorage`, `indexedDB` and
`document.cookie` throw. Workers started from a URL fail too. Cassette gives the game its own storage instead,
synced to the player's account (or kept on the device for a guest) and backed up.

## 1. The script, `Cassette.started()` and storage (required for every game)

**The drop-in (most games, every engine).** Make this the first script in `<head>`. Wait for `Cassette.ready` (the
saves have loaded), start the game, and call `Cassette.started()` once it's on screen:

```html
<script src="https://cassette.gg/sdk/v2-shim.js"></script>
<script>
  Cassette.ready.then(() => {
    startGame()        // e.g. createUnityInstance(…), engine.startGame(…), new Phaser.Game(…)
    Cassette.started() // the game is on screen: Cassette hides its loading screen
  })
</script>
```

`Cassette.ready` resolves once the saves have loaded — and never before DOMContentLoaded, so this snippet may sit ahead of the game's own scripts.
Plain, `defer` and module scripts all run before DOMContentLoaded and are safe; a game script loaded `async` is the exception — it can still arrive
after `ready` fires, and the snippet would call a `startGame` that doesn't exist yet. Load the game's script any way but `async`.

**Call `Cassette.started()` as soon as the game shows something:** its own loading bar, a title screen or the first
frame. Until then the player sees Cassette's loading screen, and without the call it stays up for 6 seconds on every
play. Calling it early or twice is harmless. A big game can also call `Cassette.progress(0.4)` while it loads (a real
fraction of bytes or files, 0 to 1, never backwards): the bar then fills instead of looping, and Cassette waits for a
game that keeps reporting. Without it the player sees the looping bar and "Still loading" after 12 seconds.

**Use the script address exactly as written**, `https://cassette.gg/sdk/v2-shim.js` (or `…/sdk/v2.js` below), even
when you publish to a test or local stack: that stack's player loads its own copy from that address. Don't copy
the script into the game. One tag is enough: `v2-shim.js` contains everything `v2.js` does. A page that loads both, or one twice, still works (the
second copy shares the first's connection and patches nothing again), but the upload check warns (`sdk_twice`) so you can drop the extra tag.

The script replaces `localStorage`, `sessionStorage`, `document.cookie`, `indexedDB` (with `IDBKeyRange` and the other
IndexedDB classes) and `Worker`. It also hides `navigator.serviceWorker` and `caches`, which the frame doesn't have, so
a game's own checks (`'serviceWorker' in navigator`) skip offline mode. The game's code doesn't change. Reads are instant once `Cassette.ready` has resolved;
writes leave right away. Outside Cassette (opened from your own disk or server) it changes nothing: `window.localStorage` and friends stay the
browser's own, while `Cassette.ready` still resolves — `cassette.mode === 'standalone'`, `cassette.persisted === 'memory'`, and `cassette.storage` a
per-page memory store of its own (not the browser's `localStorage`, nothing survives a reload) — so the same start flow runs while you develop.

Why start after `ready`: until it resolves, storage reads as empty, and a game that reads its save at startup would
see none and might overwrite it (the console says so once, as a warning, the first time storage is touched too early). Find where the game starts (the engine's start call, or the first scene) and wrap
it.

| Engine | What to do |
|---|---|
| Unity (WebGL) | the drop-in; call `createUnityInstance` inside `Cassette.ready.then(…)`. `PlayerPrefs` and `Application.persistentDataPath` are kept; Unity's download cache isn't saved (it refills) |
| Godot 4 | the drop-in; call `engine.startGame` inside `Cassette.ready.then(…)`. **Export without thread support**: threads need features the frame doesn't have |
| Construct, GameMaker, Phaser, PixiJS, three.js, Babylon.js, kaplay, plain JS | the drop-in, and start inside `Cassette.ready.then(…)` |
| Rust (macroquad) | the drop-in; call `load("game.wasm")` inside `Cassette.ready.then(…)` |
| Emscripten builds with pthreads | not supported: build single-threaded |
| LÖVE (love.js) | the drop-in; call `Love(Module)` inside `Cassette.ready.then(…)`. **`love.filesystem` saves are lost on reload** unless you add one line: love.js writes them to IndexedDB only when the page unloads, which the frame never gets to finish. In the generated `love.js`, right after `window.addEventListener("beforeunload",function(event){FS.syncfs(false,function(err){if(err){Module["printErr"](err)}})})` add `;setInterval(function(){FS.syncfs(false,function(err){if(err){Module["printErr"](err)}})},2000)`. The drop-in already keeps IndexedDB, so the saves then persist |
| pygame (pygbag) | works as `pygbag --build` writes it, with three changes: the drop-in as the first script in `index.html` (before `pythons.js`); `<script>Cassette.started()</script>` right after it (pygbag's page is its own loading screen and Python takes ten seconds or more to start: a call from `main.py` comes after Cassette's loader has already given up, 3 to 6 s in); and zip the `.tar.gz` that pygbag writes next to `index.html` (the page loads it), **not** the `.apk` beside it, which is refused. The runtime still loads from `pygame-web.github.io`. Saves through `window.localStorage` from Python are kept; the runtime takes seconds to start, long after the saves have loaded |

**Or call the storage directly** (a small game you're writing anyway):

```html
<script src="https://cassette.gg/sdk/v2.js"></script>
<script type="module">
  const cassette = await Cassette.init()                     // resolves once storage has loaded
  cassette.storage.setItem('save', JSON.stringify(state))    // the localStorage API
  const save = JSON.parse(cassette.storage.getItem('save') ?? 'null')
  cassette.files.write('slot1.sav', bytes)                   // whole files: read(path) → Uint8Array | null
  Cassette.started()                                         // on screen (the same as cassette.started())
</script>
```

**Limits:**
- 10 MiB per signed-in player per game in total, and 64 MiB per account across every game; a guest's storage (on their device) is 2 MiB;
- 5 MiB per value;
- keys up to 512 bytes;
- writing past the limit throws `QuotaExceededError`, as a browser does.

`indexedDB` is kept in the same storage: every database the game opens is copied into it, counts against the same limits and, for a signed-in
player, is saved to their account. A game that records or caches a lot in IndexedDB (a replay, a download cache) can fill its storage until
its own save throws `QuotaExceededError`. Keep scratch data in memory.

`cassette.persisted` says where storage is kept right now: `'cloud'` (the signed-in player's account), `'device'` (a guest, on this
browser) or `'memory'` (outside Cassette). Inside Cassette, `'memory'` after `Cassette.ready` means storage could not be loaded (the shell did not
answer within 15 seconds, or the game reloaded itself more than 5 times in a minute): `ready` still resolves, the game runs from memory and **nothing it writes is saved**, with only a console error to show for it.
Check `cassette.persisted` once `ready` has resolved and tell the player to reload.

A write leaves for Cassette as soon as the game's current task ends. When the game writes again within a quarter of a second, that write waits for the
end of the quarter second, so a game that writes all the time sends four batches a second. For a signed-in player a batch also waits for the answer to the one before it. A page that closes inside either wait can lose those last writes.
`await cassette.flush()` sends what is waiting and resolves once everything written so far has been saved: await it after anything the player would hate to lose (a checkpoint, a trophy, a purchase).
It rejects if a write was refused or could not be saved, including a background write that failed before the call.
Catch that error and keep the player at the checkpoint; do not spend an item or announce a save until it succeeds.
A quota refusal stops automatic retries: free space or reduce the save, write the affected values again, then flush again.
IndexedDB is copied about 0.3 seconds after its last write, so a game that saves there has that much longer. The upload check refuses a build that uses
the browser's storage without the drop-in, and tells you which.

### What else the frame changes

- **Full screen.** `element.requestFullscreen()` from a click or key press works in every browser. Firefox and Safari don't let a frame go full screen
  itself, so the drop-in asks the player's page to: the whole game goes full screen, your element stays where it is, and the window simply gets bigger
  (size to it as usual). There `document.fullscreenElement` stays empty and `fullscreenchange` doesn't fire; the player leaves with Esc or Cassette's bar,
  and a game can't leave it for them. `await cassette.requestFullscreen()` asks the same way and answers `true` or `false`.
- **Your own server.** Requests to it come from the origin `null`: it must answer CORS for any origin, and cookies and `credentials: 'include'` never work from the frame (§6).
- **Links to other pages.** `window.open()` and `target="_blank"` do nothing in the frame (Firefox throws an error, the others return `null`). `await cassette.openExternal('https://…')` from a click or key press shows the player the address and opens it in a new tab if they agree; it answers `true` if they did and `false` if not. Only absolute `http` and `https` addresses, one question at a time, and nothing is asked without a fresh click.
- **Clipboard.** `navigator.clipboard.writeText(…)` from a click works (an invite code, a share text). Reading the player's clipboard doesn't, in any browser.
- **Sign in with Google, Facebook or Apple.** Their buttons and scripts need cookies and a pop-up window, which the frame doesn't have, so they can't work here (the upload warns, `external_sign_in`). Players sign in with Cassette: `await cassette.signIn()` (§2), and `cassette.identityToken()` (§6) proves who they are to your own server.

**Reloading.** A game can reload itself with `location.reload()` or `location.replace(location.href)` (say after the player picks a language): it keeps its storage and
stays connected, and what it wrote just before is there. Cassette loads the game again from its entry address, so anything the game added to the address (a `?level=3`, a `#hash`)
is gone after a reload: keep such state in storage. An address the game builds itself (`location.href = 'index.html'`, another page of the build) loses what connects it to
Cassette: that page runs without storage and `cassette.persisted` is `'memory'`. A game that reloads itself more than 5 times in a minute is left without storage too.

**Signing in, and one save on two devices.** Storage belongs to whoever is signed in. The `storage` event fires whenever it
changed under the game: a sign-in or sign-out swapped whose storage it is, or another device of the same player saved (the player's
page learns of it when they come back to the tab).
- A guest who signs in **to an account that has no save for this game** keeps their progress: it moves to the account.
- A guest who signs in **to an account that already has one** does not merge: the account's save is what storage now holds. The guest's copy
  stays on this device (it is what storage holds again if the player signs out), and the game's memory still has the guest's progress:
  the `storage` event is the moment to compare it with the account's.
- A game that keeps its save in memory and writes it back every few seconds overwrites whatever another device saved (the last write wins).
  So don't just reload on `storage`: that throws away the progress in memory. Read the stored save, compare it with the one in memory
  (the player's progress, not a timestamp), and either load it, keep yours (your next write replaces it) or ask:

```js
let owner = cassette.player?.id ?? null                  // whose save `state` is (null: a guest's)
cassette.on('storage', () => {
  const now = cassette.player?.id ?? null                // already the new player when a sign-in or sign-out fires this
  const stored = JSON.parse(cassette.storage.getItem('save') ?? 'null')
  if (owner !== null && now !== owner) {                 // signed out, or another account: `state` is not this storage's
    owner = now
    state = stored ?? newGame()                          // never write the account's progress onto the device
    return
  }
  owner = now
  if (!stored || sameProgress(stored, state)) return
  if (furtherAlong(stored, state)) loadSave(stored)       // keep a copy of what this replaces
  else if (furtherAlong(state, stored)) writeSave(state)  // yours wins: write it now
  else askWhichToKeep(stored, state)                      // stop writing until they answer
})
```
- A fresh signed-out visit loads this browser's guest save (empty if there is none). If a `storage` event switches the game from an account to
  a guest save, load what the device has or start fresh; do not copy the account's in-memory progress into the guest save, where the next
  person to sign in on this device would find it. The account's save stays on the account. A session ending mid-play is handled separately below.
- Order on a sign-in: the guest's saves are moved into the account (or kept on the device, if the account had a save) before the game is told.
  Then `storage` fires, then `player`, then `cassette.signIn()` resolves: when your `storage` handler runs, storage already is the account's and
  `cassette.player` is the new player.
- If the session ends during play (signed out in another tab or an expired login), the game keeps the account's save in memory.
  New writes wait on this device for that same account to sign back in; they are not converted into a guest save. The play menu asks the player to sign in again.
- `localStorage`, `sessionStorage`, cookies and `cassette.files` follow the player; **IndexedDB does not**: the databases the game has open are not re-read when
  the player signs in or out, so a game that keeps its save there would write the previous player's data into the next one's storage. Keep saves in `localStorage` or
  `cassette.files`.
- A trophy or score earned as a guest belongs to the player once they sign in: if your game replaces its guest progress with the account's save, carry
  the guest's earned trophies over before the replacement (or report them first), or they are lost with it.
- The account's earlier saves are kept as backups, made daily and before an import, restore or wipe.

## 2. Who is playing

```js
const cassette = await Cassette.init()      // with the drop-in, `await Cassette.ready` gives the same object
cassette.player                             // { id, username } | null; `id` is this game's id for the player
cassette.on('player', (p) => updateHud(p))  // signed in or out
await cassette.signIn()                     // the sign-in sheet → the player, or null if they closed it
```

**`player` can start as `null` for a signed-in player.** The play shell waits up to 8 seconds when this browser has a sign of a session,
or 1.5 seconds otherwise. If the account is still unknown, the game starts with this device's guest save. An account found after that
is **not adopted automatically**: the play menu says "Your account loaded after this game started. Reload to use your saves."
The account's save stays untouched until a reload or a sign-in the player asks for with `cassette.signIn()`.
Handle `player` and `storage` for those deliberate changes (§1); do not wait forever for an automatic late sign-in event.

`username` is `null` until the player picks a `@username` in their Cassette account: the sign-in sheet offers it to a new account and the
player can skip it, and signing in from a game never insists. A game that shows names (a scoreboard) needs its own label for a player
without one, for example "player" plus the last four characters of `id`; leaderboard entries carry the same `{ id, username }`.

## 3. Selling: a free demo, and items

**A paid game with a free demo.** Let anyone play up to a natural end of the demo, then:

```js
if (await cassette.requireLicense({ demoEnd: true })) continueGame()
else backToMenu() // they chose not to buy now: keep their progress
```

Players who own the game pass at once. Others see Cassette's checkout, which says the demo has ended, and the call
resolves once they've paid or closed it. Say where the demo ends in `cassette.json` too
(https://cassette.gg/agents/metadata.md). A good demo ends on a hook: a cliffhanger, a new ability, a door you can't open yet.

**Driving the checkout from a script.** Its "Continue to payment" asks for no real person's gesture: it answers a trusted mouse click
(including one sent through the browser's debugging protocol), the keyboard and a script's `click()` alike. It acts only once its
consent box is ticked (until then a press says "Tick the box above to continue"), and a second press while the payment is being set up is
ignored. Where Cassette has no Stripe keys (a local stack), the payment opens in a **separate window**: a browser or a harness that
blocks popups shows "Open the payment window" instead. A script that can't drive a second window buys as the signed-in player with
`POST https://cassette.gg/v1/checkout` (the cookie session; `{"gameId":…, "consent":{"immediateDelivery":true}}`) and pays the `url` it returns.

`await cassette.entitlement()` → `{ owned, demo, items }` reads ownership without showing anything. `items` maps each item the
player holds to how many; an item they hold none of is absent, so read `items[sku] ?? 0`.

### Items: buy, hold, spend

Your agent declares the items (https://cassette.gg/agents/publish-game.md §6). Cassette sells them in its checkout and keeps each player's
balance on their account: it survives reloads, devices and your next build, and it isn't in the game's save, so editing a save can't add
to it. Don't keep a second count of what the player holds.

```js
const r = await cassette.checkout('revive', 3)        // the checkout sheet → { ok: true, entitlement } | { ok: false, reason }
const held = (await cassette.entitlement()).items.revive ?? 0
const s = await cassette.consume('revive', 1, ref)    // spend one → { ok: true, balance, replayed } | { ok: false, reason }
```

`consume(sku, quantity, ref)` takes `quantity` (a whole number, 1 to 1000) of a **consumable** item from the signed-in player's balance, in
this game only: a game can't name another game's item or another player. It never throws, and `entitlement().items` and the
`entitlement` event follow the balance it returns.

**`ref` is required, 1 to 100 bytes, and makes the spend safe to repeat.** The same `ref` with the same quantity takes nothing a second
time: it answers `{ ok: true, replayed: true, balance }`. So when a spend ends in `failed` (the reply was lost or timed out, and you can't
tell whether it went through), ask again with the **same ref**. The same ref with another quantity is `ref-reused` and spends nothing.

Use as `ref` the thing being paid for, decided **before** the first try and kept until you have an answer: `level-12`, `revive:run-8841:2`
(run, then which revive), or an id you made and saved with the game's state. Never `Date.now()` or a fresh random per try: every retry
would be a new spend. A ref belongs to one item; the same words on another item are a different spend.

| `reason` | Meaning | Spent? |
|---|---|---|
| `signed-out` | a guest has no items; Cassette doesn't open the sign-in sheet for this, call `cassette.signIn()` if you want it | no |
| `insufficient` | not enough; `balance` is what they hold | no |
| `not-consumable` | a one-time item (it is owned, not spent) | no |
| `unknown-item` | no such item in this game, or it was removed | no |
| `invalid` | a sku, quantity or ref the API refuses; checked in the game, nothing was sent | no |
| `ref-reused` | the ref was spent for another quantity (`quantity` says which) | no |
| `rate-limited` | over 60 spends a minute for this player in this game; `retryAfter` is the seconds to wait before asking again | no |
| `unavailable` | the game was taken down; its items can't be spent | no |
| `unsupported` | opened outside Cassette (from your own disk or server), or an old host | no |
| `failed` | **unknown**: ask again with the same ref | maybe |

**A spent unit can't be refunded.** A buyer can get back the units they haven't spent, within the refund window. So spend an item when the
player uses it (a revive, a key, a ticket), and leave the rest as the player's balance. Converting a gem pack into gems is the delivery
itself, and the checkout asks the buyer to agree to immediate delivery, so that one is done as soon as the pack is there.

**Spending at the point of use:**

```js
async function revive(run) {
  const ref = `revive:${run.id}:${run.revives + 1}`           // fixed before the first try
  const r = await cassette.consume('revive', 1, ref)
  if (r.ok) return grantRevive(run)                           // also true for a retry: the same ref paid once
  if (r.reason === 'failed' || r.reason === 'rate-limited') return askAgainLater(ref)
  if (r.reason === 'insufficient') return openShop()          // → cassette.checkout('revive', n)
  showProblem(r.reason)
}
```

**Gem packs** (the game has its own currency): an item costs from $0.99 and a checkout buys 1 to 99 of it, so sell *packs* (`gems-100`,
`gems-550`, each a consumable item) and keep the gems in the game's save. Cassette's balance of a pack is the packs bought and not yet
turned into gems; turning them into gems is a spend. The only care is a crash between the spend and the credit: write what you are about
to do **first**, gate the credit on that note, and run it one at a time. The spend fires the `entitlement` event, which calls it again, and two runs at once each
hold their own copy of the save: one's credit overwrites the other's and the player pays for gems they don't get.

This example assumes one active game session per account. Cloud saves use last-write-wins updates, so simultaneous devices can overwrite each other's gem credit. Keep valuable balances on your own server if you need transactions across sessions.

```js
const GEMS = { 'gems-100': 100, 'gems-550': 550 }
let running = null
const redeem = (save) => (running ??= redeemPacks(save).finally(() => (running = null)))
// call redeem(save) at start (after migrateDelivered, below), after a checkout, and on cassette.on('player') and on('entitlement')
// catch a rejected save: redeem(save).catch(() => showSaveProblem())

async function redeemPacks(save) {
  if (cassette.mode !== 'embedded' || !cassette.player || cassette.persisted !== 'cloud') return
  while (true) {
    if (!save.redeem) {                                       // choose what to redeem, save the note, only then spend
      const held = (await cassette.entitlement()).items
      const sku = Object.keys(GEMS).find((k) => (held[k] ?? 0) > 0)
      if (!sku) {
        // A previous spend may have succeeded while saving its credit failed. Persist that credit even with no packs left.
        writeSave(save); await cassette.flush()
        return
      }
      save.redeem = { sku, quantity: Math.min(held[sku], 1000), ref: crypto.randomUUID() }
    }
    // Also re-save an existing note: a previous attempt may have failed before it reached storage.
    writeSave(save); await cassette.flush()                    // refusal stops here, before any pack is spent
    const { sku, quantity, ref } = save.redeem
    const r = await cassette.consume(sku, quantity, ref)
    if (!r.ok) {
      // refused for good (the packs were refunded meanwhile, the item was removed): drop the note. Any other reason keeps it for the next start
      if (['insufficient', 'unknown-item', 'not-consumable', 'invalid'].includes(r.reason)) { save.redeem = null; writeSave(save) }
      return
    }
    save.gems += GEMS[sku] * quantity                         // the credit and the end of the note are one write
    save.redeem = null
    writeSave(save); await cassette.flush()
  }
}
```

If the game dies after the spend and before the write, the note is still there at the next start: the spend is asked again with the same
ref, answers `replayed`, and the credit happens once. Keep `redeem` and `gems` in the same stored value (one `setItem`), so they change together.
If saving the credit fails while the game stays open, keep that same in-memory save and retry `redeem(save)` after fixing the save problem. It writes the credit again even when no packs remain, without spending or crediting them twice.

**Moving a game that tracked its own gems.** If the game credited gems when `checkout` returned and counted the packs it had delivered
(say `save.delivered['gems-100']`) to tell new packs from old, Cassette still holds every pack ever bought, because nothing spent them.
Bring it to the meaning above **once, before `redeem` ever runs**, by spending the packs already delivered:

```js
async function migrateDelivered(save) {                       // true when there is nothing (more) to bring across
  if (save.migrated) return true
  if (cassette.mode !== 'embedded' || !cassette.player) return false   // the packs are on the account: wait for it
  if (!save.migrate) {                                        // decide, save the note, only then spend
    const held = (await cassette.entitlement()).items
    const spends = Object.entries(save.delivered ?? {})
      .map(([sku, n]) => [sku, Math.min(n, held[sku] ?? 0)])  // more delivered than held: some were refunded, the player keeps the gems
      .filter(([, n]) => n > 0)
    save.migrate = { spends }
    writeSave(save); await cassette.flush()
  }
  for (const [sku, quantity] of save.migrate.spends) {
    const r = await cassette.consume(sku, quantity, `migrate:${sku}`)
    if (!r.ok && r.reason !== 'insufficient') return false     // asked again at the next start, with the same refs
  }
  save.migrated = true
  delete save.migrate
  delete save.delivered
  writeSave(save)
  return true
}
// at start: if (await migrateDelivered(save)) await redeem(save)
```

Run it only for a signed-in player in Cassette (a guest holds no items), ship it in the same build as the new credit code, and never
call `redeem` before it has returned true: it would credit the packs a second time. Packs the player had bought and the old code hadn't
yet delivered stay in Cassette's balance, and `redeem` credits them.

## 4. Leaderboards and achievements

Declare them in `cassette.json`, then:

```js
const mine = await cassette.leaderboards.submit('best', score)  // → { best, rank } | null
const top = await cassette.leaderboards.top('best', 10)         // [{ rank, player: { id, username }, score, at, meta? }]
const near = await cassette.leaderboards.around('best', 5)      // the entries around the player's own; [] with no score yet
const got = await cassette.achievements.unlock('first-win')     // → { unlocked: true, new } | null
const all = await cassette.achievements.list()                  // [{ id, title, description, hidden, unlockedAt, unlocks }]
```

```json
"leaderboards": [{ "id": "best", "title": "Best run", "order": "desc", "min": 0, "max": 1000000000 },
                 { "id": "fastest", "title": "Fastest run", "order": "asc", "min": 30000, "max": 3600000, "format": "time-ms" }],
"achievements": [{ "id": "first-win", "title": "First win", "description": "Win your first race." },
                 { "id": "secret-door", "title": "Behind the wall", "hidden": true }]
```

**Declarations**
- Check the file before you zip it: `POST https://cassette.gg/v1/schema/cassette/validate` with the whole `cassette.json` as
  the body answers every problem at once (`{"ok":false,"errors":[{"path":"leaderboards[0].order",…}]}`), by the rules
  the upload itself applies.
- Scores are integers within the board's `min` and `max` (at most ±9007199254740991).
- `order` is `desc` when higher is better, `asc` for times.
- `format` is `number` (the default) or `time-ms`, which says the integer is milliseconds. Cassette shows boards on the store page
  and formats `time-ms` as a time. In your game's own UI, format the integer yourself (store 12.3 as 123 if you need one decimal place).
- Ids are lowercase letters, digits, `-` and `_`, up to 32 characters, and start with a letter or a digit (`first-win`, not `ach.first` or `_x`).
- Once a board has scores its `order` can't change: a build that flips it is refused (`board_order_changed`). Keep the order, or give the board a new id to start over.
  `min`, `max`, the title and the format can change; ids are permanent (an unlock or a score is stored by id).
- At most 10 boards and 100 achievements. A board title is at most 40 characters; an achievement title at most 60 and its description at most 200.
- `hidden: true` shows other players "Hidden achievement" with no text until they unlock it; `list()` returns the real text only for the player's own unlocks.
- Achievements have no custom icon. Cassette shows trophies on the store page and a player's progress on their account and Library;
  use `list()` to draw them inside your game too.

**What a call does**
- Opened outside Cassette (from your own disk or server) every call answers `null` or `[]`: to see boards and trophies work, upload the build and play
  its unlisted link (https://cassette.gg/g/<slug>, live at once).
- Signed out, `submit` and `unlock` answer `null`: there is no player to score (`top`, `around` and `list` still read — the boards are public). Offline, refused or over a limit, the call answers `null` (`top`, `around` and `list` answer `[]`) and never throws. Keep what you couldn't send and send it again
  later: `[]` cannot tell an empty board from a failed request.
- A board keeps each player's **best** score: a worse or equal one changes nothing, and `best` in the answer is the stored one.
- Ties share a rank: 100, 90, 90, 70 are ranks 1, 2, 2, 4. Among equal scores the one set first is listed first.
- `rank` is among active players; a banned or deleted player's score is off every board.
- `unlock` is idempotent: the first call answers `new: true`, every later one `new: false`, and `unlockedAt` keeps the first time. `unlocks` is how many players have it.
- Limits per player and game: 30 score submits and 60 unlocks a minute. A player who signs in with a long history to send (every trophy earned as a guest)
  hits the unlock limit: send about 40 a minute and keep the rest for the next minute.
- A paid game's scores and unlocks need the player to own it (the boards stay readable by everyone).
- `meta` is up to 64 characters, shown with the entry, e.g. a character class or a seed.

**Scores come from the player's browser, and Cassette cannot tell a real one from a forged one.** It checks the player's account and that a score is an
integer within your declared `min` and `max`: nothing else. A player can send any score in that range, and unlock any declared achievement, from the
browser's console or straight to the API with their own session, so a board is as honest as your bounds are tight.
- Set `min` and `max` to what the game can really produce, not "a large number". That is the whole server-side check.
- Treat these boards as bragging rights. Cassette's score endpoint does not verify your server's signatures, and a player can bypass
  checks in your game. For a board that matters (a prize, a ranking people cite), keep that authoritative board on your own server,
  which computes or checks the results. `identityToken()` (§6) proves who is playing, not whether their score is honest.
- Don't send a score from a save the player can edit without checking it makes sense (a game that exports saves lets a player rewrite them).
- Cassette moderators can remove a cheater's entry or wipe a board after an exploit (and a ban removes the player's scores everywhere). You can't from the
  API: tell Cassette (info@delegas.com) with the game, the board and the player's `@username`.
- Scores sent while you test an unlisted build are real entries on the same board. Use throwaway accounts, and ask Cassette to wipe the board before you list the game.

### Your own analytics events

```js
Cassette.track('level_complete', { level: 3, mode: 'hard' })
```

Shows up in the game's Analytics tab and in the API. A name is 1 to 40 characters (`a-z`, `0-9`, `_ . : -`, starting
with a letter); at most 8 properties, each a short string, a number or a boolean. Anything else is dropped, `track`
never throws, and Cassette caps what it stores per play and per day. Never put a player's name, email or id in an
event. Plays, players, retention, devices, countries and sources need no call at all. The full contract, funnels and
the API: https://cassette.gg/agents/analytics.md.

### Sections, for frame rates by level

```js
Cassette.section('level-3')
```

Crashes and frame rates are collected with nothing added to the game. This one optional call names the part of the game
being played, so frame rates can be read per section. Call it when the section changes; `Cassette.section('')` clears
it. The name follows `track`'s rule. Never put a player's name or id in it. Read them: https://cassette.gg/agents/health.md.

## 5. Where the player's controls tab goes: `player.handle`

The player's controls tab (and the bar it opens, with the Cassette wordmark) sits in the top-right corner by default.
Put it where it won't cover your UI, in the build's `cassette.json`:

```json
"player": { "handle": "bottom-left" }
```

Values: `top-left`, `top-center`, `top-right`, `bottom-left`, `bottom-right`. Anything else means the default.

## 6. The player on your own server

```js
const t = await cassette.identityToken()   // { token, expiresAt } | null
if (t) await fetch('https://api.example.com/run', { headers: { authorization: `Bearer ${t.token}` } })
```

On the server, verify it as an EdDSA JWT:
- check it against `https://cassette.gg/.well-known/jwks.json`;
- check `iss` = `https://cassette.gg` and `aud` = your game's id;
- `sub` is the player's id for your game.

Or post it to `https://cassette.gg/v1/verify` with `{ "token": …, "gameId": … }`. Your server must allow requests from any
origin (`Access-Control-Allow-Origin: *`): the game's origin is `null`. A request with no CORS answer fails in the browser with a
CORS error that looks like a bug in Cassette; the fix is on your server. **No cookies:** the frame sends none to your server
(`credentials: 'include'` fails against `*`, and a server that echoes `null` and allows credentials still gets no cookie), so a
cookie session can't work. Send the token above as a bearer header, or a token of your own in storage.

## 7. Multiplayer: host-less rooms

Real-time multiplayer is built in: `cassette.rooms` gives rooms by code, invite link and quick match. Your game calls
`room.send` and listens for `message`, and Cassette carries the data between players. The rule for any multiplayer game: **a
match must never freeze, reset or end because the "host" player closed their tab**, and making it so takes a few lines, not a
rewrite. The two drop-in primitives are `room.checkpoint(get, set)` (3 lines on an existing `send`/`onMessage` game) and
`room.shared(initial)` (a live replicated object). The full contract, reports and worked examples are on their own page: **read
https://cassette.gg/agents/multiplayer.md before writing any multiplayer code.**

---

Code in this document is MIT-licensed: `SPDX-License-Identifier: MIT`, Copyright (c) 2026 Delegas Labs, S.L. Copy and
adapt it freely.
