Wintersalmon | Blog

One API, N Games: Client-Authoritative Event Sourcing for a Unified Game Client

5 min read

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-client at board-game.wintersalmon.com.
  • YINSH on 2026-04-25 added one gameType value, 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:

  1. Client-authoritative simulation. The engine runs on the client. The server never imports it.
  2. 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.”


Hungjoon

I'm Hungjoon, a software engineer based in South Korea. This is my long-form notebook — homelab, Kubernetes, AI infra, and whatever else keeps me up at night.