Skip to main content

Realtime

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

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.

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:

FieldMeaning
order"desc": higher wins (points). "asc": lower wins (times).
min, maxThe 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:

GameBoard namesEntry to add
Puzzle moduleendless, levels, daily-YYYY-MM-DDAlready in the scaffold.
Shooter module<config.id>-kills"flag-rush-kills": { order: "desc", min: 0, max: 500 }
Kingdom moduleconfig.board, default kingdom-powerAlready in the scaffold (tighten max to the game's power). A renamed board needs its own entry.
Rooftop Hop, Lantern Tag, Pulse Arenathe game's name, run time in milliseconds"rooftop-hop": { order: "asc", min: 1_000, max: 3_600_000 }
Tap Poptap-pop"tap-pop": { order: "desc", min: 0, max: 10_000_000 }
Moon ChessglobalAlready in the scaffold.
Racing modulenot wired yetAdd 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:

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

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:

ConstantDefaultMeaning
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_HZ10Ticks per second in tick mode.
MAX_PLAYERS8Seats 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:

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

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.