Board game v2 migration: one client, per-game runtimes
The board-game v2 migration was a bet that the client should stay shared while each game runtime becomes independently deployable. board-game-client still ships as one SPA, but live play now flows through /api/v2/game-platform and a catalog-pinned board-game-rules-*-v2 service. As of June 12, 2026, docs/task-log/20260608-board-game-v2-separate-spa-migration/16-game-status-dashboard.md marks all 16 tracked games [V2 PROD LIVE].
- Client routes moved under
/v2/games/<game>, with production exposure controlled by the server catalog. - Each game rule service exposes
/health,/manifest, and/transition. game-api-gochecksclientCommandIdandexpectedSequenceNumber, then commits event/state/viewer-state rows to Mongo.- Hidden-information games split public events from private
viewer-states. - The migration stayed additive: v1 routes remained available while each game moved.
The deployment unit was the problem
The initial pain was not the rules of any single game. It was the deployment boundary. A small Black and White client fix, a YINSH route-mode bug, and a Chess replay tweak all rode the same board-game-client image. That meant every change had the blast radius of the whole game room.
The first design sketch considered independent SPA deployments per game. That looked clean until auth, lobby, room joining, WebSocket connection state, share links, and observer UI entered the picture. Those were shared product surfaces, not game surfaces. The final shape became a shared client shell with path-scoped game runtimes: apps/board-game-client/src/router.ts recognizes /v2/games/<game>, while go/internal/game/catalog.go decides which rule service that game uses.
The server owns transitions without inventing state
In v2, the server has one clear online job. When a client submits a command, go/cmd/game-api/handlers/platform_v2.go packages the current state and command, calls the pinned rule service at /transition, and commits only the returned result.
That rule request is stateless: currentState + command -> transition result. YINSH, for example, runs through apps/game-validator/src/rule-services/yinsh-server.ts and returns validation, next state, event payload, and room-completion metadata when needed. Cascadia, Warchest, Celestia, and the other migrated games follow the same runtime contract. That is what made replay, testing, and rollback manageable.
Private viewer-state unlocked hidden-information games
Public games are straightforward. Cascadia and Chess can show every participant the same canonical state. Hidden-information games cannot. black-and-white, indian-poker, minus-auction, fruit-shop-v2, fish-shop-v2, warchest, one-night-ultimate-werewolf, and celestia all need per-recipient projections.
The v2 catalog names that difference with projectionPolicy. Public games can use the event log and current state directly. Private games return viewerStates from the rule service, and game-api-go stores the player and observer projections alongside the committed event. Without this piece, hidden-game production promotion would have kept slipping into “later”.
WebSocket became delivery, not authority
The only intentionally stateful piece is the WebSocket connection manager. ws-relay-go does not run rules. It watches MongoDB change streams and fans committed updates out to connected clients. That keeps HTTP submit as the single transition path and WebSocket as the delivery layer.
The testing model followed that boundary. L1 covered engines and rule services. L2 covered catalog pins, route-mode helpers, and transition contracts. L3 covered backend-free replays. L4 covered host, guest, and observer live flows. The final YINSH closeout in PR #1078 verified direct /transition(init) in production and staging, then confirmed three browser roles reached /v2/games/yinsh/rooms/<roomId>.
Parallel workers migrated games, but catalog exposure stayed serial
The work was parallel by game and serial by production exposure. One session could implement a rule service and route-mode client while another handled shared catalog or Kubernetes pins. Production catalog changes still landed deliberately, because the catalog is the public switch.
That trade-off paid for itself during the final wave. PR #1054 promoted Warchest, One Night Ultimate Werewolf, Celestia, and legacy Fruit Shop. PR #1078 closed the last YINSH route-mode gap. If one game regressed, rollback could remove the game from catalog or pin one board-game-rules-*-v2 image without undoing the whole platform.
The next cleanup is URL promotion
/v2/games/<game> was the migration guardrail, not the final user-facing shape. The next step is to make v2 semantics serve the existing public URLs. A room like /v2/games/chess/rooms/<id> should become the platform-backed version of the URL players already know, such as /chess/games/<id>.
That work should start additively. First, root-level game routes resolve to v2 behavior and /v2 remains an alias. Then legacy /api/v2/games traffic is measured down to zero. Only after that should legacy route-mode branches, old validator deployments, and stale API paths be removed. The execution plan lives in docs/task-log/20260608-board-game-v2-separate-spa-migration/21-v1-cleanup-url-cutover-plan.md.
#board-game #architecture #migration
AI workflow note
Codex drafted this post from the migration dashboard, central changelog, and YINSH closeout logs. The prompt that worked was to connect completion status, concrete deployment artifacts, and the next cleanup plan in one narrative. The risky failure mode was making the architecture sound more finished than it is, so the draft anchors claims to PR numbers, image tags, and file paths. The post was first staged as a DRAFT- file, then promoted after the v1 cleanup plan existed.
