# Game backend

> Each game's shared and lasting data lives on its own Cloudflare Worker, made from the template's backend scaffold (Durable Objects, D1, Workers AI), while the device keeps an offline copy; what the scaffold contains, which engine piece uses which route, and how to deploy it to your own Cloudflare account with Wrangler.

A Moonspell game keeps anything that must outlive the device, be shared between players, be fair or be live on its own Cloudflare Worker: leaderboards, "N playing now", multiplayer rooms, synced saves and the computer opponent's model. The device keeps a copy of everything, so the game opens instantly and plays offline, and the engine syncs it when it can.

The Worker comes from a scaffold in the template, `templates/crescent-game/.claude/skills/realtime-backend/scaffold/backend/`. The game talks to it through `src/engine/net` and `src/engine/save`. With no backend (`GAME_BACKEND_URL = null`) the same engine calls work on the device alone, so you can build the whole game first and add the backend later.

## Turn it on

1. Copy the scaffold into the game repo as `backend/` and deploy it (steps below).
2. Set the Worker's URL in `src/engine/net/config.ts`:

```ts
export const GAME_BACKEND_URL: string | null = 'https://my-game--api.<your-subdomain>.workers.dev';
```

Leaderboards, presence, rooms, save sync and the opponent's lines turn on by themselves. Nothing else in the game changes.

## What the scaffold contains

```text
backend/
  CLAUDE.md                 where the game's data lives: Cloudflare pieces, rules, recipes
  wrangler.jsonc            the Worker, 4 Durable Object classes, D1 database DB, Workers AI as AI
  package.json              scripts: dev, deploy, types, typecheck, db:local, db:remote, smoke
  src/index.ts              the routes (Hono)
  src/room.ts               GameRoom: one per room code
  src/presence.ts           Presence: one per game ("N playing now", quick match, /ai/turn limits)
  src/leaderboard.ts        Leaderboard: one per board, BOARDS config, D1 reads and writes
  src/saves.ts              PlayerSaves: one per player, save slots in its own SQLite
  src/ai.ts                 the computer opponent on Workers AI
  src/protocol.ts           message types, identical to src/engine/net/protocol.ts
  src/save-protocol.ts      save sync types, identical to src/engine/net/save-protocol.ts
  src/util.ts               ids, hashing, name cleaning, socket helpers
  migrations/0001_init.sql  D1 tables: scores (every score) and best (each player's best per board, with its ghost)
  test/smoke.mjs            HTTP and WebSocket checks, locally or --read-only against a deployed Worker
```

### Bindings

From `wrangler.jsonc`:

| Binding | Kind | What it holds |
| --- | --- | --- |
| `ROOMS` | Durable Object, class `GameRoom` | One object per room: who is in, states, events, shared room state. |
| `PRESENCE` | Durable Object, class `Presence` | One object (`global`) for the game: the live player count, quick-match rooms, the opponent's rate limits. |
| `LEADERBOARD` | Durable Object, class `Leaderboard` | One object per board: the live top list in front of D1. |
| `SAVES` | Durable Object, class `PlayerSaves` | One object per player: their save slots in SQLite. |
| `DB` | D1 database | Tables `scores` and `best`. |
| `AI` | Workers AI | The models behind `POST /ai/turn`. |

The migrations list two tags: `v1` creates `GameRoom`, `Presence` and `Leaderboard`; `v2` creates `PlayerSaves`. A new class always gets the next tag. Never rename or delete a deployed class: `deleted_classes` destroys its data.

### Routes

| Route | What |
| --- | --- |
| `GET /health` | `{ ok, protocol, time }` |
| `GET /rooms/:roomId` | WebSocket: join a room (`?player=<secret id>&name=&spectate=1`). A plain GET returns `{ players, spectators, maxPlayers, open }`. |
| `POST /match?size=4` | Quick match: `{ roomId, seat, size }`, the same code for everyone asking within 45 seconds until the room is full. |
| `GET /presence` | WebSocket: `{ t: "presence", count }` on every change. A plain GET returns `{ count }`. |
| `GET /leaderboard?board=&limit=` | `{ board, order, entries, you }`; `you` when the `x-player-id` header is sent. |
| `GET /leaderboard/live?board=` | WebSocket: `{ t: "top", entries }` now and on every change. |
| `POST /scores` | `{ board?, playerId, name, score, meta?, ghost? }` returns `{ ok, improved, best }`. |
| `GET /ghost?board=&player=` | A player's best run's replay; without `player`, the board's first place. |
| `GET /saves` | The player's slot headers (`x-player-id`). |
| `GET /saves/:slot`, `PUT /saves/:slot` | One slot; a write from the revision the copy last saw, or a 409 with the server's copy. |
| `GET /saves/live?player=` | WebSocket: `hello`, then every write to the player's saves. |
| `POST /ai/turn` | The computer opponent's turn: `{ move, say, model, ms }`. |

Every socket uses the WebSocket Hibernation API, so an idle room or presence socket costs nothing while it stays open.

## Which engine piece uses what

| Engine piece | Calls | On the backend | Page |
| --- | --- | --- | --- |
| `useLeaderboard`, `submitScore`, `fetchGhost` | `/leaderboard`, `/leaderboard/live`, `/scores`, `/ghost` | `Leaderboard` per board, D1 | [Realtime](https://docs.moonspell.app/backend/realtime.md) |
| `usePresence` | `/presence` | `Presence` | [Realtime](https://docs.moonspell.app/backend/realtime.md) |
| `useRoom`, `useTurnRoom`, quick match | `/rooms/:roomId`, `/match` | `GameRoom` per room | [Realtime](https://docs.moonspell.app/backend/realtime.md) |
| `defineSave`, `defineProgression`, module progress | `/saves`, `/saves/:slot`, `/saves/live` | `PlayerSaves` per player | [Saves sync](https://docs.moonspell.app/backend/saves-sync.md) |
| `gameNow()` | the `time` in save responses | the server's clock | [Saves sync](https://docs.moonspell.app/backend/saves-sync.md) |
| `useOpponent`, `requestTurn` | `/ai/turn` | Workers AI, limits in `Presence` | [Computer opponent](https://docs.moonspell.app/backend/ai-opponent.md) |
| `getPlayerId()` | `x-player-id` header, `?player=` on sockets | hashed before use: `publicId` (what others see), `saveOwner` (names the player's saves) | [Net](https://docs.moonspell.app/engine/net.md) |

The client API for each piece is on its engine page: [Net](https://docs.moonspell.app/engine/net.md), [Saves](https://docs.moonspell.app/engine/saves.md), [Turns](https://docs.moonspell.app/engine/turns.md).

## The device keeps a copy

| Piece | Without a backend, or offline |
| --- | --- |
| Leaderboard | This device's own runs, same API. A score that could not be sent waits on the device and goes out with the next leaderboard call. |
| Presence | `status: 'local'`; hide the "playing now" chip. |
| Rooms | A room with only this player: play against bots or pass-and-play. |
| Saves | Kept on the device (MMKV), same API. They start syncing once a backend exists. |
| Computer opponent | The search plays its own move, with no lines. |

## Deploy the scaffold to your own Cloudflare account

Inside the Moonspell app, the hosted pipeline deploys each game's backend through a private gateway. Outside it you deploy to your own account with Wrangler. These steps follow the scaffold's files and Wrangler's documentation; they were not run end to end for this page, so treat them as untested and check each output.

You need Node.js 22 or later (the smoke test uses Node's built-in `fetch` and `WebSocket`) and a Cloudflare account. Durable Objects with SQLite storage are what the scaffold uses.

**1. Copy the scaffold** from the game repo's root:

```bash
cp -R .claude/skills/realtime-backend/scaffold/backend backend
cd backend
npm ci
```

`npm ci` installs Hono, Wrangler 4.155.0 and TypeScript from the scaffold's lockfile.

**2. Log in to Cloudflare:**

```bash
npx wrangler login
```

**3. Name it and create the database.** `wrangler.jsonc` has two placeholders. `__PREFIX__` starts every name: the Worker is `<prefix>api` and the database `<prefix>db`. On your own account the prefix only needs to be unique among your Workers; the scaffold's comment uses the form `sunset-sprint--`. Create the database with that name and note the `database_id` Wrangler prints:

```bash
npx wrangler d1 create my-game--db
```

Then fill in both placeholders (macOS and Linux):

```bash
sed -i.bak "s/__PREFIX__/my-game--/g; s/__D1_DATABASE_ID__/<database_id>/" wrangler.jsonc && rm wrangler.jsonc.bak
```

**4. Shape it to the game.** Put every leaderboard name the game uses in `BOARDS` in `src/leaderboard.ts` ([Realtime](https://docs.moonspell.app/backend/realtime.md#leaderboards-and-the-boards-rule)). Room settings (`MODE`, `TICK_HZ`, `MAX_PLAYERS`) are at the top of `src/room.ts`.

**5. Typecheck** (this also runs `wrangler types`, which writes `worker-configuration.d.ts`):

```bash
npm run typecheck
```

**6. Test locally.** Apply the migration to the local database, start the dev server in one terminal, and run the smoke test in another:

```bash
npm run db:local
npm run dev
```

```bash
node test/smoke.mjs
```

`npm run dev` is `wrangler dev --local`, which serves `http://127.0.0.1:8787` and keeps every binding local. Workers AI has no local simulator, so locally `/ai/turn` answers 503 and the smoke test skips it. [Computer opponent](https://docs.moonspell.app/backend/ai-opponent.md#local-development) has the options.

**7. Deploy:**

```bash
npm run db:remote
npm run deploy
```

`db:remote` applies `migrations/0001_init.sql` to the D1 database on Cloudflare; `deploy` uploads the Worker and creates the Durable Object classes from the migration tags. Wrangler prints the Worker's `workers.dev` URL (`workers_dev` is on in `wrangler.jsonc`).

**8. Check it live**, without writing scores:

```bash
curl https://my-game--api.<your-subdomain>.workers.dev/health
node test/smoke.mjs https://my-game--api.<your-subdomain>.workers.dev --read-only
```

**9. Point the game at it**: set `GAME_BACKEND_URL` in `src/engine/net/config.ts` to that URL. Run `npm run typecheck` and `npm run export:web` in the game afterwards. `backend/` is excluded from the game's TypeScript project, and the template's `.gitignore` already ignores `backend/node_modules/`, `.wrangler/` and `.dev.vars*`. Commit `backend/` with the game.

Later changes are the same loop: change, `npm run typecheck`, test locally, deploy, check live.

## Rules the scaffold keeps

From `backend/CLAUDE.md`:

- **The server decides what has to be fair**: match results, trades, rewards with real value, daily rewards on the server's clock. The device's copy is trusted for single-player progress.
- **Identity** is the device's secret player id. It is hashed before it names anything; the secret is never stored or shown.
- **One Durable Object per natural unit** (a player, a room, a board, a world), each with small data.
- **Wire files are pairs**: `src/protocol.ts` and `src/save-protocol.ts` stay byte-identical to their copies in `src/engine/net/`. Check with `cmp backend/src/protocol.ts src/engine/net/protocol.ts`.
- **Secrets and prompts stay in the Worker** (`wrangler secret put`), never in the game's bundle.
- **Every route caps size and rate** and validates what it stores.

`backend/CLAUDE.md` also maps other Cloudflare pieces to kinds of data (KV for config, R2 for files, Queues, cron triggers, Vectorize, Workflows) and has recipes for a new Durable Object class and an AI character route.

## Gotchas

- A board name missing from `BOARDS` answers 404, and those scores stay on the device. See [Realtime](https://docs.moonspell.app/backend/realtime.md).
- Never point a shipped build at `http://127.0.0.1:8787`. Use the deployed URL or `null` before `npm run export:web`.
- Workers AI usage is billed to your account, including calls made during local development through remote bindings.
