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.
| 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):
<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). Theredocument.fullscreenElementstays empty andfullscreenchangedoesn'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 answerstrueorfalse. - Your own server. Requests to it come from the origin
null: it must answer CORS for any origin, and cookies andcredentials: 'include'never work from the frame (§6). - Links to other pages.
window.open()andtarget="_blank"do nothing in the frame (Firefox throws an error, the others returnnull).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 answerstrueif they did andfalseif not. Only absolutehttpandhttpsaddresses, 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), andcassette.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
storageevent 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
storageevent 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
storagefires, thenplayer, thencassette.signIn()resolves: when yourstoragehandler runs, storage already is the account's andcassette.playeris 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 andcassette.filesfollow 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 inlocalStorageorcassette.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.
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:
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
minandmax(at most ±9007199254740991). orderisdescwhen higher is better,ascfor times.formatisnumber(the default) ortime-ms, which says the integer is milliseconds. Cassette shows boards on the store page and formatstime-msas 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, notach.firstor_x). - Once a board has scores its
ordercan'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: trueshows 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
nullor[]: to see boards and trophies work, upload the build and play its unlisted link (https://cassette.gg/g/<slug>, live at once). - Signed out,
submitandunlockanswernull: there is no player to score (top,aroundandliststill read — the boards are public). Offline, refused or over a limit, the call answersnull(top,aroundandlistanswer[]) 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
bestin 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.
rankis among active players; a banned or deleted player's score is off every board.unlockis idempotent: the first call answersnew: true, every later onenew: false, andunlockedAtkeeps the first time.unlocksis 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).
metais 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
minandmaxto 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.ggandaud= your game's id; subis 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.