Docs/Stumper/Scoring & XP

Scoring & XP

This page is the spec. The math is implemented twice — once on the server in Scoring.php and once on the client in lib/score.ts — and the duplication is intentional so the UI never has to wait for a round-trip to show the player their points. If you change one, change the other in the same commit.

Inputs

TypeScript
isCorrect:     boolean   // wrong answers always score 0 and reset streak
currentStreak: number    // streak BEFORE this answer
timeLimitMs:   number?   // null in untimed lobbies and practice mode
timeTakenMs:   number?   // null if the client didn't measure

Formula

TypeScript
if (!isCorrect) {
   return { points: 0, streak: 0, multiplier: 1 };
}

const newStreak  = currentStreak + 1;
const base       = 100;
const speedBonus = (timeLimitMs && timeTakenMs)
   ? floor (max (0, timeLimitMs - timeTakenMs) / timeLimitMs * 100)   // 0–100
   : 0;

const multiplier = newStreak >= 6 ? 4
                 : newStreak >= 4 ? 3
                 : newStreak >= 2 ? 2
                 :                  1;

const points = (base + speedBonus) * multiplier;

Streak multipliers

Streak (after this answer)Multiplier
1×1
2-3×2
4-5×3
6+×4

A wrong answer drops the streak straight to 0 — no decay. The next correct answer starts at ×1.

Speed bonus

Linear from 100 (instant) to 0 (timer expired):

xychart-beta
   title "Speed bonus vs answer time (30s timer)"
   x-axis "Answer time (s)" [0, 5, 10, 15, 20, 25, 30]
   y-axis "Speed bonus" 0 --> 100
   line [100, 83, 66, 50, 33, 16, 0]

In untimed lobbies and practice mode timeLimitMs is null, so the speed bonus is 0 and points are pure base × multiplier.

XP and levels

XP is separate from per-round score. It accumulates across every mode and never resets.

text
Level   0     1    2    3    4     5     6     7     8     9     10
XP req  0   100  250  500 1000  1800  3000  5000  8000 12000  18000

Implemented in:

  • stumper.gg/src/lib/levels.ts for the client (badge + progress bar)
  • Game/Stumper/Controller/Leaderboard.php for the server-side level computation on the leaderboard

The thresholds are a hard-coded array. To change them, edit BOTH files in the same commit.

Where points become XP

The transformation from points (per-round) to xp (lifetime) happens in StatsService::recordAnswer:

PHP
$xpGained = $statsService->recordAnswer (
   playerId:  $identity->id,
   category:  $question->getCategory (),
   isCorrect: $isCorrect,
   points:    $isCorrect ? 100 * $multiplier : 0,
   streak:    $currentStreak
);

Currently xpGained == points for correct answers. The split exists so a future "XP boost weekend" or "category bonus" multiplier can be added without changing the per-round scoreboard math.

Test fixtures

The pure Scoring class has no dependencies, which makes it easy to fix the math via tests:

PHP
[
   'correct, no streak, no timer' => [
      'in'  => [ true, 0, null, null ],
      'out' => [ 'points' => 100,  'streak' => 1, 'multiplier' => 1 ],
   ],
   'correct, streak 5, half-time' => [
      'in'  => [ true, 5, 30000, 15000 ],
      'out' => [ 'points' => (100 + 50) * 4, 'streak' => 6, 'multiplier' => 4 ],
   ],
   'wrong'  => [
      'in'  => [ false, 5, 30000, 1000 ],
      'out' => [ 'points' => 0, 'streak' => 0, 'multiplier' => 1 ],
   ],
]