Robokade

Game data (saves and scores)

Game data gives an Arena or Arcade game a small SQLite database on our server: players' saves, stats that carry from one match to the next, a shared top table. Your code that touches the database lives in a private data/ folder that only runs on our server. Only signed-in players have server data; everyone else plays as before.

For AI agents: this page as plain text at /docs/data.md; all docs are listed in /llms.txt.

Examples: house Connect Four (wins, losses and streak per bot level, shown at the start of each match) and house Online Test (a saved disc colour and a shared top 10 of wins).

What it is for

  • Arcade games: saves, settings, unlocks, a player's progress, shared tables (scores, a guild board). The game page calls ops you declare.
  • Arena games (turn-based, rules on our server): a snapshot at match start (your record, your unlocked deck, your streak) and an update at match end from the server's own result. Arena games have no page ops in this version.
  • Not for: real money, trading anything of real value, paid random rewards (never); chat or free text beyond short names; identifying players; anything a world game does better (one shared live world: see World games).

The files

FileWhatPublic?
data in game.json / arcade.jsonthe database and (Arcade) the ops the page may callyes
data/data.jsyour data code: op (Arcade) or matchStart / matchEnd (Arena), optionally check, forget, upgradeno: runs on our server only
data/schema.sqltables and indexes (CREATE TABLE ... STRICT, CREATE INDEX)no
data/seed.sqloptional INSERTs, run once when the database is createdno
data/migrations/<fromVersion>.sqlhow a database of an older version becomes this version's schemano
data/*.jshelpers data.js imports (only from data/ and the kit's core/)no

Nothing public may import data/ (the check fails if core.js, view.js, bot.js or an Arcade page does). .sql files are allowed only in data/. World games can't have data/ (they have their own database).

The data field

"data": {
  "db": true,
  "ops": {
    "load":  { "mode": "read" },
    "save":  { "mode": "write", "args": { "slot": { "type": "int", "min": 0, "max": 2 }, "state": { "type": "json", "max": 4096 } } },
    "score": { "mode": "write", "shared": true, "args": { "points": { "type": "int", "min": 0, "max": 1000000 } } },
    "top":   { "mode": "read", "shared": true }
  },
  "textFields": ["heroName"]
}
  • ops (Arcade only, 1 to 24): names [a-z][a-z0-9_], up to 24 characters. mode is read or write. shared: true when the op reads or writes what other players see (a top table): the game page then says these scores are sent by players' browsers and not checked by Robokade, and they never go on leaderboards.
  • args (up to 12 per op): int and number (min, max), bool, enum (values, up to 64), id (A-Z a-z 0-9 _ -, up to 24), text (only names listed in textFields), json (up to 4096 characters, depth 4, 64 keys per object, 256 items).
  • Every string that isn't an enum value (an id, a text, any string inside json) must pass the display-name rule: at most 24 plain letters, digits, spaces and . _ -, no links, rude or reserved words. A whole call is at most 6000 characters. Our server checks every arg again.
  • textFields (up to 8): args that are free names (a hero's name). They are deleted with the player.
  • Arena only: snapshotBytes (256 to 8192 characters per seat, default 2048), rooms (true: matches in rooms with friends get matchEnd too; default false), affectsPlay (true when the snapshot changes how the game plays, for example an unlocked card: such matches stay off the leaderboards).

data.js

// Arcade
export function op(db, pid, name, args, ctx) {
  if (name === "load") { const r = db.get("SELECT state FROM save WHERE pid = ? AND slot = ?", pid, args.slot ?? 0); return r ? JSON.parse(r.state) : null; }
  if (name === "save") { db.run("INSERT INTO save (pid, slot, state) VALUES (?, ?, ?) ON CONFLICT(pid, slot) DO UPDATE SET state = excluded.state", pid, args.slot, JSON.stringify(args.state)); return true; }
  if (name === "top") return db.all("SELECT pid, max(points) AS points FROM score GROUP BY pid ORDER BY points DESC LIMIT 10").map((r) => ({ who: mc.player(r.pid), points: r.points }));
  return null;
}
// Arena
export function matchStart(db, seats, ctx) { /* return { p0: { wins: 3 } | null, p1: ... } */ }
export function matchEnd(db, end, ctx) { /* add the result of end.result for each end.seats[i].pid */ }
// both
export function check(db) { return ""; }                 // "" or a problem: a problem cancels the write
export function forget(db, pid) { db.run("DELETE FROM save WHERE pid = ?", pid); }
  • db.get(sql, ...params), db.all(sql, ...params), db.run(sql, ...params): synchronous, values always as ? parameters. The same SQL rules as world games: What SQL may do.
  • pid is the player's id in your game: the same each visit, different in every game, and it never tells you who the player is. Store it as it is (never encoded or hashed).
  • ctx: now (milliseconds, to the second), rng (a seeded generator for the kit's rng helpers). Arcade ops also get ctx.agentReported (the page says a player's agent is playing) and ctx.room ("solo", "host" or "guest").
  • Showing players: never return a pid. Return mc.player(pid) instead: the page gets { you: true } for the player who asked, { name } only for adults who switched on "Show my name in game tables", else { name: "A player" }. Players aged 13-17 are never named. A raw pid in a reply fails the call (PID_LEAK).
  • mc.textOk(s): true when text your code builds passes the display-name rule.
  • One transaction per call; check() runs after every write, inside it. An error, a cap or a problem from check() keeps nothing of that call. No memory between calls, no async, no clock, no randomness except ctx.rng, no network.

Arena: matchStart and matchEnd

  • matchStart(db, seats, ctx): seats is [{ seat, pid, kind, team?, level? }]. pid is null for bots, guests and players who are not signed in. Return an object with a value (or null) per seat id. It goes into your core.js as setup({ options }) → options.data. setup() must work without it (signed-out players, a busy server, replays of old records). Each seat's value: at most snapshotBytes characters, depth 4, 64 keys per object, 32 KB in all, no pid inside.
  • Keep the snapshot out of anything that changes per match (no timestamps, no counters): replays and the public view must not show them.
  • matchEnd(db, end, ctx): once per finished match, from our server's own signed result, possibly a little later (when the game's database is idle). end = { match, version, room, seats: [{ seat, pid, kind, team?, level?, whole }], result, views, moves, botFallbacks }. match is an opaque key for this match in your game (not the match id players see in history): use it only to tell matches apart. result is your result() at the end; views holds each seat's final observation and the spectator's; whole is false when the seat changed hands during the match. A seat gets pid: null (no credit) when it changed hands, when one account played more than one side, or when the player deleted their data; one account on two seats of the same side is credited on its first seat only. When every seat is null, matchEnd is not called.
  • Your game becomes protected (rules on our server) from its first version with data.

Arcade: the SDK

const { mc } = await import("/__mc/v1/sdk.js");
const d = await mc.data.ready();           // { signedIn: true, version } or { signedIn: false } (within 2 s)
const r = await mc.data.call("save", { slot: 0, state: { level: 3 } });   // never rejects
// r: { ok: true, value } or { ok: false, error }

Errors: NOT_SIGNED_IN, OFF (game data is switched off), HELD, BUSY (try again soon), RATE_LIMITED, BAD_ARGS, FAILED (your code failed), RELOAD (a new version is live: reload the page), OFFLINE, TIMEOUT (10 s), TOO_LARGE (over 6000 characters, not sent), TOO_MANY (more than 4 calls waiting). Keep a local save for signed-out players and when calls fail: the game must stay playable without game data. In a room every page calls for its own player only, guests included. Agents never get data calls.

Caps

op readop writematchStartmatchEnd
CPU50 ms50 ms100 ms200 ms
One statement / whole call250 ms / 1 s250 ms / 1 s250 ms / 1 s500 ms / 2 s
Statements50100100500
Rows returned / changed1000 / none1000 / 2001000 / none1000 / 2000
Reply64 KB64 KBsnapshotBytes per seatnone

check, forget and upgrade have the world caps. Per player and game: 4 reads a second (bursts of 10), 1 write a second (bursts of 5), 3000 calls and 1 MB of written args a day. Database size: 100 MB to start.

Players' data

  • Players can delete their data in your game (the Arcade page's menu, or their account page) and their account. Both call your forget(db, pid), then Robokade checks every table: cells equal to the player's names go blank and every copy of their pid is replaced by an anonymous id. So keep pids as they are and don't copy names elsewhere. After a delete the player starts fresh.
  • You can download a copy of your game's data: account page → Games → Download game data (pids only, never accounts: don't try to find out who players are, don't share it, delete old copies).

New versions

A version with the same data/ goes live as usual. A version with a changed data/ or data field is tested on a copy of the live database (your migration, upgrade(db, fromVersion, ctx) in batches returning { more: true } to be called again, then check()) and switched by the reviewer; old Arcade pages get RELOAD. We can roll back for 7 days (data written since the switch is lost then). schema.sql always describes the full current schema.

Test it in the kit

You need Node.js 22.5 or newer (the kit uses Node's built-in SQLite with our rules).

node tools/mc-local/mc.mjs data <arcade-folder> --check      # checks, then every op for 3 players, forget, no raw ids
node tools/mc-local/mc.mjs data <arcade-folder>              # open the printed address: your game with game data
node tools/mc-local/mc.mjs db query "SELECT * FROM save" --port 18098
node tools/mc-local/mc.mjs online-test <arcade-folder>       # online play; host and guests save too
node public/kit/test/cli.js public/games/<id>                # Arena: snapshot, bot-vs-bot, matchEnd twice, replay
node tools/mc-local/protected.mjs                            # Arena: play it with matchStart and matchEnd on a local db
  • mc data frames your game from another origin with Robokade's bridge and limits. Switch between players: signed out, A (an adult), B (an adult who shows the name "Bea"), T (13-17, never named); set game data off, busy or to a new version; try Delete my data in this game. The terminal prints every call and its result.
  • cli.js (Arena) plays a match with your matchStart snapshot in setup(), checks that the replay with it gives the same final hash, sends matchEnd twice (the second is skipped: a match counts once) and forgets a player.
  • The local database is ~/.cache/robokade/data/<id>.sqlite; delete it to start over.

Submitting works as for other games: Submit and review. The reviewer reads data/ too.