# Playtest hosting: agent guide > Deploy a web game build (Unity WebGL, Godot web export, or plain HTML/JS) and get back a > link that plays it. You (the agent) do the whole loop over HTTP; your human only approves > you once in a browser. ## Before you deploy: game state must live in browser storage Analytics here (progress, pinned metrics, save replay) are read from the game's saved state, with no platform code in the game. That only works if the game keeps its state in one of the supported places: 1. localStorage, as JSON under readable keys (e.g. "save" → {"level": 3, "deaths": 12}). Preferred. 2. IndexedDB (what Unity PlayerPrefs / persistentDataPath and Godot user:// use in web builds). If you are writing or changing the game, use one of these. If it keeps state anywhere else (memory, cookies, its own backend), add the save.js wrapper from "Game state" below so the state is mirrored into localStorage at checkpoints. Tell your human if you can't. Three rules that protect the game and its players: - Builds are public. Anyone with the play link can download every file of every build (there are no private builds yet). Never put secrets in a build: API keys, tokens, credentials. - Games share a parent domain. Each game's localStorage and IndexedDB are its own, but a cookie set for the parent domain is visible to every other game. Cookies a game sets are kept to its own origin (any Domain= is removed). Don't use cookies for anything private; keep state in localStorage. - Keep each saved key under 64 KB. A larger value isn't kept, and save points that hold one can't be restored or rewound. Split state by what it is (settings, progress, world) instead of one large key, and write only the keys that changed. API base: https://api.betacade.com OpenAPI spec: https://api.betacade.com/openapi.json (YAML: https://api.betacade.com/openapi.yaml, human docs: https://api.betacade.com/) All API requests and responses are JSON. Every success carries a `next` hint saying what to do next; every error is {"error": {"code": "...", "message": "... Fix: ..."}} — branch on `code`, follow the fix in `message`. ## Start here: concepts Read this first; the steps below use these words. - Save: the game's localStorage keys (or IndexedDB). The play page syncs them to us about 2 seconds after the game writes, at most once every 30 seconds, plus once when a session ends. - Save point (also "checkpoint"): one synced snapshot of those keys at a moment. - Save history: one tester's save points in order, across their sessions and builds. - Branch: a line of save history. A new branch starts whenever a tester, or you, resume from an older save point; the old line is kept as it was. Resuming in the studio (a restore link, or the replay viewer's "Open in-game") starts a new branch FROM THE POINT YOU CHOSE, never from the tester's latest save, and never changes the tester's own history. - Tester: one player of one game, shown as {"testerId", "displayName", "nameSource"}. They have an alias ("Amber Fox"), never an email you can see. - Resumable (a build setting): this build loads its whole state from the saved keys. It lets testers carry on across builds and devices, and lets you open any save point in this build. - Replayable (a build setting): this build records its live state, so a session plays back smoothly. It needs code in the game (section 11). Main ●───●───●───●───● one ● per save point, oldest left │ "From 12:40" └───●───● resumed from an older point: a new branch The two kinds of replay: | | Save-point replay | Smooth replay | | --------------------- | ----------------------------------------- | ----------------------------------------------- | | Build setting | resumable: true | replayable: true (and resumable, usually) | | What's recorded | the saved keys, at each sync | the game's own snapshots, up to 30 a second | | What the game must do | load from its save; in replay skip input, | register wt.onSnapshot and wt.onReplay, keep a | | | saves and tracking, then wt.ready() | stateVersion in the snapshot | | What the viewer shows | a still frame per save point | continuous playback, scrubbing, 0.5x to 8x | | What breaks it | state kept outside the saved keys | changing the snapshot's shape without changing | | | | stateVersion | Ask your human first: quick, or thorough? Before changing the game, ask which they want, and say what each costs for THIS game (you've seen its code; judge it, don't guess from this page): - Quick: deploy the build as it is. Playable in minutes, with sessions, play time, errors and feedback. Progress and save-point replay work only if the game already keeps its state in localStorage. - Thorough: also make the game resumable (all state in localStorage, loaded at startup) and, if they want smooth replay, replayable (the hooks in section 11), then test that a resumed save and a replay look right. This can take a while: it depends on how the game keeps its state and how its loop is written. Either way, deploy the quick version first if you can, so they have a link while you work. Which settings to choose: - All the game's state is in localStorage, loaded at startup: set resumable: true. - State lives anywhere else: add the save.js wrapper ("Game state" below) first, then resumable. - You can split the game into state = step(state, input, dt) and render(state): also set replayable: true and add the hooks (section 11). Otherwise leave it off; save-point replay still works. stateVersion (smooth replay only): - Change it when the snapshot's SHAPE changes (a field renamed, added with new meaning, or its type changed). Don't change it on every build. - A recording always plays in the build it was recorded on, so old recordings keep working after you change it. The viewer refuses a recording whose stateVersion isn't the one that build's wt.onReplay registers, and falls back to save points with a note; it never guesses. Lists and limits: - Every list takes ?limit= (default 20, at most 100; the tester timeline at most 50) and pages with ?cursor= from the previous page; nextCursor is null on the last page. Newest first unless the section says otherwise. A limit out of range is 400 "invalid_request". - A saved value over 64 KB isn't kept: it shows as {"tooLarge": true} and can't be fetched. - Feedback text is "body"; a bug's text is "description". A minimal working game: one file, every hook. REQUIRED parts make the platform's analytics, resume and save-point replay correct; OPTIONAL parts add to them. It runs the same on any other host (every wt call is optional-chained). ```html Tiny Toad ``` Changelog (breaking changes marked; tell your human when one affects them): - 2026-10-01: a player who plays without sharing data gets a tester when they first write (their alias, no play data), so you can reply; feedback's "tester" is null only for a note with no browser key. - 2026-10-01: builds carry "warnings" (finalize says if a file uses cookies), and cookies a game sets are kept to its own origin (any Domain= is removed). - 2026-10-01: production moved to https://api.betacade.com. Games are served from their own domain, . (see each build's buildUrl; don't build these URLs yourself). Existing tokens, games, builds and testers carried over. - 2026-09-30: you can write to testers: POST /v1/games//players//messages (section 7). BREAKING: feedback no longer has "email", and nameSource is never "email": testers are the studio's name for them or their alias. - 2026-09-29: saves are a tree (section 9). BREAKING: checkpoints carry "branchId", and restore links need it ({"branchId", "at"}). - 2026-09-28: BREAKING: testers became objects {"testerId", "displayName", "nameSource"} everywhere they were numbers; address them by testerId. ## 1. Sign in (device authorization, one time) POST https://api.betacade.com/v1/device/authorize Content-Type: application/json {"clientName": "Claude Code"} → 200 {"deviceCode": "...", "userCode": "ABCD-EFGH", "verificationUrl": "https://api.betacade.com/device?code=ABCD-EFGH", "interval": 5, "expiresIn": 900, "next": "..."} Tell your human: "Open , check the code matches , sign in with your email and approve." They get a 6-digit code by email; no account is needed beforehand (one is created when they approve). Then poll: POST https://api.betacade.com/v1/device/token Content-Type: application/json {"deviceCode": ""} - 400 {"error": {"code": "authorization_pending"}}: not approved yet; wait `interval` seconds and poll again. - 400 "slow_down": you polled too fast; add 5 seconds to your interval. - 400 "access_denied": the human denied it. Stop. - 400 "expired_token": 15 minutes passed. Start again at /v1/device/authorize. - 200 {"accessToken": "bc_pat_...", "tokenType": "Bearer", "scopes": ["games:read", "games:write"]} The token is returned exactly once. Store it (e.g. in an env var or your project's local, git-ignored config) and send it on every request below: Authorization: Bearer ## 2. Create a game (once per game) Before creating it, ask your human what their studio is called (the name players should see the game is by). Players see it on the play page's welcome: "Space Frogs by Pond Studio". POST https://api.betacade.com/v1/games {"name": "Space Frogs", "studioName": "Pond Studio"} → 201 {"id": "", "slug": "space-frogs", "studioName": "Pond Studio", "playUrl": "https://play.betacade.com/g/space-frogs", ...} studioName is optional (at most 60 characters). Leave it out if your human has none: the page then names only the game, never your account. Set or change either name later (the slug and every link stay): PATCH https://api.betacade.com/v1/games/ {"studioName": "Pond Studio"}; null clears it. GET https://api.betacade.com/v1/games lists your games; reuse the id on later deploys instead of creating another. ## 3. Start a build POST https://api.betacade.com/v1/games//builds Idempotency-Key: Pick the upload mode by what you can do: | You can… | Use | | ----------------------------------------------------- | ------------------------------------------------------------------- | | Run a shell (macOS, Linux, Windows 10+) | `tar -czf build.tar.gz -C .` then archive mode | | Run a shell with `zip` | `cd && zip -r ../build.zip .` then archive mode | | Only make HTTP requests, or have just one HTML file | files mode (`sha256` optional; without it every file is uploaded) | tar with gzip ships with all three desktop OSes; zip often isn't installed on minimal Linux. Archives (.zip, .tar, .tar.gz) are recognized by their bytes, not their file name. Archive mode: {"mode": "archive", "sizeBytes": , "notes": "optional release notes"} → 201 {"number": 1, "mode": "archive", "uploadUrl": "https://...", "uploadExpiresAt": "...", "next": "..."} Files mode (one entry per file, paths relative to the build folder): {"mode": "files", "files": [{"path": "index.html", "sizeBytes": 1234, "sha256": ""}]} → 201 {"number": 1, "mode": "files", "uploads": [{"path": "index.html", "uploadUrl": "https://..."}], "alreadyStored": [], ...} Files whose sha256 we already store for you are left out of `uploads` (listed in `alreadyStored`), so a rebuild only sends what changed. Send sha256 to get this. Limits: 100 MB unpacked per build, 5000 files, no absolute paths or "..". ## 4. Upload PUT the raw bytes to each uploadUrl (no Authorization header; the URL is pre-signed): curl -sS -T build.tar.gz "" ## 5. Finalize POST https://api.betacade.com/v1/games//builds//finalize → 200 {"number": 1, "status": "ready", "preset": "unity|godot|html", "fileCount": 42, "warnings": [], "playUrl": "https://play.betacade.com/g/space-frogs", "buildUrl": "https://play.betacade.com/g/space-frogs/1", "next": "..."} We check every upload landed (and matches its sha256), unpack the archive, detect the engine preset, and find the entry index.html (at the root, or inside a single top-level folder). - The response carries "warnings" (usually []): what we noticed but didn't reject, e.g. "game.js uses document.cookie: ...". The build is ready; fix them in your next build. - 409 "upload_missing": PUT the missing files, then finalize again. - 422 (e.g. "no_entry", "path_escape", "build_too_large", "sha256_mismatch"): the build is marked failed; fix what the message says and start a new build. ## 6. Hand back the links - playUrl always plays the latest ready build. Give this to your human. - buildUrl plays that exact build forever. - GET https://api.betacade.com/v1/games//builds lists builds and their status. - Tell your human they can see who played at https://api.betacade.com/studio (they sign in with an email code). ## 7. Who played (when your human asks) Players are anonymous testers. The play page asks each player first, and records nothing for one who chooses to play without sharing data. Everywhere below a tester is {"testerId": "3f9a1c07be", "displayName": "Amber Fox", "nameSource": "alias"}: address them by testerId (a random id, unique in the game) and refer to them by displayName, which is your own name for them ("studio"), else a random alias drawn once for them in this game ("alias"). Nothing is numbered. Testers have no email you can see, even ones signed in on the play page. GET https://api.betacade.com/v1/games//players → 200 {"items": [{"tester": {"testerId": "3f9a1c07be", "displayName": "Amber Fox", "nameSource": "alias"}, "alias": "Amber Fox", "studioName": null, "firstSeenAt": "...", "lastSeenAt": "...", "playSeconds": 312, "sessionCount": 2, "builds": [1, 2], "errorCount": 1, ...}], "next": "..."} Name a tester for your human (private: the tester never sees it; games:write): PATCH https://api.betacade.com/v1/games//players/ {"name": "Sam from Discord"} → 200 {"tester": {"testerId": "3f9a1c07be", "displayName": "Sam from Discord", "nameSource": "studio"}, "alias": "Amber Fox", "studioName": "Sam from Discord", "next": "..."} At most 60 characters; {"name": null} clears it. GET https://api.betacade.com/v1/games//sessions?limit=20 → 200 {"items": [{"id": "", "tester": {"testerId": "3f9a1c07be", ...}, "buildNumber": 2, "startedAt": "...", "playSeconds": 190, "status": "playing|idle|ended", "errorCount": 1, "display": {"viewportW": 390, "viewportH": 664, "screenW": 390, "screenH": 844, "dpr": 3, "orientation": "portrait", "touch": true}}], "nextCursor": null, "next": "..."} Newest first; pass nextCursor back as ?cursor= for the next page. To answer "who played today?", page until startedAt is before today. "display" is the size the session opened with: viewportW × viewportH is the game's own frame in CSS pixels (what your layout gets, smaller than the screen), screen is the device's; null when the page recorded none. Resizes during play are kept too, and the studio's replay shows each save point at the size the tester had then. GET https://api.betacade.com/v1/games//sessions/ → 200 {..., "errors": [{"message": "...", "stack": "...", "at": "...", "lines": ["log: ...", "warn: ..."]}], "endLines": ["..."]} Each error carries the console lines leading up to it (the last 200, each cut to 500 characters). Play time is the span from a session's start to its last heartbeat (sent every 30 s while the tab is visible). All three need the games:read scope your token already has. Each player also carries "metrics": every pinned metric's latest and highest value for them (see step 8). How many were playing over time (counts only, no testers named; games:read too): GET https://api.betacade.com/v1/games//activity?window=1h → 200 {"window": "1h", "bucketSeconds": 60, "buckets": [{"at": "...", "players": 2, "byBuild": [{"buildNumber": 4, "players": 2}], "new": 1, "returning": 1}, ...], "builds": [{"buildNumber": 4, "label": "build 4", "players": [0, ..., 2]}, ...], "playingNow": 3, "playingNowBreakdown": {"new": 1, "returning": 2, "byBuild": [{"buildNumber": 4, "testers": 2}, {"buildNumber": 3, "testers": 1}], "signedIn": 1, "anonymous": 2}, "peak": {"at": "...", "players": 3}, "next": "..."} window is 1h (1-minute buckets, the default), 24h (15-minute) or 7d (hourly). Each bucket counts the distinct players with a session overlapping it, oldest first, zeros included; the last bucket holds now. A bucket's byBuild counts each of its players once, on the newest build they played in it (so it sums to players); "new" players' first session on this game overlaps the bucket, the rest are "returning". builds is the chart legend: the builds in any bucket, newest first, at most five, then {"buildNumber": null, "label": "older"} summing the rest; each entry's players lines up with buckets. playingNow counts players whose session heartbeated in the last 75 seconds. playingNowBreakdown splits them: "new" had no earlier session on this game, "returning" did; byBuild is newest build first (someone playing two builds at once counts under each); "signedIn" signed in to a player account on the play page, "anonymous" didn't. How long testers played in a window (counts only; games:read): GET https://api.betacade.com/v1/games//play-time?window=1h → 200 {"window": "1h", "buckets": [{"label": "<1 min", "minSeconds": 0, "maxSeconds": 60, "testers": 2}, {"label": "1–5 min", "minSeconds": 60, "maxSeconds": 300, "testers": 3}, ..., {"label": "60+ min", "minSeconds": 3600, "maxSeconds": null, "testers": 0}], "testers": 5, "medianSeconds": 140, "next": "..."} Each tester active in the window (a session overlapping it, as activity counts them) is counted once, by their total play time inside the window: every session's start → last heartbeat, clipped to the window, summed. Buckets are [minSeconds, maxSeconds). Same windows as activity. What size of screen they play on (counts only; games:read): GET https://api.betacade.com/v1/games//displays?window=1h → 200 {"window": "1h", "testers": 6, "unknown": 0, "classes": [{"name": "phone", "label": "Phone", "minWidth": 1, "maxWidth": 599, "testers": 4}, {"name": "tablet", ..., "testers": 0}, {"name": "desktop", ..., "maxWidth": null, "testers": 2}], "portrait": 4, "landscape": 2, "touch": 4, "sizes": [{"viewportW": 390, "viewportH": 664, "testers": 3}, ...], "next": "..."} Each tester active in the window counts once, by their most recent session there and the game frame size it opened with: phone under 600 CSS pixels wide, tablet 600–1024, desktop wider. "sizes" is the five most common exact sizes. If most testers are on phones, make sure the game fits a narrow portrait frame and takes touch. Feedback testers sent from the play page's pause menu (newest first, paged like sessions): GET https://api.betacade.com/v1/games//feedback → 200 {"items": [{"tester": {"testerId": "3f9a1c07be", "displayName": "Amber Fox", "nameSource": "alias"}, "buildNumber": 2, "sessionId": "", "atSeconds": 95, "createdAt": "...", "body": "The jump feels floaty"}], "nextCursor": null, "next": "..."} "atSeconds" is how far into the session it was sent. A player who plays without sharing data still has a tester once they write (their alias, no play data, sessionId null), so you can reply; "tester" is null only for the rare note sent with no browser key. There is no email: a tester is their alias, or your name for them. Reply to a tester, or write first (games:write). They see it from your studio's name (the game's studioName, else "the developer"), never your human's account or you; Betacade emails a tester who has an account (at most once per thread every 6 hours, sooner once they've read it), and you never see their address: POST https://api.betacade.com/v1/games//players//messages {"body": "Thanks! Fixed in build 3. Does the jump feel better now?"} → 201 {"tester": {"testerId": "3f9a1c07be", "displayName": "Amber Fox", "nameSource": "alias"}, "items": [{"id": "...", "sender": "player", "body": "The jump feels floaty", "buildNumber": 2, "atSeconds": 95, "createdAt": "..."}, {"id": "...", "sender": "developer", "body": "Thanks! Fixed in build 3. ...", "buildNumber": null, "atSeconds": null, "createdAt": "..."}], "blocked": false, "contactable": true} Read the thread with GET on the same path (games:read). 1 to 2,000 characters. 403 "tester_blocked_messages": they block your studio's messages. 403 "tester_not_contactable": they turned sharing off and never wrote (reply once they send a note). 429 "studio_messages_too_fast": at most 100 an hour per game and 10 per tester; wait Retry-After seconds. Never ask a tester for contact details on your human's behalf unless your human asked you to. ## 8. Pin metrics: how far testers get The play page syncs each tester's localStorage (keys starting with "secret:" never leave the browser). Pin up to 5 metrics per game, each pointing at a number inside a key's JSON value, e.g. "save" → "level". Only JSON numbers count; strings and missing values are ignored. First, see what the saves hold: GET https://api.betacade.com/v1/games//save-keys → 200 {"items": [{"key": "save", "players": 4, "sample": "{\"level\":3,\"deaths\":12}", "paths": [{"path": "level", "sample": 3}, {"path": "deaths", "sample": 12}]}], "next": "..."} Then pin one (games:write). path is a dot path into the value ("stats.deaths"; numeric segments index arrays), or "" when the value itself is the number: POST https://api.betacade.com/v1/games//metrics {"label": "Level", "key": "save", "path": "level"} → 201 {"metric": {"id": "", "label": "Level", "key": "save", "path": "level", ...}, "backfill": {"changes": 57, "truncated": false}, "next": "..."} Pinning replays the saves already synced, so earlier plays count too. - 409 "metric_limit_reached": 5 are pinned; unpin one with DELETE https://api.betacade.com/v1/games//metrics/. - 409 "metric_exists": that key and path are already pinned. - 422 "invalid_metric": fix the label, key or path as the message says. Then read it: GET https://api.betacade.com/v1/games//metrics/ → 200 {"metric": {...}, "playersWithValue": 4, "reached": [{"value": 1, "players": 4, "medianPlaySeconds": 40}, {"value": 3, "players": 2, "medianPlaySeconds": 610}], "latest": [{"value": 2, "players": 2}, {"value": 3, "players": 2}], ...} "reached" answers "how many testers got to level 3, and after how much play time?" (median of their total play time, across sessions, when they first reached it). GET https://api.betacade.com/v1/games//metrics lists the pinned ones. The same for recent testers only: GET https://api.betacade.com/v1/games//metrics/?window=1h adds "window": "1h", "activeTesters": 5, "reachedRanges": [{"from": 1, "to": 3, "testers": 5, "medianPlaySeconds": 40}, {"from": 4, "to": 4, "testers": 2, "medianPlaySeconds": 610}] counting only the testers active in the window (a session overlapping it, as activity counts them), though each one's highest value is their highest ever, not just the window's. Values reached by the same number of testers are merged into one range (the same testers reached them all); medianPlaySeconds is to reach "from". window is 1h (the default), 24h or 7d and scopes only these fields; "reached" and "latest" always cover every tester. One tester's current save (the tip of the save branch they last played, see 9) and change timeline (newest first, paged like sessions): GET https://api.betacade.com/v1/games//players//save ## 9. Resumable builds: testers resume, you play from their saves Mark a build resumable ONLY after the game keeps ALL its state in localStorage as described in "Game state" below: it loads everything from those keys at startup and writes them with setItem / removeItem / clear. Restoring a save into a game that keeps state anywhere else can corrupt it. When you turn it on, tell your human you did and why it's safe. Set it when starting the build ({"mode": ..., "resumable": true}), or toggle it later (games:write): PATCH https://api.betacade.com/v1/games//builds/ {"resumable": true} {"resumable": false} turns it off. `resumable` shows on every build, and on the game (its latest ready build). On a resumable build the play page offers each tester who sends data "Switch save" (their saves, on any device they're signed in on: continue, rename or delete one) and "Go back in time" (one save's checkpoints; loading an older one is marked as a rewind). Saves are a tree. Each save sync is a checkpoint on a branch; going back to an older checkpoint and saving starts a new branch from there ("From