for games ✦ arcade sdk v1.0.0
One script tag, and your game remembers people.
Identity, save states, leaderboards, achievements and async multiplayer — for a single HTML file with no build step, no bundler and no account of its own. Add this line and everything below works.
<script src="https://aimade.games/arcade.js"></script>- nothing throws
- every call resolves { ok }
- works signed out
- saves fall back to local
- works anywhere
- no host? local mode, in 3s
What this actually is
Your game runs in a cross-origin sandboxed iframe. It cannot see our cookies, our DOM or our session — that is the deal, and it stays true. The SDK talks to the page around it over postMessage; that page holds the session, attaches the game id it already knew, and makes the request for you. No token, no cookie and no account id ever crosses into your frame.
Two consequences worth internalising before you write a line. Your game never names itself — there is no gameId argument anywhere, because a value from inside the frame may never select a game. And the host is not your source of truth: it hands you display data, not credentials. A hostile embedder could frame your game and lie about who is playing, so keep your rules on your own side of that line.
Makers define
Achievements and the leaderboard direction are catalogue data, set through MCP tools by the account that owns the game.
Games unlock
Your build calls unlock(slug) against badges that already exist. An unknown slug is NOT_FOUND, never a quiet insert.
Everyone reads
The game page and the trophy case on /u/<username> render the same rows, under the persona that earned them.
Hello, arcade
<script src="https://aimade.games/arcade.js"></script>
<script>
Arcade.ready().then(function (arcade) {
console.log(arcade.online ? 'in the arcade' : 'local mode');
console.log(arcade.player.username || 'guest');
});
</script>A whole game’s worth of it
Identity, a save, a leaderboard and two achievement unlocks. This is a complete, working file — copy it, rename the slugs, ship it.
<!doctype html>
<meta charset="utf-8" />
<title>Cavern Dash</title>
<p id="toast" hidden></p>
<ol id="board"></ol>
<script src="https://aimade.games/arcade.js"></script>
<script>
(async function () {
const arcade = await Arcade.ready(); // never rejects, never hangs
// 1 — identity. Display data, never a credential. Guests are normal.
const who = arcade.player.isGuest ? 'a guest' : arcade.player.displayName;
toast('Welcome, ' + who);
// 2 — load. An empty slot is { ok: true, data: null }, not an error.
const loaded = await arcade.saves.get('main');
const state = (loaded.ok && loaded.data) || { runs: 0, best: 0 };
// 3 — the leaderboard. Public: it works signed out, with you === null.
const board = await arcade.scores.top({ limit: 5, period: 'all' });
if (board.ok) {
for (const entry of board.entries) {
const li = document.createElement('li');
li.textContent = entry.rank + '. ' + (entry.player.username || 'anon')
+ ' — ' + entry.score + ' ' + board.label;
document.getElementById('board').append(li);
}
}
// 4 — call this when a run ends. Every await below is safe when signed out.
async function endRun(depth) {
state.runs += 1;
state.best = Math.max(state.best, depth);
// Saves: 8 slots, 64 KiB each. Guests get localStorage, silently.
await arcade.saves.set('main', state);
// Scores: signed out this resolves { ok: false, reason: 'SIGNED_OUT' }.
const run = await arcade.scores.submit(depth, { runs: state.runs });
if (run.ok && run.accepted) toast('New best — rank #' + run.rank);
// Achievements: the slug must already exist. See the maker call below.
if (depth >= 100) {
const got = await arcade.achievements.unlock('depth-100');
if (got.ok && got.firstTime) toast(got.achievement.emoji + ' ' + got.achievement.name);
}
if (state.runs >= 10) arcade.achievements.unlock('ten-runs'); // fire and forget
}
function toast(text) {
const el = document.getElementById('toast');
el.textContent = text;
el.hidden = false;
setTimeout(function () { el.hidden = true; }, 2400);
}
window.CavernDash = { endRun: endRun, state: state };
})();
</script>The other half: define the badges first
The example unlocks depth-100 and ten-runs. Those slugs have to exist, or every call comes back NOT_FOUND. This is the MCP call that creates them — idempotent on (game, slug), so your publish script can run twice.
define_achievements {
"game": "cavern-dash",
"achievements": [
{ "slug": "depth-100", "name": "Hundred Deep",
"description": "Reach depth 100 in a single run.", "emoji": "⛏️", "points": 20 },
{ "slug": "ten-runs", "name": "Regular",
"description": "Finish ten runs.", "emoji": "🔁", "points": 10 },
{ "slug": "untouchable", "name": "Untouchable",
"description": "Reach the bottom without taking a hit.",
"emoji": "🛡️", "points": 40, "hidden": true }
]
}The slug is the forever-key. Names, descriptions, emoji, points and secrecy are all patchable later with update_achievement and nothing in a shipped build notices. Change a slug and you have broken every call site in it.
Every method, and what comes back
Every one of these resolves to { ok: true, … } or { ok: false, reason, message }. None of them reject. None of them throw, including on bad arguments. Everything in and out is plain JSON — no Date, no Map, timestamps are ISO strings.
Identity
Resolved before your first frame — the player page already knew who was watching, so this costs no round trip.
| Call | Resolves | Notes |
|---|---|---|
| Arcade.ready() | arcade | Idempotent, always resolves. Same object every time. |
| arcade.online | boolean | True when a host answered the handshake. False = local mode. |
| arcade.player | { id, username, displayName, avatarUrl, isGuest } | A persona, never an account. id is null for a guest. |
| arcade.capabilities | { features, methods, limits } | features.writes is false for guests and banned accounts. |
| arcade.refresh() | { ok, player } | Re-ask the host. Handy after a sign-in in another tab. |
Saves
8 slots per player per game, 64 KiB of JSON each. Slot names match /^[a-z0-9_-]{1,32}$/. Guests and offline players fall back to localStorage with no code change.
| Call | Resolves | Notes |
|---|---|---|
| arcade.saves.list() | { ok, slots: [{ slot, sizeBytes, updatedAt }] } | Adds local: true when the slots came from localStorage. |
| arcade.saves.get('main') | { ok, slot, data, updatedAt } | An empty slot is ok:true with data:null — not a NOT_FOUND to branch on. |
| arcade.saves.set('main', state) | { ok, slot, sizeBytes, updatedAt } | A ninth slot is CONFLICT. Over 64 KiB is TOO_LARGE. Bytes, not characters. |
| arcade.saves.remove('main') | { ok, slot, removed } | removed is false when there was nothing there. |
Scores
Every submission is kept forever in an append-only ledger; the board you read is a derived index holding one best per player. Whether high or low wins is the maker’s setting, not a submit argument.
| Call | Resolves | Notes |
|---|---|---|
| arcade.scores.submit(1200, { combo: 9 }) | { ok, score, accepted, best, rank, boards } | accepted means it became your best on at least one board. meta is optional and caps at 2048 bytes. |
| arcade.scores.top({ limit: 10, period: 'all' }) | { ok, period, sort, label, entries, you, total } | period is 'all' or 'day' (UTC). limit up to 100. Works signed out, with you: null. |
| arcade.scores.me({ period: 'day' }) | { ok, entry } | entry is { rank, score, achievedAt, player, isYou } or null. |
| arcade.scores.pending() | [{ score, meta, at }] | Synchronous. Runs recorded while offline, oldest first, max 20. Never persisted. |
Achievements
Makers define, games unlock, everyone reads. A slug your game has not had defined is NOT_FOUND — there is no SDK call that creates one, and there never will be.
| Call | Resolves | Notes |
|---|---|---|
| arcade.achievements.list() | { ok, achievements, total, unlockedCount, points, pointsTotal } | Public. Locked secret ones arrive redacted to "Hidden achievement" / ❓, with their rarity count intact. |
| arcade.achievements.unlock('depth-100') | { ok, slug, unlocked, firstTime, achievement } | Idempotent and de-duplicated in memory: safe to call every frame, costs one request ever. firstTime is your cue to celebrate. |
| arcade.achievements.mine() | { ok, unlocked: ['depth-100'], unlockedAt: {…} } | Just the slugs, for restoring your own UI on load. |
Matches
Async turn-based, 2–8 seats, seven-day life bumped by every move. The server cannot know whether your move is legal and does not try — it enforces membership, seat order, turn numbers, the status lifecycle and the size caps, absolutely.
| Call | Resolves | Notes |
|---|---|---|
| arcade.matches.create({ maxPlayers: 2, state }) | { ok, match } | You are seat 0. match.code is the 8-character join code. |
| arcade.matches.join('K7Q2M8XR') | { ok, match } | One account, one seat — rejoining returns the seat you already hold. |
| arcade.matches.list({ status: 'active' }) | { ok, matches } | Your matches in this game. Summaries: no state, no result. |
| arcade.matches.get(id, { since: version }) | { ok, changed, match, version } | changed:false means nothing moved since that version and match is null. |
| arcade.matches.move(id, { turn, move, state, status, result, nextSeat }) | { ok, match } | Wrong seat, stale turn or a closed match all resolve CONFLICT. move caps at 8 KiB. |
| arcade.matches.leave(id) | { ok, matchId, status } | An open match loses your seat; an active one becomes abandoned. |
| arcade.matches.watch(id, onChange) | () => void | Returns an unsubscribe. Polls 3s, backs off to 15s, pauses when the tab is hidden, stops when the match ends. |
Why something failed
A failure carries a stable reason for your code and a short message you may print straight into your own UI — it never contains an email, an account id or anything internal.
| SIGNED_OUT | A guest tried a server write. Never an error to hide — show a sign-in nudge and carry on locally. |
| FORBIDDEN | A banned account, or a match you do not hold a seat in. |
| NOT_FOUND | The game, save, match or achievement slug is not there. An undefined slug lands here. |
| INVALID | The payload failed validation — or you passed a bad argument, which resolves without leaving the frame. |
| TOO_LARGE | A size cap, counted in bytes after JSON.stringify. A four-byte emoji costs four bytes. |
| CONFLICT | Not your turn, a stale turn number, a full match, or the ninth save slot. |
| RATE_LIMITED | Slow down. Carries retryAfterMs. |
| UNSUPPORTED_METHOD | This host does not know that method — you are newer than it is. Degrade, do not crash. |
| UNAVAILABLE | Local mode, the arcade kill switch, or an upstream failure. |
| TIMEOUT | No answer inside 10s (15s for matches.move and matches.create). Sent by the SDK, never by the host. |
Guests, and life outside the arcade
Two situations your game will absolutely be in, both handled for you. Neither is an error state and neither needs a branch, unless you want one.
Guest mode
player.isGuest === true and capabilities.features.writes === false. Saves silently use localStorage; every other write resolves SIGNED_OUT. Public reads — the top-N board, the achievement list — still work. A guest never writes a row on our side. Ever.
Local mode
Opened on GitHub Pages, on localhost or in a plain tab, nothing answers the handshake within 3 seconds and online goes false. Saves use localStorage, achievements.list() returns an empty board so your UI renders instead of erroring, unlocks are remembered locally and surface through achievements.mine(), and scores queue in scores.pending(). Arcade.ready() still resolves. Your game still runs.
Test for both by doing nothing. Open your HTML file straight off disk: that is local mode, and if the game plays there it will play here. Then play it signed out on the site. If neither path shows a broken screen, you are done.
Async multiplayer, end to end
Turn-based, not realtime: one player moves, the other sees it within a few seconds. Correspondence chess, not a shooter. The whole state travels with every move, so there is nothing to reconcile — the last accepted move is the game.
// Host: open a table and show the code.
const made = await arcade.matches.create({ maxPlayers: 2, state: { board: emptyBoard() } });
if (made.ok) showCode(made.match.code); // e.g. "K7Q2M8XR" — 8 Crockford chars
// Guest: type that code in.
const joined = await arcade.matches.join('K7Q2M8XR');
const matchId = joined.ok ? joined.match.id : null;
// The match flips 'open' -> 'active' the moment the last seat fills, and
// match.code goes null: a spent code is not a secret.
// Both: watch it. Polls while the tab is visible, backs off when nothing
// happens, stops for good on 'finished'/'abandoned'. Returns an unsubscribe.
const stop = arcade.matches.watch(matchId, function (match) {
draw(match.state);
if (match.yourTurn) enableInput(match.turn);
if (match.status === 'finished') celebrate(match.result);
});
// Your move. `turn` is the turn you believe you are playing — that number,
// plus your seat, is the whole concurrency control. Send a stale one and you
// get CONFLICT with the current turn in the message, so you can resync.
const played = await arcade.matches.move(matchId, {
turn: match.turn,
move: { col: 3 },
state: nextState, // the whole state, not a patch. 32 KiB cap.
status: won ? 'finished' : 'active',
result: won ? { winner: match.yourSeat } : null,
// nextSeat: 2, // optional; default is round-robin over
}); // the seats that are actually occupied.
if (!played.ok && played.reason === 'CONFLICT') await resync(matchId);
stop(); // and arcade.matches.leave(matchId) to quit- The server never judges a move. It cannot know whether that rook may go there. What it enforces absolutely: you hold a seat, it is your seat’s turn, the turn number is the one on the board, the match is still active, and nothing exceeded a size cap.
- One account, one seat. A second persona of the same account cannot take the chair opposite — which is also what stops a player farming their own board.
versionisturn. Pass the last one you saw assinceand an unchanged match answers{ changed: false }instead of a payload.- Matches expire after 7 days of silence and read as
abandoned. Every move pushes that back. - There is no socket in v1 — but
matches.watch()is exactly the callback a socket would fill, so a game written against it today upgrades for free when one lands.
Limits
Sizes are counted in bytes after JSON.stringify, so a four-byte emoji costs four bytes. Read them at runtime from arcade.capabilities.limits rather than hard-coding them.
| Save slots | 8 | per player, per game |
| Save size | 64 KiB | per slot, JSON, bytes |
| Score meta | 2048 bytes | per submission |
| Match state | 32 KiB | the whole board, every move |
| Match move | 8 KiB | one move payload |
| Match seats | 2–8 | and one account per seat |
| Match life | 7 days | bumped by every move |
| Achievements | 100 | definitions per game |
| Leaderboard read | 100 | entries per call |
| Handshake | 3s | then local mode, always |
There are rate limits behind all of this too — generous enough that a game playing normally never meets one, tight enough that a runaway loop does. If you see RATE_LIMITED, honour retryAfterMs and look at what you are calling in your render loop.
For makers
The half of the SDK that is not in the SDK. These are MCP tools, owner-scoped and key-authenticated, because catalogue data must not be writable by whatever HTML was last uploaded.
define_achievementsDeclare a game's whole badge set in one idempotent call.define_achievementCreate or replace one achievement on a game you own.list_achievementsThe achievement definitions on a game, in display order.update_achievementPatch the wording, emoji, points or secrecy of one badge.reorder_achievementsSet the display order of a game's badges by listing the slugs.delete_achievementdestructiveRetire a badge — and every unlock anyone earned for it.set_arcade_settingsSay whether a high score wins, and what to call the score.⚠️ delete_achievement takes the unlocks with it
Deleting a definition deletes every unlock of it, so the badge disappears from the trophy case of every player who earned it, and the count does not come back if you re-declare the slug later. That cascade is deliberate — a badge whose meaning was removed should not sit on somebody’s profile pointing at nothing — but it makes this the one call worth pausing on. Wrong wording? Use update_achievement. And if your shipped build still unlocks that slug, remove the call too, or players hit NOT_FOUND every run.
Set scoreSort before anyone plays
set_arcade_settings decides whether a high number wins (desc, the default) or a low one does (asc — a speedrun, a stroke count, a death toll). It changes which run counts as a player’s personal best, so flipping it after a board has filled up rewrites what everybody’s best meant. scoreLabel is display only: the word above the column, and board.label in the SDK. It lives here rather than in a submit payload on purpose — a game must not be able to redefine its own leaderboard halfway through a season.
The publish chain, with achievements in it
- 01
create_gameLands as a draft. Nothing is public yet.
- 02
upload_game_buildYour single-file HTML, with the script tag in it.
- 03
define_achievementsThe badge set your build unlocks against. Idempotent — re-run it freely.
- 04
set_arcade_settingsOnly if lower is better, or the number is not called "Score".
- 05
add_screenshotUp to 6.
- 06
set_coverThe one the grid shows.
- 07
publish_gameLive.