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:
- A client game is a browser bundle the bot serves from
public/leaderboard-games/<id>/. The bot hands players a launch link, the page talks to the bot through the shared helper script, and results arrive over the same API an external game uses. Flappy Francine and Word Scavengers are client games. - A bot-run game is played inside Discord: threads, buttons and modals, with the bot as referee recording results directly. Word Bopper (solo, party and versus) is the only one so far and is the model to copy.
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
thumb.pngunder the bundle folder, anddocsUrlon the entry if the game has a how-to-play page. - A paragraph under Hosted games in
docs/leaderboard_moderator_docs.md, thennpm run docs:build. - A decision in
LEADERBOARD_DESIGN.md§12 for anything that was a choice, so the next person knows it was one. npx vitest run tests/constants tests/publicfor the catalogue contract and the helper.
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:
- Pure game classes in
src/games/<game>/, with no Discord imports: the rules, the moves, the rendering to strings.word-bopper-solo-game.ts,word-bopper-party-game.tsandword-bopper-versus-game.tseach expose moves that return{ ok: true }or{ ok: false, error, retryAt? }, aturncounter, andrenderPublic/renderBoardForhelpers. These are cheap to test exhaustively. - A host (
word-bopper-host.ts) that implementsBotGameHostfromsrc/utils/leaderboard-play-flow.tsand owns everything Discord: threads, embeds, button ids, modals, the service calls.
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:
- Refuse cheaply first. A closed window, a game already open, a frozen board: answer at once with an ephemeral reply and return.
- 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. - 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. - 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
recordBotGameMessageorsetMatchGameMessage. Everything the host later edits is found by those ids. - Move the leaderboard below with the refresher's
post, so the live message is again the last thing in the channel. - 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):
- The public board is addressed by its stored message id, never by "the message this press came from". A modal opened from an ephemeral message reports that ephemeral message, and
intr.update()would paint the board into something nobody can edit again. Edit or re-post by id and answer the interaction separately, withdeferUpdatewhen it came from the board itself and a one-line ephemeral acknowledgement otherwise. - After a move in a shared game, re-post the board rather than editing it, so chat in the thread never scrolls it away: post the new one, record its id, then delete the old one; retry the post a few times and fall back to an edit in place, so a game is never left without a board.
- A solo player's private board is an ephemeral message and may be replaced in place with
update(); its public counterpart (squares without letters) is edited by id.
4. Recording results
All scoring goes through the service, which applies the board's rules, logs, and triggers the live-message render:
- A solo result is an ordinary submission with the trusted bot source:
service.submit(boardId, { playerId, points, outcome, windowId, idempotencyKey, meta }, { kind: 'bot', key }). It is attributed to the game's key and still obeys the window and duplicate rules, so one game per player per window falls out of the idempotency key. - A party ends with
finishBotGame(gameId, winnerId)thenscoreBotParty(gameId, key, winnerId, points): on a points board the winner scores and everyone else records a loss; on an Elo board the winner beats each loser in a separate match. Both are idempotent per party. - A versus match ends with
applyBotMatch(matchId, key, outcomeA, { pointsA, pointsB, meta }), which records both players' results and moves the ratings with no confirmation step.
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:
- Restart.
abandonInterrupted()at startup marks every game that was in flight as abandoned;flushInterrupted(client)later edits each game message with a note once the client can reach Discord. Nothing is silently lost and nothing is half-scored. - Timeouts. The sweep calls
onBotGameAbandonedandonAbandonedfor games and matches past their limit; drop the registry entry and append a note to the board. - Solution posts for windowed games, if the game offers them:
revealableWindowslists closed windows on boards that opted in,markWindowRevealedrecords the post.
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
- A paragraph under Bot-run games in
docs/leaderboard_moderator_docs.md, thennpm run docs:build. - The design: a section under §4.11 for how the game plays, and numbered decisions in §12 for every choice.
- Deployment notes in the commit message if anything needs a permission (threads need Create Public Threads and Send Messages in Threads) or a command registration.
Checklist
For either kind:
- Catalogue entry with a stable
id, a thumbnail that exists, defaults that pass validation. -
scoringRelevantset truthfully on every preference. -
tests/constants/leaderboard-game-catalog.test.tsgreen. - Moderator docs paragraph, docs rebuilt, design decisions recorded.
Client game:
- Bundle under
public/leaderboard-games/<id>/, built output committed,games:buildupdated for a Vite project. - Loads
/js/leaderboard-client.js; onefromLaunchUrlper page; practice mode without a launch link. - One idempotency key per finished game;
windowIdon every result of a windowed game;{ highest, lowest }when presets differ in order.
Bot-run game:
- Pure game classes with no Discord imports, tested exhaustively.
- Host implements
BotGameHostplus the sweep's four hooks; the single-host wiring generalised first. - Defer before opening a thread; every board edit by stored id; re-post after shared moves.
- Results only through
submitwith the bot source,scoreBotPartyorapplyBotMatch. - Restart, timeout and solution-post paths covered by tests.