Client
The Stumper client lives at stumper.gg/. It's a Vue 3 app with Pinia stores, file-based routing, and a small set of composables that bind to the Mercure SSE streams.
flowchart TB
subgraph pages["pages/"]
Home["Home.vue"]
Lb["LeaderboardPage.vue"]
Results["Results.vue"]
end
subgraph stores["stores/"]
lobbyStore["lobbyStore"]
gameStore["gameStore"]
xpStore["xpStore"]
themeStore["themeStore"]
end
subgraph composables["composables/"]
useLobbyStream["useLobbyStream()"]
useLobbyListStream["useLobbyListStream()"]
useGameplay["useGameplay()"]
useCountdown["useCountdown()"]
usePreferences["usePreferences()"]
end
subgraph lib["lib/"]
katforge["katforge<br/>(SDK instance)"]
score["score.ts"]
levels["levels.ts"]
end
Home --> lobbyStore
Home --> gameStore
Home --> useGameplay
Lb --> lobbyStore
Results --> lobbyStore
useGameplay --> gameStore
useGameplay --> useCountdown
lobbyStore --> useLobbyStream
useLobbyStream --> katforge
useLobbyListStream --> katforge
gameStore --> katforge
lobbyStore --> katforge
xpStore --> levels
gameStore --> score
lobbyStore — multiplayer state machine on the client
The store mirrors the server's LobbyManager 1-to-1 and is the only thing in the client that talks to the lobby HTTP endpoints. Every method is async and returns the same shape the SDK returns.
const lobby = useLobbyStore ();
await lobby.createLobby (hostName, { numQuestions, timer, visibility });
await lobby.joinLobby (code, name);
await lobby.startGame ();
await lobby.submitAnswer (selectedIndex, timeTakenMs);
await lobby.nextQuestion ();
await lobby.endLobby ();
await lobby.leaveLobby ();
await lobby.restoreSession (code); // refresh-recovery
await lobby.revealQuestion (); // force-reveal when local timer hits 0
State exposed reactively:
lobby.code // current lobby join code
lobby.players // PlayerEntity[] (server-side player rows)
lobby.currentQuestion // QuestionSafe | null
lobby.status // 'waiting' | 'in_progress' | 'finished'
lobby.isHost // derived from currentPlayerId === lobby.host_player_id
lobby.serverNowMs // server time, used to compute timer drift
gameStore — single-player state machine
Used by practice and endless modes. Same shape as lobbyStore but no SSE.
const game = useGameStore ();
await game.fetchQuestion (category, difficulty, prompt, practiceMode);
await game.submitAnswer (selectedIndex, timeTakenMs);
await game.submitTimeout (); // timer expired
game.nextQuestion ();
game.reset ();
useLobbyStream() — the EventSource binding
A shared composable (only one instance ever exists) that owns the EventSource for the current lobby. It's a small state machine on top of EventSource:
useLobbyStream ()
.connect (lobbyCode) // mints token, opens stream, fires onUpdate
.disconnect ()
.onUpdate ((lobby) => { ... })
Internally:
- Calls
katforge.games.stumper.mintLobbyToken(code)→ server sets themercureAuthorizationcookie - Opens an
EventSourceagainst the Mercure hub withtopic=https://stumper.gg/lobby/{code}andwithCredentials: true - Forwards each
datapayload (parsed JSON) to subscribers - On error, drops the stream and re-mints the JWT before reconnecting
Used exclusively by lobbyStore.connectStream(). No component touches EventSource directly.
useGameplay() — the timer + scoring binder
Ties the current question, the countdown, and the local scoring math together so a single component can render "you scored 200 (×2 streak, +50 speed bonus)" the instant the player submits.
const {
question, // current question (or null)
timeRemaining, // reactive ms count
timeProgress, // 0..1 for progress bars
submit, // submit + compute local score
localScore // last computed score (from lib/score.ts)
} = useGameplay (gameStore);
lib/score.ts and lib/levels.ts
Pure functions, no Vue. They mirror the server math so the UI never has to wait for a round-trip to know the points or level. See Scoring & XP.
multiplierFor (streak)
speedBonus (timerDuration, timeTakenMs?)
pointsFor (streak, timerDuration, timeTakenMs?)
levelFromXp (xp)
progressFromXp (xp) // → { level, xpInLevel, xpForNextLevel, percentage }
lib/katforge.ts — the singleton SDK instance
export const katforge = new KATforge ({
baseUrl: import.meta.env.VITE_API_URL ?? 'https://api.katforge.com',
storage: browserStorage,
games: [ 'stumper' ]
});
Imported by every store and composable. Never instantiate the SDK twice — the auth state lives inside the instance.
File map (by responsibility)
| You're working on… | …open these files |
|---|---|
| Lobby state | stores/lobby.ts, composables/useLobbyStream.ts |
| Single-player flow | stores/game.ts, composables/useGameplay.ts |
| Score / streak math | lib/score.ts (and Scoring.php server-side) |
| Levels / XP bar | lib/levels.ts, stores/xp.ts |
| Player preferences | composables/usePreferences.ts |
| Lobby browser | composables/useLobbyListStream.ts, components/LobbyBrowser.vue |
| Theme / dark mode | stores/theme.ts, components/ThemePicker.vue |
| OAuth callback | pages/OAuthCallback.vue |
Page surface
There are only five Vue pages. Almost all of the interactive surface is inside Home.vue.
pages/
├── Home.vue ← landing + practice + lobby + endless. Owns most of the UX.
├── LeaderboardPage.vue ← global leaderboard with sortable tabs
├── Results.vue ← post-lobby summary screen
├── OAuthCallback.vue ← receives the redirect from /v1/gateway/oauth/{provider}/check
└── NotFound.vue
Keeping the surface small is deliberate. New top-level pages are unusual; most new features add components or composables that Home.vue uses.