# Realtime

> The backend side of leaderboards (and the rule that every board name must be in BOARDS), "N playing now" presence, and multiplayer rooms players join from a link, as the scaffold's Durable Objects implement them.

Three things make a game feel inhabited: a leaderboard of other players' scores, a count of who is playing now, and rooms where friends play together. Each is a Durable Object class in the [backend scaffold](https://docs.moonspell.app/backend/overview.md), reached through `src/engine/net`. This page covers what the backend does and what you configure there. The client hooks (`useLeaderboard`, `usePresence`, `useRoom`, `useLaunchRoomId`) are on [Net](https://docs.moonspell.app/engine/net.md).

## Leaderboards and the BOARDS rule

Every score goes to `POST /scores` with a board name. The `Leaderboard` Durable Object for that board (one per board) writes it to D1, keeps each player's best in the `best` table and every submission in `scores`, and pushes the new top list to anyone watching `GET /leaderboard/live`.

**Every board name the game uses must be in `BOARDS`** in `backend/src/leaderboard.ts`, either exactly or as a family. A board the backend does not know answers 404, and its scores never leave the device.

```ts
export const BOARDS: Record<string, BoardConfig> = {
  global: { order: "desc", min: 0, max: 1_000_000_000 },
  // The puzzle module (definePuzzleGame): endless scores, the level reached, one board per day.
  // Keep them for a puzzle game; delete them otherwise.
  endless: { order: "desc", min: 0, max: 100_000_000 },
  levels: { order: "desc", min: 1, max: 10_000 },
  "daily-*": { order: "desc", min: 0, max: 100_000_000 },
  // The kingdom module (defineKingdomGame): kingdom power, highest first (its `board` option renames it).
  // Keep it for a kingdom game; delete it otherwise.
  "kingdom-power": { order: "desc", min: 0, max: 10_000_000 },
};
```

That is the scaffold as shipped. Each entry has:

| Field | Meaning |
| --- | --- |
| `order` | `"desc"`: higher wins (points). `"asc"`: lower wins (times). |
| `min`, `max` | The score range a real run can reach. A score outside it is refused with a 400. Tighten it: it is the cheapest anti-cheat there is. Times arrive in milliseconds (the examples send `Math.round(elapsed * 1000)`), so a time board's range is in milliseconds too. |

A key ending in `*` is a **family**: `"daily-*"` covers `daily-2026-10-31` and every other day, so boards with a date, a track or a level in the name need one entry. When several families match, the longest prefix wins. Board names are 1 to 32 lowercase letters, digits or `-`.

The board name comes from the game's `useLeaderboard(url, { board })` call; without `board` it is `global`. Names the engine's modules and examples use:

| Game | Board names | Entry to add |
| --- | --- | --- |
| Puzzle module | `endless`, `levels`, `daily-YYYY-MM-DD` | Already in the scaffold. |
| Shooter module | `<config.id>-kills` | `"flag-rush-kills": { order: "desc", min: 0, max: 500 }` |
| Kingdom module | `config.board`, default `kingdom-power` | Already in the scaffold (tighten `max` to the game's power). A renamed board needs its own entry. |
| Rooftop Hop, Lantern Tag, Pulse Arena | the game's name, run time in milliseconds | `"rooftop-hop": { order: "asc", min: 1_000, max: 3_600_000 }` |
| Tap Pop | `tap-pop` | `"tap-pop": { order: "desc", min: 0, max: 10_000_000 }` |
| Moon Chess | `global` | Already in the scaffold. |
| Racing module | not wired yet | Add an `onFinish` callback in the game's copy of the racing module, then submit lap times in milliseconds to a family such as `"lap-*": { order: "asc", min: 5_000, max: 3_600_000 }`. |

To find every board a game uses:

```bash
grep -rn "useLeaderboard" src/game src/engine/modules
```

### What else the backend enforces

- One score per player per 2 seconds (429 otherwise).
- `meta`: at most 1 KB of JSON, shown with the entry (lap count, level).
- `ghost`: an opaque replay string of at most 64 KB, kept only when the score is the player's new best. `GET /ghost` returns a player's, or the board's first place. A ghost is usually `[t, x, z, heading]` samples at 10 Hz.
- Names are cleaned to 20 characters. Players are stored by a hash of their secret id (`publicId`), never the id itself.

## Presence: "N playing now"

`GET /presence` is a WebSocket to one `Presence` object for the whole game. Every open copy of the game holds a hibernating socket there and receives `{ t: "presence", count }` when the count changes, at most once a second. A plain GET returns `{ count }`. There is nothing to configure. The object's comment says one is enough up to thousands of concurrent players.

The same object hands out quick-match rooms: `POST /match?size=4` returns `{ roomId, seat, size }`, the same code to everyone asking within 45 seconds until the room has `size` players (2 to 16). The game asks with `quickMatch(GAME_BACKEND_URL, { size })` ([Networking](https://docs.moonspell.app/engine/net.md)).

## Multiplayer rooms

`GET /rooms/:roomId` upgrades to a WebSocket on the `GameRoom` object for that code: one object per room, room codes of 1 to 32 letters, digits, `-` or `_` (upper-cased). The socket carries `?player=<secret id>&name=` and `&spectate=1` to watch without a seat. A plain GET returns `{ players, spectators, maxPlayers, open }`.

The tuning is at the top of `backend/src/room.ts`:

| Constant | Default | Meaning |
| --- | --- | --- |
| `MODE` | `"relay"` | `"relay"`: each state is forwarded the moment it arrives; each client owns its entity. `"tick"`: states are batched on an alarm every `1 / TICK_HZ` seconds, and `tick()` is the place for a server-authoritative simulation. |
| `TICK_HZ` | `10` | Ticks per second in tick mode. |
| `MAX_PLAYERS` | `8` | Seats per room; spectators do not count. The shooter module needs it at least as large as `seats`. |

Relay is right for racing, runners and party games. Use tick mode when the outcome must be fair or shared (turn order, who grabbed the item, hits). A ticking room never hibernates while players are in it.

Limits the room enforces:

- A client message is at most 4 KB (more closes the socket with code 4009); shared room state at most 8 KB.
- About 40 messages a second per socket; clients send state at 10 to 20 Hz (default 15).
- Close codes 4000 (the same player connected elsewhere) and 4001 (room full) stop the client's reconnects.
- A room deletes its stored state 30 minutes after the last player leaves.

The first message a client gets, `welcome`, is a full snapshot (players, last states, room state); it arrives again after every reconnect. `roomState` holds shared facts such as phase, round and scores, and the host writes it: the non-spectator who joined first. `useTurnRoom` (the turns kit) keeps its seats, moves and results in that room state, so a player who reconnects gets the whole game back.

New message types go in `backend/src/protocol.ts`, then the file is copied over `src/engine/net/protocol.ts`. The two must stay identical:

```bash
cmp backend/src/protocol.ts src/engine/net/protocol.ts
```

### Joining from a link

A room code travels in a link as `?room=CODE`. On the game side, `useLaunchRoomId()` reads the code from the link the game was opened with, and `roomInviteUrl(code)` builds a link to share.

- **Inside the Moonspell app** the shell passes the game its launch link and its public share link, so an invite opens the game straight into the friend's room.
- **Outside it** there is no share link by default, so `roomInviteUrl` returns null: show the room code large enough to read out, and let players type it. In a browser, the engine reads the share link from a `share` query parameter on the game's page, and `useLaunchRoomId` also checks the page's own URL for `room=`, so a link such as `https://your-site.example/?room=K7QXM` should open into that room. This browser path follows from the engine's code and has not been tested for this page.

The backend side is the same either way: the code names the `GameRoom` object.

## Testing

`node test/smoke.mjs` (against `wrangler dev --local`) checks health, presence, rooms, scores, the leaderboard and ghosts, and saves. Add a check for every message or route you add. After deploying, `node test/smoke.mjs <url> --read-only` checks the live Worker without writing scores. See [the deploy steps](https://docs.moonspell.app/backend/overview.md#deploy-the-scaffold-to-your-own-cloudflare-account).
