Docs/Stumper/Client

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.

TypeScript
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:

TypeScript
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.

TypeScript
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:

TypeScript
useLobbyStream ()
   .connect    (lobbyCode)   // mints token, opens stream, fires onUpdate
   .disconnect ()
   .onUpdate   ((lobby) => { ... })

Internally:

  1. Calls katforge.games.stumper.mintLobbyToken(code) → server sets the mercureAuthorization cookie
  2. Opens an EventSource against the Mercure hub with topic=https://stumper.gg/lobby/{code} and withCredentials: true
  3. Forwards each data payload (parsed JSON) to subscribers
  4. 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.

TypeScript
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.

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

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

lib/katforge.ts — the singleton SDK instance

TypeScript
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 statestores/lobby.ts, composables/useLobbyStream.ts
Single-player flowstores/game.ts, composables/useGameplay.ts
Score / streak mathlib/score.ts (and Scoring.php server-side)
Levels / XP barlib/levels.ts, stores/xp.ts
Player preferencescomposables/usePreferences.ts
Lobby browsercomposables/useLobbyListStream.ts, components/LobbyBrowser.vue
Theme / dark modestores/theme.ts, components/ThemePicker.vue
OAuth callbackpages/OAuthCallback.vue

Page surface

There are only five Vue pages. Almost all of the interactive surface is inside Home.vue.

text
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.