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
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
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.
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.tsfor the client (badge + progress bar)Game/Stumper/Controller/Leaderboard.phpfor 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:
$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:
[
'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 ],
],
]