Adding a hosted game to the bot

Who this is for

You are changing the bot itself to ship a new game that moderators can pick from the catalogue tile on the setup page. There are two kinds, and the first decision is which one you are building:

Both start with a catalogue entry. The design document (LEADERBOARD_DESIGN.md, §4.7 for the catalogue, §4.9 for preferences, §4.10 and §4.11 for bot-run games) records why things are the way they are; this page is the how.


The catalogue entry

Every hosted game is one object in LEADERBOARD_GAME_CATALOG in src/constants/leaderboard-game-catalog.ts. The entry describes the game; it does not contain it. Picking the tile on the setup page fills the wizard's defaults and mints or binds the server's key for that game, so the fields you set here are what moderators see.

Field What it decides
id The key's game id and catalogue id. Kebab-case, stable forever: boards and keys reference it.
name, blurb, thumbnail, docsUrl The tile. The thumbnail is /leaderboard-games/<id>/thumb.png and the file must exist.
runtime 'client' for a bundle, 'bot' for a game the bot plays out.
launchPath /leaderboard-games/<id>/ for a client game; '' for a bot game.
reports Whether the game sends points, an outcome, an opponent. A game that never reports an outcome cannot be put on an Elo board.
players, matchmaking solo, versus or both; and how two players meet: challenge (one names another, who accepts), party (a host invites, others accept), or none.
defaultMode, defaultOrder, defaultAggregate The wizard's prefill. A lowest-wins game (order: 'asc') must use aggregate: 'best'.
rankings Optional presets (decision 63). When present the wizard offers only these shapes and the server refuses any other; use it when only one or two board shapes make sense.
windowed The game plays one puzzle per server-defined window and sends a windowId with each result. Requires a windowMinutes preference.
suggestedCeiling, suggestedConfirmTimeoutSecs Prefills for the per-result ceiling and the Elo confirmation timeout.
eloOnly, hidesBoardControls, kFactorChoices Bot-run games only: force Elo, hide controls the game fixes (requireSession, eloConfirmBoth, showPlayerCount), show K as Steady / Normal / Volatile.
preferences The moderator's per-board game settings, below.
check A cross-field rule returning a refusal message or null, for what the per-field schema cannot say (Word Scavengers checks the word list can fill the grid).

tests/constants/leaderboard-game-catalog.test.ts enforces the contract for every entry: a client entry has its bundle and thumbnail and loads the helper, the defaults pass the entry's own validation, a windowed entry has windowMinutes, and an ascending default uses best. Run it after editing the catalogue.

Preferences

A preference is a form field on the setup page and a value the game reads at launch. Types are number (with min/max), boolean, select (with options), text (with maxLength; set allowEmpty when an emptied field is a real choice rather than a return to the default) and wordlist (one word per line, letters only, normalised to upper case, bounded by minWords, maxWords and wordLength). Set scoringRelevant: true on anything that changes what a result is worth: changing such a field on a board with results asks the moderator to confirm, is logged, and stamps "Rules changed" on the board.

Two things to design for. Preferences are public: the game fetches them, so anyone can read them from the network tab, and nothing the player must never see belongs in one. And a preference can change under a live board: players get the new value on their next launch, results already recorded stand, so a game should not assume the value it read last time.


A client game

1. Where the code lives

Two layouts are in use. A plain static bundle (Flappy Francine) is committed directly under public/leaderboard-games/<id>/ with its index.html, scripts and images. A built project (Word Scavengers) lives under games/<id>/ with its own package.json, and Vite writes the build into the bot's static folder:

// games/<id>/vite.config.js
export default defineConfig({
  plugins: [react()],
  base: './',
  build: { outDir: '../../public/leaderboard-games/<id>/', emptyOutDir: true },
});

For a built project, add it to the root games:build script in package.json and commit the built output: the bot serves it, the catalogue test checks it exists, and production never runs a build for it. The project has its own ESLint config; the root config ignores games/.

public/games/ is a different folder. It holds demo copies of games that are not wired to the leaderboard and is managed outside this repo. Never put a hosted game there.

2. Loading the helper

index.html loads the shared helper from the bot, not a copy:

<script src="/js/leaderboard-client.js"></script>

Then, once per page load (memoise it; React may run a mounting effect twice, and the helper strips the launch parameters on its first read):

const lb = LeaderboardClient.fromLaunchUrl();
const info = lb ? await lb.preferences() : null;   // { preferences, clock, board } or null

lb.canSubmit is false when the page was opened without a launch link. Keep the game playable in that case as a practice mode that scores nothing, with a banner saying to launch from Discord. lb.canRead is true when the link named a key and a board, so preferences work even without a session.

3. Reading preferences and the clock

info.preferences has every field the entry declares, with the moderator's value or the default. Validate on the game side anyway (unknown select values fall back, numbers are clamped), because a moderator may have changed a field after the bundle was built. info.clock.now is the server's time; lb.serverNow() keeps the correction after the call, so countdowns are right on a wrong-clocked machine.

For a windowed game, info.clock also carries windowId, seed, windowStartsAt and windowEndsAt. Seed the puzzle generator from seed (a random hex string the server issued when the window opened; hash it to whatever your generator wants), never from the window number or the clock, so a puzzle cannot be computed early. When the window ends, lb.preferences({ fresh: true }) fetches the next one.

4. Submitting

const results = await lb.submit({ points, initials, meta: { level } });

One call per finished game. The helper makes up an idempotencyKey per call and posts to every board in the link; if the game may call it again for the same finished game (a retry button, a reload), make the key yourself and pass it. A windowed game passes windowId and gets the key <board>:<windowId> for free, which makes a second solve of the same window a duplicate.

When the entry has ranking presets with different orders, send both numbers and let the helper pick per board:

await lb.submit({ points: { highest: 1, lowest: seconds }, windowId });

Each entry the helper returns is that board's answer plus ok. Show body.standing.rank and displayName on success, "already counted" on a 409 duplicate, and body.message otherwise. Word Scavengers' games/word-scavengers/src/leaderboard.js is a complete example of this layer, including practice mode and the per-board message mapping.

5. Finish the job


A bot-run game

A bot-run game never touches the HTTP API. The bot opens a public thread for each game, runs it with buttons and modals, and records the results through the service with a trusted source. Copy the shape of Word Bopper, which is split in two:

1. The host contract

interface BotGameHost {
  handles(key: LeaderboardGameKey): boolean;
  startSolo(intr, board, key): Promise<void>;    // Play on a solo board
  startParty(intr, board, key): Promise<void>;   // Play on a party board: the presser hosts
  startVersus(intr, match, board, key): Promise<void>;  // the challenged player pressed Accept
}

The play flow routes Play, /play and challenge acceptance to the host when service.isBotRun(key) and host.handles(key). The housekeeping sweep (src/jobs/leaderboard-sweep-job.ts) additionally expects flushInterrupted(client), onBotGameAbandoned(client, game), onAbandoned(client, match) and revealSolutions(client).

Today's wiring assumes one host. LeaderboardPlayFlow.useBotGames takes a single host and the sweep job's constructor is typed to WordBopperHost. A second bot-run game means either teaching WordBopperHost to dispatch by catalogue id or generalising both to a list of hosts keyed by handles. Do that first, as its own commit, before adding the game.

2. Starting a game

The pattern in every start method, in order:

  1. Refuse cheaply first. A closed window, a game already open, a frozen board: answer at once with an ephemeral reply and return.
  2. Defer. Opening a thread, posting the board and re-posting the live leaderboard beneath it take longer than Discord's three-second window for a first response. Call deferReply (ephemeral) before any of it; every later reply is a follow-up.
  3. Record the game through the service: openBotGame({ board, key, kind, hostId, windowId }) for solo or party, startBotMatch(matchId, channelId, messageId) for versus. These enforce the board's status and give the sweep something to time out.
  4. Open the thread in the board's channel (fall back to the channel when threads cannot be made, and say so in the log channel), post the board, ping the players, and store the thread and message ids with recordBotGameMessage or setMatchGameMessage. Everything the host later edits is found by those ids.
  5. Move the leaderboard below with the refresher's post, so the live message is again the last thing in the channel.
  6. Reply to the player with a pointer to the thread.

3. Moves

Give every game an id and route all of its interactions through BotGameRegistry.run(id, work), which serialises them per game so two presses at once apply one after the other. Carry the game id and the turn counter in component custom ids, in the bot's grammar vv_<name>_vid<gameId>_arg_<action>_arg2_<value> (the vid is alphanumeric; getVvId, getVvArg and getVvArg2 in src/utils/vv-utils.ts read it back). Use the turn to refuse only what must not apply twice (Give up); a guess is judged by the game's own rules, so the modal may open from an older message.

Three rules about messages, learned the hard way (decisions 73 to 75):

4. Recording results

All scoring goes through the service, which applies the board's rules, logs, and triggers the live-message render:

Catch LeaderboardError from these and show its message in the board's tail ("Not scored: this board is frozen"); the game state is what matters and the player should see why nothing counted.

Read the moderator's settings with service.preferencesFor(board, key) and, for a windowed game, service.currentWindow(board, key), which issues and stores the window's seed. Honour a gameTimeoutMinutes preference by declaring it; abandonStalledBotGames and abandonStalledMatches read it generically.

5. Housekeeping

Game state lives in memory for the life of a game, by design, so the host must cope with three things:

6. Discord wiring

Each kind of component is a small class that forwards to the host: a button in src/buttons/ (extend AuthorizedButton, deferType = NONE when any press opens a modal), a modal in src/modals/, a select menu in src/select-menus/. Register them in src/start-bot.ts alongside the Word Bopper ones, construct the host there, give it the refresher, and pass it to leaderboardPlayFlow.useBotGames and the sweep job. No slash command changes are needed: Play, /play, challenges and /mystanding already route to the host.

7. Tests

tests/games/word-bopper-host.test.ts has a FakeDiscord with channels that open threads, messages that can be edited and deleted, and interactions that record replies, updates, modals and deferrals. Reuse it. Cover: the thread and the board on start; each move's effect on the public message and the private one; refusals (gone game, stranger, stale press, cooldown); the end of the game and the standings it produced; and the housekeeping notes. Test the pure game classes separately and exhaustively, every refusal branch included. Then npx vitest run tests/games tests/services tests/jobs.

8. Finish the job


Checklist

For either kind:

Client game:

Bot-run game: