Four browser windows arranged in a 2×2 grid, each logged in as a different player, silently passing cards to a Cloud Run server and watching the state snap back. No chat. No signaling. Just four Playwright-driven browsers and a hundred cards to get rid of in descending order. When that test passes, the game works.

This is a post about the architecture and shipping of Countdown v2 — a cooperative card game in the vein of The Mind, built as a Dart monorepo with a WebSocket server and a Flutter client. I wrote about why I rewrote it from v1 in the last post. This one is about what’s in the box.

The game

The Mind is a card game where you win by silently playing your cards in ascending order, without talking or signaling. Countdown flips the direction — cards play descending, 100 down to 1 — but the feel is the same. You hold a few cards. You stare at your teammates. Someone plays. You breathe. You wait for the tension in your hand that tells you it’s your turn. Either the card plays and the round continues, or it doesn’t, and somebody loses a life.

It’s a game about the texture of cooperative silence. That makes the engineering problem interesting, because the thing I’m shipping isn’t “a working card game.” It’s the feel of four people trusting each other through a browser.

The monorepo and where logic lives

Four packages:

  • countdown_core — pure Dart game engine. Deck, hand, player, rules, play results. No I/O, no Flutter, no sockets. Deterministic functions on immutable state.
  • countdown_console — CLI bot simulator. Exists to exercise the core without any UI or networking.
  • countdown_server — Dart + shelf WebSocket server. Wraps the engine with rooms, connections, and broadcasts.
  • countdown_flutter — Flutter client. iOS, Android, macOS, Windows, Linux, web. A dumb renderer of server state.

The pivot point is countdown_core. The server imports it as a path dependency. The client imports it as a path dependency too, but only for types and enums — the actual rule logic stays server-side. There is exactly one place where the rules of the game are expressed, and everything else is a consumer.

This is the architectural insight v1 didn’t have. Game is engine, transport is detail, UI is projection. Hold onto that shape and the code stays honest.

Server-authoritative state

The server is authoritative. Clients send intents — create_room, join_room, play_card, vote_card_count — and the server validates them against the engine, mutates state, and broadcasts a state_update containing the entire relevant snapshot to every connected client. No deltas. No diffs. No ordering guarantees required.

This is a deliberate simplification. Diff-based protocols are smaller but have to solve a lot of hard problems — out-of-order messages, dropped messages, replay logic. Snapshot-based protocols send more bytes and solve none of them. For a 100-card game with fewer than ten players per room, the bytes don’t matter and the simplicity is load-bearing.

Per-player hand visibility is handled at the serialization boundary: each player receives their own hand values; other players’ hands are broadcast as empty arrays. That means a client can’t snoop another player’s hand even if they tamper with the wire — the server never sent it to them in the first place. The trust boundary and the privacy boundary are the same line of code.

Reconnection without message queues

One problem WebSocket games are notorious for: what happens when a player drops connection mid-game? Do you kill the round? Hold a seat? Queue messages for them? Reconstruct their state?

My answer was: when a player reconnects with their room code and player ID, the server replaces their old sink with the new one. The next broadcast — which always contains the full state — catches them up automatically. No queue. No “missed messages” handling. No state reconstruction.

This works because the snapshot protocol is already idempotent. If I’d chosen a diff protocol, reconnection would be a serious project. Because I chose snapshots, reconnection is a few lines of code. This is the payoff for picking the boring option.

Spectator mode

Entering the room code PILE_VIEWER on a device puts it into spectator mode. It joins the room as a read-only connection — no hand, no controls, just a view of the discard pile, player list, and round number. Put it on a table. Shove a phone in the middle. Four people with phones can all see their hands, and one shared screen shows the common state. It turns a mobile-only game into a table game.

Spectators are a parallel list of connections on the server, separate from players. The broadcast loop iterates players and spectators independently. Adding the feature was small, because the server was already per-connection-aware. That’s what “designed for the right boundary” looks like in practice — a feature you didn’t plan for costs an afternoon instead of a week.

Tests: deeper than they look

The test pyramid for a networked game is weird. You need unit tests for rules, but you also need to know the wire protocol is honest, and you also need to know the UI doesn’t deadlock when a round ends, and you also need to know four players can finish a game in real time without the server eating itself.

Here’s what actually runs on every PR:

Layer Tool Coverage
Game engine dart test Deck, hand, rules, play outcomes
Bots dart test Optimal and fallible bot policies
Server dart test Rooms, protocol, reconnection, voting, full games
Client flutter test Widgets and state machine
Visual regression Golden tests Screenshots across viewports and themes
End to end Playwright Four real browser windows, full game flow

The Playwright suite is the expensive one. It opens four Chromium windows, has them create a room, join it, vote on card counts, play a round, lose a life, play another round, win. Each test runs the real client against the real server over a real WebSocket. When that suite is green, the game works for real.

I wouldn’t recommend this for every project. For a cooperative multiplayer game where the whole point is “does it actually synchronize across clients,” it’s worth every penny.

Golden tests on Linux, developed on macOS

One gotcha worth sharing. Flutter’s golden tests are sensitive to platform font rendering. Goldens generated on macOS don’t match goldens generated on Linux, and GitHub Actions runs Linux. So CI is the source of truth. Locally, I keep the goldens marked assume-unchanged in git so they don’t pollute diffs. There’s an /approve-goldens comment workflow on PRs that regenerates goldens on Linux CI and commits them back to the branch.

This is dumb. It shouldn’t require this much infrastructure. But visual regression tests are non-negotiable for a game where the state is the screen, so the infrastructure got built.

Product decisions that aren’t in the rules

Things the rulebook doesn’t care about, but players do:

  • A tutorial overlay you can toggle on and off from the lobby, for new players.
  • Sound and haptics, with a mute toggle for public play.
  • Card-play animations and a confetti celebration when you win.
  • Round-transition interstitials, so the game breathes between rounds.
  • A “Play Again” flow that keeps the room intact, so you can rematch without resetting.
  • A dark theme, because playing at night shouldn’t blind you.
  • Reconnection that makes dropped connections invisible.

None of these are engineering showpieces. They’re the difference between software that works and software people want to play.

Deployment

Server runs on Google Cloud Run. Cloud Build compiles the Dart server into a multi-stage Docker image and deploys it on push to main. The Flutter web client is built per PR and deployed to GitHub Pages with preview URLs, each pointing at the live Cloud Run server via --dart-define=WS_URL. Every PR is a playable link. You can open two browser windows on a PR preview and actually play the branch.

This is the part that made me take the project seriously. “Works on my machine” is not the same thing as “open this URL and play with a friend.” Closing that gap is half the job.

AI-assisted, explicitly

The co-authored-by tags in the commit history aren’t cosmetic. Roughly a fifth of the commits have Claude as a co-author. The CLAUDE.md in the repo is a working contract: how to run tests, what the WebSocket protocol looks like, where the gotchas are. It’s written for an agent, not a human. Future-me and future-Claude both need it.

The pattern across the project looked like this:

  • I described the next feature, the boundary it should respect, and how it would be tested.
  • Claude drafted the implementation, wrote the tests, and proposed a PR description.
  • I reviewed, argued with the design where I disagreed, ran the tests, and merged.

The result is 228 commits in 17 days on a non-trivial piece of networked software with a real test suite and real deployment. That’s not a velocity number I could hit alone. It’s also not a velocity number I’d trust without the test suite telling me the game still works at the end.

What’s next

The game is playable. You can open it in a browser and actually play it. The next things on my list are shorter: tighter onboarding, a way to play solo against bots, and an eventual native iOS build. The architecture is built for all of that. The hard decisions are behind me.

That’s what a seventeen-day rewrite buys you. You land in the right shape, and the next six months of work are just additions.