Skip to main content

Game backend

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:
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​

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:

BindingKindWhat it holds
ROOMSDurable Object, class GameRoomOne object per room: who is in, states, events, shared room state.
PRESENCEDurable Object, class PresenceOne object (global) for the game: the live player count, quick-match rooms, the opponent's rate limits.
LEADERBOARDDurable Object, class LeaderboardOne object per board: the live top list in front of D1.
SAVESDurable Object, class PlayerSavesOne object per player: their save slots in SQLite.
DBD1 databaseTables scores and best.
AIWorkers AIThe 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​

RouteWhat
GET /health{ ok, protocol, time }
GET /rooms/:roomIdWebSocket: join a room (?player=<secret id>&name=&spectate=1). A plain GET returns { players, spectators, maxPlayers, open }.
POST /match?size=4Quick match: { roomId, seat, size }, the same code for everyone asking within 45 seconds until the room is full.
GET /presenceWebSocket: { 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 /savesThe player's slot headers (x-player-id).
GET /saves/:slot, PUT /saves/:slotOne 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/turnThe 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 pieceCallsOn the backendPage
useLeaderboard, submitScore, fetchGhost/leaderboard, /leaderboard/live, /scores, /ghostLeaderboard per board, D1Realtime
usePresence/presencePresenceRealtime
useRoom, useTurnRoom, quick match/rooms/:roomId, /matchGameRoom per roomRealtime
defineSave, defineProgression, module progress/saves, /saves/:slot, /saves/livePlayerSaves per playerSaves sync
gameNow()the time in save responsesthe server's clockSaves sync
useOpponent, requestTurn/ai/turnWorkers AI, limits in PresenceComputer opponent
getPlayerId()x-player-id header, ?player= on socketshashed before use: publicId (what others see), saveOwner (names the player's saves)Net

The client API for each piece is on its engine page: Net, Saves, Turns.

The device keeps a copy​

PieceWithout a backend, or offline
LeaderboardThis 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.
Presencestatus: 'local'; hide the "playing now" chip.
RoomsA room with only this player: play against bots or pass-and-play.
SavesKept on the device (MMKV), same API. They start syncing once a backend exists.
Computer opponentThe 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:

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:

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:

npx wrangler d1 create my-game--db

Then fill in both placeholders (macOS and Linux):

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). 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):

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:

npm run db:local
npm run dev
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 has the options.

7. Deploy:

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:

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.
  • 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.