Building a Browser Game with Astro, Cloudflare Workers, and a Verifiable D1 Leaderboard
A browser game can calculate a score entirely on the player's device. A public leaderboard needs a different answer to the question: why should the server believe that score? That distinction became one of the most useful design decisions while building 195-0 Game, an independent implementation inspired by The Cabinet's drafting concept. The game asks you to draft five people, assign cabinet roles, and run a fictional campaign against 195 nations. It has Classic, Daily, and Multiverse modes. The interesting engineering work was connecting that small interaction loop to trustworthy rankings, shareable results, and a site that works without putting every page behind client-side rendering. Here are the decisions-and production mistakes-that shaped the implementation. 1. Separate static pages, interactive play, and server verification The project uses: - Astro 7 for pages and the shared site layout. - Preact for the interactive game UI. - Cloudflare Workers for server endpoints. - Cloudflare D1 for leaderboard storage. - Satori and resvg for generating PNG result cards. Most content pages are prerendered. The browser handles selection, role assignment, and animation. The server issues draft tokens, verifies completed runs, handles leaderboard submissions, and renders share images. The request flow is roughly: Prerendered page + Preact game | +-- POST /api/draft Get a signed draft token +-- POST /api/score Replay moves and compute the score +-- POST /api/leaderboard Publish a verified result +-- GET /api/leaderboard Read rankings +-- GET /r/ Render a shareable result page +-- GET /api/og?c=... Render its PNG card This makes the boundary explicit: the browser owns the interaction, while the server decides which score can be published. Completing a game and publishing it are also separate steps. Players can finish a run without putting a display name on a public board. 2. Submit moves, not a claimed score An early validation approach was to recompute a score from a cabinet sent by the browser. That checks whether the score matches the cabinet, but it does not establish that the player was ever offered those candidates. The current flow sends a signed draft token and an ordered action log instead. Each round records a structure like this: interface RoundAction { respins: ("category" | "country")[]; pick: number; role: Role; } The server verifies the token, reconstructs the draft from its seed, applies the moves, and computes the result itself. It rejects invalid candidate indexes, reused seats, incomplete logs, and actions that the mode does not allow. The order of respins matters. Replacing an ordered action list with a respin count can consume the random sequence differently and produce a different candidate pool. After verification, the scoring endpoint returns a signed result token. The publication endpoint reads the score from that token rather than trusting another client-supplied number. This is not complete anti-cheat. It validates a legal sequence of moves, not human participation. A bot could still search for strong legal choices, and browser-local player identifiers are not verified identities. Those limitations matter when describing what a leaderboard actually guarantees. 3. Determinism is more than using the same seed Daily mode uses a UTC date to select a shared challenge. Local midnight would give players different reset times depending on their time zones. But matching the seed is only part of reproducibility. The browser and server also need matching candidate data, ordering, and random-number consumption. This is why the replay path uses the same draft functions as the game rather than maintaining a second implementation of the rules. For longer-lived projects, I would also version the rules and roster in draft tokens. A deployment that changes candidate ordering can otherwise make an old browser tab incompatible with the current server. The Daily streak has a separate, smaller problem: a stored counter can become stale. If someone returns after a week away, a saved streak of five should not still display as active. Our streak logic compares the last completion date with today when reading the counter, and completing the same UTC day twice does not increase it twice. 4. A daily leaderboard cannot be reconstructed from lifetime bests The interface has two independent dimensions: Mode: Classic | Daily | Multiverse Period: Today | All time A storage bug becomes obvious with a simple example: - Yesterday, a player scored 170. - Today, they scored 140. - Their lifetime best is still 170. - Their entry on today's board should be 140. If the database keeps only the lifetime-best row, filtering it by today's date loses today's valid run. For Classic and Multiverse, we therefore keep daily personal bests separately from lifetime bests. This is an excerpt from the daily table migration: CREATE TABLE IF NOT EXISTS daily_runs ( mode TEXT NOT NULL CHECK (mode IN ('classic', 'multiverse')), daily_date TEXT NOT NULL, client_id TEXT NOT NULL, score INTEGER NOT NULL CHECK (score BETWEEN 0 AND 195), cabinet TEXT NOT NULL, player_name TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY (client_id, mode, daily_date) ); CREATE INDEX IF NOT EXISTS daily_runs_board_idx ON daily_runs (mode, daily_date, score DESC, created_at ASC); Daily mode already partitions records by puzzle date. Its all-time view needs to select each player's best across those dates. Another migration lesson: you cannot recover discarded historical runs from a best-only table. The migration can preserve known records, but missing history must remain missing. For local development, a small database wrapper uses Node's SQLite support and the same SQL migrations. This helps test queries without a remote database, although it does not reproduce every aspect of the Workers runtime or D1 service. 5. A share link is not an attached image Our result URLs contain an encoded payload with the mode, score, and cabinet. That lets the server render a result page without creating a database row for every share. The tradeoff is visible: the URLs are long. Encoding is also not encryption or verification. A share page is a presentation of a result, not proof that the result belongs on the leaderboard. The image pipeline is: Result payload → card layout → Satori SVG → resvg PNG We generate a 1200 × 630 landscape card and a 1080 × 1920 portrait card. The link preview uses the landscape version consistently; players can choose which format to download. Two production problems prevented previews from working: - robots.txt blocked both the result pages and the image endpoint. - The deployed image renderer returned HTTP 500 because a dependency failed during initialization in Workers. The HTML metadata existed, but that alone was not enough. We allowed crawlers to access result pages and the image endpoint, kept result pages marked noindex , and pinned a compatible rendering dependency. Verification then included fetching the actual production PNG-not just checking that the build completed. There is also a UX distinction between sharing a URL and attaching an image. Our X flow copies the card and opens the composer; the player still pastes and publishes it. A website cannot manipulate another site's composer just because it opened the URL. 6. Tests should cover the boundaries between components The most useful checks in this project cover: - Whether draft choices stay inside the offered pool. - Whether server replay reconstructs the expected cabinet. - Whether malformed share payloads are rejected. - Whether today's lower score remains eligible for today's board. - Whether streaks behave correctly across UTC date boundaries. - Whether rendered cards contain all five cabinet seats. - Whether production returns a real image at the URL declared in social metadata. The common theme is that a component can work in isolation while the product still fails between components. A successful page build does not prove that a Worker can render its preview image. A correct lifetime-best query does not prove that the daily board contains today's players. A repeatable random generator does not help if client and server consume it differently. Try the implementation You can play 195-0 Game without an account, or start with the Daily Draft to compare decisions on a shared challenge. I'd welcome feedback on the engineering tradeoffs: how would you version deterministic game rules across deployments, and when would you replace payload-based share URLs with stored short links? Top comments (0)
Comments
No comments yet. Start the discussion.