#Saving game data
Leaderboards and accounts are shared. When your game needs its own persistent data (collections, save files, progress), it gets its own DynamoDB table(s) and a small server module. Sightlines is the worked example throughout.
You write two things: a backend block in game.json, and a route module under
packages/api/src/games/. The arcade provisions the tables, routes /api/<slug>/*
to your module, and hands it a store scoped to your game.
#1. Declare tables in game.json
"backend": {
"module": "sightlines.mjs", // your module under packages/api/src/games/
"tables": [
{ "name": "players", "pitr": true }, // point-in-time recovery for precious data
{ "name": "config" },
{ "name": "matches", "ttl": true } // e.g. server-authoritative PvP state, auto-expires
]
}
Each table is provisioned as arcade-<slug>-<name>-<env> (e.g.
arcade-sightlines-players-prod). Options per table: range (add a sort key sk
for sorted queries), pitr (backups), ttl (auto-expire items by an expiresAt
epoch-seconds attribute). Tables appear in the SAM template via
infra/gen-game-tables.mjs (run automatically in CI); locally they're just JSON
files under .data/.
#2. Write the module
// packages/api/src/games/sightlines.mjs
import { ApiError, json } from "../http.mjs";
export function createSightlinesApi(store, { secrets }) {
const db = store.game("sightlines"); // scoped: only sees this game's tables
async function handle(req, subpath) {
// req.session is { accountId, email, name, picture } or null (set by the core).
if (!req.session) throw new ApiError(401, "not signed in");
const me = req.session.accountId; // = the player's email, your row key
if (req.method === "GET" && subpath === "state") {
const row = await db.get("players", { pk: me });
return json(200, { state: row ?? null });
}
if (req.method === "POST" && subpath === "save") {
const row = await db.mutate("players", { pk: me }, (p) => {
p = p ?? { pk: me, createdAt: new Date().toISOString() };
p.lastScore = req.body.score; // mutate in place
return p;
});
return json(200, { state: row });
}
throw new ApiError(404, `no such endpoint: ${req.method} /api/sightlines/${subpath}`);
}
return { handle };
}
Register it in packages/api/src/games/index.mjs:
import { createSightlinesApi } from "./sightlines.mjs";
export function createGames(store, deps) {
return { sightlines: createSightlinesApi(store, deps) };
}
That's it. GET /api/sightlines/state and POST /api/sightlines/save are live, and
your client calls them with credentials: "include" (same-origin in prod; the
sightlines vite dev server proxies /api, so the session cookie still works).
#The store API
store.game("<slug>") returns a handle over your tables. Every item is a plain
object with a string pk (and sk if the table has range: true); the
store manages a numeric version for you.
| Method | Use |
|---|---|
get(table, { pk, sk? }) |
one item, or null |
put(table, item, { ifNotExists?, ifVersion? }) |
write one item |
delete(table, { pk, sk? }) |
remove one item |
query(table, { pk, skPrefix?, skBetween?, limit?, forward? }) |
items in one partition, sorted by sk |
scan(table) |
every item; admin/low-frequency only |
mutate(table, key, fn) |
read → apply fn(item|null) → conditional write, retrying on conflict |
Use mutate for anything read-modify-write. It re-reads and re-applies under
optimistic concurrency, so two concurrent writes can't clobber each other. It's the
right tool whenever you change an existing row (Sightlines runs its whole match
verification + reward inside one mutate).
Key by accountId (the player's email) to tie data to the shared account, so the
player keeps their progress across devices and games.
#Server-authoritative multiplayer (optional)
The same module is also where trusted PvP rules live. Export realtimeMatch(ctx, data)
alongside handle, and the realtime relay routes a client's room.match(...) actions to it:
return { handle, realtimeMatch };
async function realtimeMatch(ctx, data) {
// ctx = { room, roomKey, from, fromConnId, members, sendTo, broadcast }
const match = await db.mutate("matches", { pk: ctx.roomKey }, (m) => apply(m, data));
ctx.broadcast({ type: "message", data: { t: "state", state: redactFor(match) } });
}
Authoritative match state is just another store.game(slug) table you declare (Sightlines uses
matches, ttl: true, keyed by ctx.roomKey). The WebSocket transport's own connection/room
registry is separate arcade infrastructure (arcade-ws-connections-<env>) that you don't declare
or touch. See multiplayer.
#Local vs production
Same code both places; only the backend swaps:
- Local: a JSON file per table under
.data/(sightlines-players.json), keyed bypk, hand-editable (grant items, reset state), and re-read on every request. - Production: DynamoDB (
arcade-<slug>-<name>-prod), via@aws-sdk/lib-dynamodb.
The Lambda's IAM role already grants CRUD on every arcade-* table, so adding a
table needs no permission change.
See also: accounts · leaderboards · architecture.