One API, N Games: Client-Authoritative Event Sourcing for a Unified Game Client
Adding a new board game to the cluster used to be three weeks of backend work. After this rearchitecture, YINSH took a weekend, an afternoon, and ten lines in a registry. The pivot: the server stops understanding games, stores opaque events in sequence, and the client replays them. From the cluster post forward, “ship a new game” is a client-only project.
- Chess-only backend was about to fork into four parallel services for Alkagi, Horror Race, Fruit Shop.
- Pivot, written into the roadmap on 2026-02-24: client-authoritative simulation + server as typed event relay.
- Five phases: auth API, v2 game API, engine-to-client move, unified
board-game-clientatboard-game.wintersalmon.com. - YINSH on 2026-04-25 added one
gameTypevalue, one registry entry, one validator switch case. Zero new endpoints.
The chess shape did not survive contact with a second game
The chess service had per-game endpoints — POST /api/v1/chess/move, POST /api/v1/chess/lobby/create — eight Knative functions under functions/chess/, and a dedicated SSE relay (game-event-relay-service) that knew chess events. The server imported @cloudnest/chess-engine for validation. Adding Alkagi the same way meant a parallel functions/alkagi/ tree, an alkagi server-side validator, and a second engine in the same binary. Four games meant four forks.
The deeper failure mode was release coupling: bumping the chess engine would ship an Alkagi server. The fork is one step away every time.
The server stops importing game engines
Two design moves, both in docs/task-log/archive/2026/20260224-game-platform-api-rearchitecture-roadmap.md:
- Client-authoritative simulation. The engine runs on the client. The server never imports it.
- Server as typed event relay. Opaque events stored in sequence, rebroadcast over SSE. State is the result of replaying events from
seq 0.
Adding a new game now adds one value to a gameType union and one client-side registry entry. Phase ordering in the roadmap was deliberate: auth had to be solid before anything reused it, and the relay was not worth building until at least one game proved the v2 contract.
Phase 1 fixed errorCodes before clients had to parse strings
The four-endpoint surface — register/login/me/logout under /api/v1/auth/* — locked an error envelope with eleven errorCode values: INVALID_JSON_BODY, VALIDATION_FAILED, EMAIL_ALREADY_REGISTERED, INVALID_CREDENTIALS, UNAUTHORIZED, REGISTRATION_DISABLED, METHOD_NOT_ALLOWED, REQUEST_BODY_TOO_LARGE, ACCOUNT_LOCKED, RATE_LIMITED, INTERNAL_SERVER_ERROR. Frontends branch on the code, never the human-readable string. Tokens lived in httpOnly cookies (accessToken 1d, refreshToken 7d) with CSRF via double-submit cookie + X-CSRF-Token header.
The reusable knob was an authPathPrefix config in packages/shared-auth-client — apps default to /auth for backwards compatibility but pass /api/v1/auth to hit the new surface. That single config is the only reason I could roll out the new auth without rewriting every client at once. auth-playground at playground.wintersalmon.com was the verification harness; debugging auth bugs through a multiplayer chess UI is masochism.
The v2 surface is deliberately game-agnostic
GET /api/v2/games list rooms
POST /api/v2/games create (body: gameType)
POST /api/v2/games/:id/join
POST /api/v2/games/:id/start
POST /api/v2/games/:id/submit emit event
GET /api/v2/games/:id/events SSE replay
GET /api/v2/players/me/stats
Phase 3’s definition of done was a grep: zero /api/v1/ references in the chess client’s API module. The last v1 holdout was augmented-chess-client/game-api.ts:147 calling GET /api/v1/players/me/stats. Cutting it required functions/game/player/get-stats/ plus getMyStats(gameType?) on shared-game-client/room-api.ts.
Phase 3 also fixed a real SSE bug: disconnect during a match, opponent moves, reconnect — missed events were lost. The fix was an isFirstConnectRef plus an explicit replayEvents() from the persisted sequence number on every reconnect after the first. Same pattern in augmented-chess-client/GameScreen.tsx and alkagi-client/MultiplayerGame.tsx. Manual repro: disconnect, opponent moves, reconnect, missed move appears.
The registry is where new games actually plug in
apps/board-game-client/ at board-game.wintersalmon.com is one auth flow, one lobby, one game-room screen. Routes use a /<gameType>/... prefix. LobbyScreen and GameRoomScreen are generic — parameterized by gameType. Game-specific UI lives only inside each GameScreen, lazy-loaded:
chess: {
gameType: "chess",
displayName: "Augmented Chess",
engine: "@cloudnest/augmented-chess-engine",
CreateGameForm: ChessCreateGameForm,
GameScreen: lazy(() => import("./games/chess/GameScreen")),
},
K8s deployment was the standard FluxCD pattern: ImageRepository + ImagePolicy, ingress for board-game.wintersalmon.com, TLS SAN added to the existing certificate, two-phase Dockerfile COPY for nine workspace deps. Same shape as every other client app on the cluster.
YINSH validated the architecture in a weekend
YINSH landed on 2026-04-25: 79-position hexagonal board, five game phases, five action types, 58 unit tests across 11 files (packages/yinsh-engine/). The multiplayer integration was about ten lines — register gameType: "yinsh" in the registry with a hexagon icon, lazy-load a GameScreen, point the multiplayer store at createRoomApi("yinsh"). The server validator added one switch case in apps/game-validator/src/validators/yinsh.ts. No new endpoints, no new ingress rules, no new TLS SAN, no new image automation. board-game.wintersalmon.com/games/yinsh came online for free. The only YINSH-specific server code is opaque event validation — the server still has no idea what a ring or a marker is.
#game-platform #event-sourcing
AI workflow note
Claude wrote the five-phase roadmap as a planning document before any code shipped. The planner agent was asked to enumerate risks before Phase 1 started; the two it surfaced — cheat surface from client-authoritative simulation, and scope creep from “just one more game-specific server hook” — directly shaped the v2 contract (opaque payloads, hard rule that the server never imports a game engine). The discipline that mattered was finishing each phase end-to-end before starting the next: auth had a working playground before the v2 surface began, the v2 surface had a real client before engines moved off the server. When I broke that rule on smaller features later, debugging cost more than the time I “saved.”
