Skip to content
aimade.games
Submit ✦

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.

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

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

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

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

CallResolvesNotes
Arcade.ready()arcadeIdempotent, always resolves. Same object every time.
arcade.onlinebooleanTrue 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.

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

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

CallResolvesNotes
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. Alternating by default; 'simultaneous' gives every seat one secret move per round, revealed together. The server cannot know whether your move is legal and does not try — it enforces membership, seat order, turn numbers, round completion, the status lifecycle and the size caps, absolutely.

CallResolvesNotes
arcade.matches.create({ maxPlayers: 2, state, mode: 'alternating' }){ ok, match }You are seat 0. match.code is the 8-character join code. mode is optional and fixed at creation: 'alternating' (default, one seat per turn) or 'simultaneous' (every seat commits one secret move per round).
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, no moves — but yourMoveNeeded and submittedSeats are there, which is what a lobby list needs.
arcade.matches.get(id, { since: version }){ ok, changed, match, version }changed:false means nothing moved since that version and match is null. A simultaneous match also carries mode, submittedSeats, yourSubmitted, yourMoveNeeded and moves — the last 3 revealed rounds, never another seat's move for the round still in play.
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. Simultaneous: nextSeat is INVALID, a second move in one round is CONFLICT, and state and status:'finished' are taken only from the seat that opened the round — so state must describe resolved rounds, never the move you are making.
arcade.matches.leave(id){ ok, matchId, status }An open match loses your seat; an active one becomes abandoned.
arcade.matches.pendingCodestring | nullNot a call — a value, read after Arcade.ready(). Null unless this player arrived through an invite link, which is most of the time. Always keep manual code entry.
arcade.matches.watch(id, onChange)() => voidReturns an unsubscribe. Polls 3s, backs off to 15s, pauses when the tab is hidden, stops when the match ends. In a simultaneous match it also fires when another seat commits mid-round, before anything is revealed.

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_OUTA guest tried a server write. Never an error to hide — show a sign-in nudge and carry on locally.
FORBIDDENA banned account, or a match you do not hold a seat in.
NOT_FOUNDThe game, save, match or achievement slug is not there. An undefined slug lands here.
INVALIDThe payload failed validation — or you passed a bad argument, which resolves without leaving the frame.
TOO_LARGEA size cap, counted in bytes after JSON.stringify. A four-byte emoji costs four bytes.
CONFLICTNot your turn, a stale turn number, a full match, or the ninth save slot.
RATE_LIMITEDSlow down. Carries retryAfterMs.
UNSUPPORTED_METHODThis host does not know that method — you are newer than it is. Degrade, do not crash.
UNAVAILABLELocal mode, the arcade kill switch, or an upstream failure.
TIMEOUTNo 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.

signed out

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.

no host

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.

js
// 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.yourMoveNeeded) enableInput(match.turn);   // true in either mode
  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

Simultaneous rounds: everybody moves at once, in secret

Pass mode: ‘simultaneous’ to create() and a round stops being a queue. Every seat submits exactly one move against the same turn number, and the round completes only when the last of them lands — rock-paper-scissors, Diplomacy orders, a betting round. The default, alternating, is unchanged and is what every match written before this was. The mode is fixed at creation and never patched: a table that changed how a round worked halfway through would invalidate every client’s copy of the rules.

The secrecy is real, not an honour system. While a round is in play, another seat’s move for it is not blanked or nulled in what you read — it is absent, because a field that exists is a field somebody eventually populates. Your own submission does come back, which is how a client that reloaded knows it has already played. Earlier rounds are public to everyone, and so is everything once the match is over: hiding the last round would only stop the loser reading how they lost.

js
// Rock-paper-scissors: both seats commit blind, the arcade reveals together.
const made = await arcade.matches.create({
  maxPlayers: 2,
  mode: 'simultaneous',              // fixed here and nowhere else
  state: { round: 0, scores: [0, 0] },
});

// Watch fires twice a round: once when a seat commits (submittedSeats grows,
// version moves) and once when the round reveals and the turn moves on.
const stop = arcade.matches.watch(matchId, function (match) {
  // 1. Fold the revealed rounds into your checkpoint. match.moves carries the
  //    last 3 rounds, every seat, oldest first — anything older is
  //    already inside match.state, which is why that window is enough.
  let view = match.state;
  for (const round of groupByTurn(match.moves)) {
    if (round.turn < view.round) continue;             // already folded in
    if (round.moves.length < match.players.length) continue;  // still secret
    view = resolve(view, round.moves);   // pure: same inputs, same answer,
  }                                      // on every client, no negotiation
  draw(view);

  // 2. One flag, both modes: does this match want a move out of you?
  if (match.yourMoveNeeded) enableThrows(match.turn, view);
  else showWaiting(match.submittedSeats);  // who has played is public...
                                           // ...what they played is not
  if (match.status === 'finished') celebrate(match.result);
});

// 3. Play the round. No nextSeat — nobody hands the turn to anybody, and
//    sending one is INVALID rather than quietly ignored.
const played = await arcade.matches.move(matchId, {
  turn: match.turn,
  move: { throw: 'rock' },     // secret until every seat has played
  state: view,                 // resolved rounds ONLY — never this throw
  status: view.scores.some((s) => s >= 3) ? 'finished' : 'active',
});

// Both are safe to send every time: the server takes state and a declared
// finish from whichever seat OPENED the round, and drops a later submitter's.
// That is the secrecy rule — the opener wrote its state before it could know
// what anyone else would play, so its state cannot leak the round.
if (!played.ok && played.reason === 'CONFLICT') await resync(matchId);
                                   // stale turn, or you already played it
  • yourMoveNeeded is the only flag a turn indicator should branch on. It means “this match is waiting on you” in both modes: it is your seat’s turn in an alternating match, and you still owe this round a move in a simultaneous one. yourTurn still exists and still means the seat pointer, so it is always false in a simultaneous match — branch on it there and your lobby never lights up.
  • submittedSeats is public, the payloads are not. Which seats have played the round in hand is exactly the tension the UI is made of — three of four in, waiting on one — so it is in every read, including list summaries. yourSubmitted is your own row of it.
  • match.moves is a window, not a history. It carries the last 3 rounds you are allowed to see, oldest first, and nothing more no matter how long the match runs. Fold anything older into state as you go — that is what the checkpoint is for. Alternating matches carry none: the seat that moved already wrote the consequence into state on its way past.
  • Only the seat that opens a round writes state or declares status: ‘finished’. Send both anyway; the server decides. The rule is a secrecy rule before it is a tidiness one: your state must describe rounds that have already been revealed and never the move you are making now. The opener’s state was computed before its author could know what anybody else would play, so it cannot leak the round. A later submitter’s is dropped for the opposite reason — theirs was computed knowing their own move, and a game that folded a secret into it would be publishing it early.
  • Resolution is yours, and must be deterministic. The server never judges a round; it hands every client the same inputs — the checkpoint state, the revealed moves, and any seed you put in that state — and every client derives the identical next state from them. That is why taking whichever checkpoint arrives first is safe: they would all have been the same value. Do not reach for Math.random() during resolution; seed it from state instead.
  • nextSeat is meaningless here. Nobody hands the turn to anybody, so passing one resolves INVALID rather than being quietly ignored — a silently dropped field is how a game ships believing it controls turn order. A second submission from one seat in one round is CONFLICT, and so is a move whose turn the match has already left behind.

Invite links: the code arrives with the player

Reading eight characters off a canvas and texting them to a friend is a step the site can take for you. Any match code can be sent as a URL:

https://aimade.games/g/{slug}/play?code=K7Q2M8XR

The play page checks the code’s shape — eight Crockford characters, nothing else from the URL is looked at — and hands it to your game in the handshake. By the time Arcade.ready() resolves it is sitting in arcade.matches.pendingCode, and your lobby can offer one tap instead of an input box. A code that is malformed, expired or already spent is simply ignored by the page and refused by join() — an invite is never an error screen between a player and your game.

js
// Your lobby, on boot. One tap when they followed an invite link,
// the manual code box for everybody else — which is still most people.
const invite = arcade.matches.pendingCode;   // "K7Q2M8XR", or null

if (invite) {
  showJoinButton('Join the match', async function () {
    const res = await arcade.matches.join(invite);
    if (res.ok) startMatch(res.match);
    else showCodeEntry(res.message);         // expired, full, or already yours
  });
} else {
  showCodeEntry();                           // the eight-character input
}
  • Always keep the manual path. pendingCode is null for everybody who opened your game the ordinary way, which is nearly everybody. It is a shortcut, not a route.
  • A join code is a bearer capability. Whoever holds it may take a seat — that was already true when it was text on a screen, so putting it in a URL grants nothing new. It is not a password and it is not an identity: the seat is still bound to the account that claims it, one per account.
  • Additive, and optional. The field joined protocol 1 after v1 shipped. A game written before it existed sees exactly what it saw before, and a host that never sends one leaves null.
  • 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.
  • version only ever goes up. Pass the last one you saw as since and an unchanged match answers { changed: false } instead of a payload. It moves on anything a seat could see — including, in a simultaneous match, another seat committing mid-round — so treat it as an opaque number to compare, never as the turn count. turn is still turn.
  • 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 slots8per player, per game
Save size64 KiBper slot, JSON, bytes
Score meta2048 bytesper submission
Match state32 KiBthe whole board, every move
Match move8 KiBone move payload
Match seats2–8and one account per seat
Match life7 daysbumped by every move
Achievements100definitions per game
Leaderboard read100entries per call
Handshake3sthen 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

  1. 01
    create_game

    Lands as a draft. Nothing is public yet.

  2. 02
    upload_game_build

    Your single-file HTML, with the script tag in it.

  3. 03
    define_achievements

    The badge set your build unlocks against. Idempotent — re-run it freely.

  4. 04
    set_arcade_settings

    Only if lower is better, or the number is not called "Score".

  5. 05
    add_screenshot

    Up to 6.

  6. 06
    set_cover

    The one the grid shows.

  7. 07
    publish_game

    Live.