Skip to content
Cassette

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.

The Cassette that the script tag gives you only has init, ready, started, progress, track and section; entitlement, checkout, consume, requireLicense, leaderboards and the rest are on the client that await Cassette.init() (or await Cassette.ready) resolves with, and there is no Cassette.storage (with the drop-in localStorage is already the player's, with v2.js it is (await Cassette.init()).storage): called on the global they throw and say so in the console, so a try/catch can't turn them into silence. 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:

<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.

EngineWhat 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 4the 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 JSthe drop-in, and start inside Cassette.ready.then(…)
Rust (macroquad)the drop-in; call load("game.wasm") inside Cassette.ready.then(…)
Emscripten builds with pthreadsnot 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):

<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:
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

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:

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.

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.

reasonMeaningSpent?
signed-outa guest has no items; Cassette doesn't open the sign-in sheet for this, call cassette.signIn() if you want itno
insufficientnot enough; balance is what they holdno
not-consumablea one-time item (it is owned, not spent)no
unknown-itemno such item in this game, or it was removedno
invalida sku, quantity or ref the API refuses; checked in the game, nothing was sentno
ref-reusedthe ref was spent for another quantity (quantity says which)no
rate-limitedover 60 spends a minute for this player in this game; retryAfter is the seconds to wait before asking againno
unavailablethe game was taken down; its items can't be spentno
unsupportedopened outside Cassette (from your own disk or server), or an old hostno
failedunknown: ask again with the same refmaybe

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:

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.

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:

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:

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 }]
"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

  • 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

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

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:

"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

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.