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
| File | What | Public? |
|---|---|---|
data in game.json / arcade.json | the database and (Arcade) the ops the page may call | yes |
data/data.js | your data code: op (Arcade) or matchStart / matchEnd (Arena), optionally check, forget, upgrade | no: runs on our server only |
data/schema.sql | tables and indexes (CREATE TABLE ... STRICT, CREATE INDEX) | no |
data/seed.sql | optional INSERTs, run once when the database is created | no |
data/migrations/<fromVersion>.sql | how a database of an older version becomes this version's schema | no |
data/*.js | helpers 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.modeisreadorwrite.shared: truewhen 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):intandnumber(min,max),bool,enum(values, up to 64),id(A-Z a-z 0-9 _ -, up to 24),text(only names listed intextFields),json(up to 4096 characters, depth 4, 64 keys per object, 256 items).- Every string that isn't an enum value (an
id, atext, any string insidejson) 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 getmatchEndtoo; 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.pidis 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'srnghelpers). Arcade ops also getctx.agentReported(the page says a player's agent is playing) andctx.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 fromcheck()keeps nothing of that call. No memory between calls, noasync, no clock, no randomness exceptctx.rng, no network.
Arena: matchStart and matchEnd
matchStart(db, seats, ctx):seatsis[{ seat, pid, kind, team?, level? }].pidisnullfor bots, guests and players who are not signed in. Return an object with a value (ornull) per seat id. It goes into yourcore.jsassetup({ options })→options.data.setup()must work without it (signed-out players, a busy server, replays of old records). Each seat's value: at mostsnapshotBytescharacters, 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 }.matchis an opaque key for this match in your game (not the match id players see in history): use it only to tell matches apart.resultis yourresult()at the end;viewsholds each seat's final observation and the spectator's;wholeis false when the seat changed hands during the match. A seat getspid: 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 isnull,matchEndis 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 read | op write | matchStart | matchEnd | |
|---|---|---|---|---|
| CPU | 50 ms | 50 ms | 100 ms | 200 ms |
| One statement / whole call | 250 ms / 1 s | 250 ms / 1 s | 250 ms / 1 s | 500 ms / 2 s |
| Statements | 50 | 100 | 100 | 500 |
| Rows returned / changed | 1000 / none | 1000 / 200 | 1000 / none | 1000 / 2000 |
| Reply | 64 KB | 64 KB | snapshotBytes per seat | none |
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 theirpidis 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 dataframes 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 yourmatchStartsnapshot insetup(), checks that the replay with it gives the same final hash, sendsmatchEndtwice (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.