#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 by pk, 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.

View docs/guides/game-databases.md on GitHub ↗