Decisions
Short ADR-style entries. Each one captures a real choice between viable alternatives, the constraint that decided it, and the consequences. Update in place if the decision changes.
D1 — Mercure SSE for real-time, not WebSockets
Status: active Date: 2025-12
Context: Multiplayer lobbies need every player to see the same question state at the same time.
Decision: Server-Sent Events via the Mercure hub bundled with symfony/mercure.
Why not WebSockets:
- One-way is sufficient. Every client → server action is a normal REST call. There's no need for a persistent bidirectional channel.
- SSE rides on plain HTTP/2, so it crosses every corporate proxy and CDN without special config.
EventSourceis native to every browser; no library needed on the client.- Mercure handles fan-out, JWT-scoped topics, and reconnection out of the box.
Consequences: A second container in the cluster (the Mercure hub), a small extra hop on every publish. Worth it.
D2 — Postgres schema-per-game
Status: active
Context: Multiple games (Stumper, Lextris, Gear Goblins) share one Postgres instance and one application but need to evolve their schemas independently.
Decision: Each game owns a schema. Stumper tables live in stumper.questions, stumper.lobbies, stumper.players, etc. Cross-game tables (players, users, oauth_clients) live in public.
Why not separate databases: Cross-game JOINs (e.g. resolving stumper.players.player_id to a public.players row) become harder, and migration ordering across DBs is fiddly.
Consequences: All migrations live in the single API repo; the game prefix is part of the entity's namespace and column names stay short within each game.
D3 — Stateless multiplayer (no Redis)
Status: active
Context: Lobbies have ephemeral state (current question index, who answered) that's typically held in memory or Redis in other multiplayer designs.
Decision: Persist all lobby state in Postgres. Mercure publishes are best-effort notifications; the DB is the source of truth.
Why:
- No new infrastructure to operate.
- Refresh-recovery is automatic — a player who reconnects just calls
lobbyStatus(code)and gets the full state. - Lobby cleanup is a single SQL query the cron command runs.
Consequences: Higher write traffic to Postgres than a Redis-backed design. Each "answer submitted" is a row insert plus a player update. Still well within budget.
D4 — Question cache table, not on-demand generation
Status: active
Context: Anthropic-backed question generation is slow (1-3s) and costs money per call.
Decision: Persist every generated question in stumper.questions. New requests check the cache first and only call Anthropic on miss. Lobbies snapshot question IDs at start so every player sees the exact same set.
Why not generate per-request:
- Determinism in lobbies — without persistence, two parallel requests to "generate 10 science questions" return different sets.
- Latency — cache hits are sub-10ms vs 1-3s for cold Anthropic.
- Cost — same question served thousands of times costs once.
Consequences: The cache grows monotonically. Append-only by design, so cache invalidation is a non-problem. A periodic curation pass (rate questions, retire bad ones) would be a useful future addition.
D5 — Pure scoring math, duplicated server- and client-side
Status: active
Context: The scoreboard needs to update the instant a player submits, with no perceptible round-trip.
Decision: Implement the scoring formula identically in Scoring.php and lib/score.ts. The Scoring & XP page is the single spec.
Why not just trust the server:
- 200ms round-trip latency feels sluggish on a fast question.
- Computing locally lets the UI animate the score increment as the player watches the answer reveal.
Why not just trust the client:
- The server still authoritatively computes scoring on submit. The client's local score is for display only; the server's value wins on reconcile.
Consequences: Two implementations to keep in sync. Mitigated by being a pure function with the spec page and a small surface area. CI test fixtures (in Scoring's test suite) catch divergence.
D6 — Thin SDK module, fat stores
Status: active
Context: Where should "client logic" live? In the SDK module so other consumers benefit, or in the Pinia stores so it's collocated with the UI?
Decision: SDK is pure transport — one method per endpoint, normalized errors, no behavior. Stores own state machines, derived state, and orchestration.
Why: A second consumer of the SDK (say, a CLI tool) doesn't want a hard dependency on Pinia or any of the lobby state machine. The SDK stays useful from anywhere; the stores stay free to be opinionated about UX.
Consequences: A small amount of duplication if Lextris ever needs lobby semantics — it would have its own lobby store, not share the Stumper one. Worth it for the layering.
D7 — Fluent namespace API everywhere
Status: active
Context: What does the developer-facing surface of the codebase look like?
Decision: Every public API is a namespace chain that reads like English. katforge.games.stumper.createLobby() not katforgeCreateStumperLobby(). lobbyStore.submitAnswer() not submitStumperLobbyAnswer(). Server services follow the same convention.
Why: The chain itself documents what the code does. There's exactly one place each name lives in the tree, so the Cmd-T jump-to-symbol gets you there in one keystroke.
Consequences: Every page in this section is structured around the fluent surface of one component. Adding a feature means writing the new method signature here first, then implementing against it.