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.
$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.
$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.
$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).
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.
$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.
$teams->finalize ($lobbyId) // post-game roll-up
$teams->leaderboard ($metric, $limit, $page, $viewerPlayerId) // ranked list
Client components
Pinia stores
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:
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:
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.