Docs/Stumper/Architecture

Architecture

Three layers, each with a small set of well-named components. Every component exposes a fluent surface that is the contract between layers.

flowchart TB
   subgraph client["stumper.gg (Vue 3)"]
      lobbyStore["lobbyStore"]
      gameStore["gameStore"]
      xpStore["xpStore"]
      useLobbyStream["useLobbyStream()"]
      lobbyStore -.uses.-> useLobbyStream
   end

   subgraph sdk["@katforge/api"]
      games_stumper["katforge.games.stumper"]
   end

   subgraph server["api.katforge.com (Symfony)"]
      Controllers["Controllers<br/>(Lobbies, Endless, Questions, Favorites, Leaderboard, ...)"]
      LobbyManager
      EndlessManager
      QuestionGenerator
      StatsService
      Scoring
      TeamService
      Repos["Repositories<br/>(thin Doctrine layer)"]
   end

   PG[(Postgres<br/>stumper schema)]
   Mercure((Mercure))
   Anthropic((Anthropic))

   client -->|REST| games_stumper
   games_stumper -->|HTTP| Controllers

   Controllers --> LobbyManager
   Controllers --> EndlessManager
   Controllers --> QuestionGenerator
   Controllers --> StatsService

   LobbyManager --> Scoring
   LobbyManager --> StatsService
   LobbyManager --> TeamService
   LobbyManager --> QuestionGenerator
   LobbyManager --> Repos
   LobbyManager -.publish.-> Mercure

   EndlessManager --> Scoring
   EndlessManager --> StatsService
   EndlessManager --> QuestionGenerator
   EndlessManager --> Repos

   QuestionGenerator --> Repos
   QuestionGenerator -.generate.-> Anthropic

   Repos --> PG
   useLobbyStream -.subscribe.-> Mercure

Server components

Each service has a single responsibility and a small fluent surface. The controllers are intentionally thin — they translate HTTP into method calls and back.

LobbyManager

Owns the multiplayer state machine. Every state transition publishes to Mercure so all subscribers stay in lockstep.

PHP
$manager->create     ($dto, $playerId)         // → ['lobby' => ..., 'player' => ...]
$manager->join       ($code, $name, $playerId) // → PlayerEntity
$manager->start      ($code, $playerId)        // host only
$manager->answer     ($code, $playerId, $dto)  // → ['result' => ...]
$manager->next       ($code)                   // advance to next question
$manager->reveal     ($code)                   // force-end the current question
$manager->end        ($code, $playerId)        // host only
$manager->leave      ($code, $playerId)
$manager->heartbeat  ($code, $playerId)
$manager->status     ($code)                   // → snapshot (same as Mercure payload)
$manager->list       ()                        // → public lobbies
$manager->mintSubscriberToken ($code, $userId) // → JWT for Mercure
$manager->cleanup    ()                        // periodic GC of stale lobbies

Source: api.katforge.com/src/Game/Stumper/Service/LobbyManager.php. Lifecycle details: Lobby Lifecycle.

EndlessManager

Single-player gauntlet. No SSE, no multiplayer state.

PHP
$manager->start  ($category, $name, $playerId) // → ['run' => ...]
$manager->answer ($runId, $selectedIndex)      // → ['run' => ...]
$manager->format ($run)                        // → array (for response serialization)

Source: api.katforge.com/src/Game/Stumper/Service/EndlessManager.php.

QuestionGenerator

The question pipeline: Anthropic generation, dedupe against the per-player seen set, persistence, and category routing. See Question Pipeline.

PHP
$generator->fetch       ($category, $difficulty, $count, $prompt, $seenIds, $model)
$generator->draw        ($playerId, $category, $difficulty, $prompt, $retry)
$generator->categorize  ($prompt) // → string[] suggested categories
$generator->format      ($question)        // full payload (with answer)
$generator->formatSafe  ($question)        // answer hidden
$generator->formatReveal($question, $isCorrect) // post-answer payload

Source: api.katforge.com/src/Game/Stumper/Service/QuestionGenerator.php.

Scoring

Pure function. No dependencies. Same math runs server-side and client-side (stumper.gg/src/lib/score.ts).

PHP
Scoring::calculate (
   isCorrect:    bool,
   currentStreak: int,
   timeLimitMs: ?int,
   timeTakenMs: ?int
) // → ['points' => int, 'streak' => int, 'multiplier' => int]

Source: api.katforge.com/src/Game/Stumper/Service/Scoring.php. Math: Scoring & XP.

StatsService

Per-player aggregates: total XP, accuracy, streaks, per-category stats.

PHP
$stats->recordAnswer     ($playerId, $category, $isCorrect, $points, $streak) // → xpGained
$stats->getStats         ($playerId)         // → totals
$stats->getCategoryStats ($playerId)         // → per-category breakdown

TeamService

Aggregates lobby results into team scores for the team-based leaderboard.

PHP
$teams->finalize    ($lobbyId)                                    // post-game roll-up
$teams->leaderboard ($metric, $limit, $page, $viewerPlayerId)     // ranked list

Client components

Pinia stores

TypeScript
useLobbyStore ()
   .createLobby (hostName, options)
   .joinLobby   (code, name)
   .startGame   ()
   .submitAnswer (selectedIndex, timeTakenMs?)
   .nextQuestion ()
   .endLobby    ()
   .leaveLobby  ()
   .restoreSession (code)        // for refresh recovery

useGameStore ()
   .fetchQuestion (category?, difficulty?, prompt?, practiceMode?)
   .submitAnswer  (selectedIndex, timeTakenMs?)
   .submitTimeout ()
   .nextQuestion  ()
   .reset         ()

useXpStore   ()    // local XP cache and level computation
useThemeStore ()   // dark/light/system, persisted to localStorage

Composables

Thin wrappers around shared concerns:

TypeScript
useLobbyStream     ()  // EventSource lifecycle bound to current lobby code
useLobbyListStream ()  // EventSource for the public lobby browser
useGameplay        ()  // ties timer + question + scoring into one reactive bundle
useCountdown       (deadlineMs)  // reactive ms-precision countdown
useFlavorText      (active)      // rotating flavor strings during loading
usePreferences     ()            // server-backed prefs with optimistic sync
useLobbyJoin       ()            // restore-from-cookie + join helper

Lib

Pure functions, no Vue dependencies:

TypeScript
multiplierFor (streak)
speedBonus    (timerDuration, timeTakenMs?)
pointsFor     (streak, timerDuration, timeTakenMs?)

levelFromXp   (xp)
progressFromXp (xp) // → { level, xpInLevel, xpForNextLevel, percentage }

Source: stumper.gg/src/lib/. These mirror the server's Scoring and level math so the UI never has to wait for a round-trip to know the points or level.

Why this composition

  • Controllers stay thin so all rules live in services where they're testable.
  • Services have no HTTP awareness — they take DTOs in, return arrays/entities out. They can be reused from CLI commands (see LobbyCleanupCommand).
  • Pure scoring is duplicated server- and client-side intentionally. It's small, the math is the contract, and the client can show points instantly without waiting for the server. The duplication is enforced by the Scoring & XP page being the spec for both.
  • The SDK is a thin transport. It maps every server endpoint to a fluent method, normalizes errors, and otherwise doesn't add behavior. Logic belongs in services or stores.