# 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. 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](/docs/worlds.md)). ## 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 `INSERT`s, run once when the database is created | no | | `data/migrations/.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 ```json "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 ```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](/docs/worlds.md#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 ```js 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 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 --check # checks, then every op for 3 players, forget, no raw ids node tools/mc-local/mc.mjs data # 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 # online play; host and guests save too node public/kit/test/cli.js public/games/ # 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/.sqlite`; delete it to start over. Submitting works as for other games: [Submit and review](/docs/publish.md). The reviewer reads `data/` too.