If you award points for answering quickly, you have to pick a curve. Most apps pick linear because it is the one that comes to mind, and linear has a specific, ugly failure: it makes the last few seconds worthless, so a table that is genuinely deliberating is playing for nothing.
Here is the shape we landed on for pub-trivia.app, and the constraints that produced it.
What the curve has to do
Four requirements, and they fight each other:
- Fast is worth defending. Being first should feel like it paid.
- Slow and correct still beats wrong. A table that thought hard and got there must out-score a table that guessed instantly and missed.
- Nobody is robbed by latency. The difference between 14.6s and 15.0s must be small, because some of that gap is pub WiFi, not hesitation.
- It scales with the question. A 500 point tie-breaker has to be worth five times a 100 point warm-up, in every mode.
Linear satisfies 1 and 4 and fails 2 and 3. At the buzzer it pays zero or close to it, so requirement 2 is broken outright, and every millisecond of network jitter near the end costs real points.
The function
const T = Math.max(1, timeLimitSeconds)
const t = Math.max(0, Math.min(elapsedSeconds, T))
const k = Math.log(9) / T
const multiplier = MIN_TIME_POINT_RATIO + (1 - MIN_TIME_POINT_RATIO) * Math.exp(-k * t)
return Math.max(0, Math.min(base, Math.round(base * multiplier)))
With MIN_TIME_POINT_RATIO = 0.1, a 100 point question on a 15 second window pays:
0s -> 100 1s -> 88 3s -> 68 5s -> 53
8s -> 38 10s -> 31 15s -> 20
Steep early, flat late. Exactly the shape the four requirements ask for: the first two seconds are where the reward lives, and the last three are nearly indistinguishable from each other, so latency and deliberation are not punished.
Why k = ln(9) / T
This is the part worth stealing. The decay constant is derived from the time limit rather than tuned, so the curve has the same shape whether the window is 5 seconds or 15.
ln(9) is chosen so that at t = T the exponential term has fallen to one ninth of its starting value. With a floor of 0.1, that puts the asymptote at 10 percent and the value at the buzzer at 20 percent of the question's points. A tunable magic number would have to be re-tuned for every time limit, and would silently change the game's feel when a host picked 10 seconds instead of 15.
If you want a different feel, change the floor and let k follow. Do not hand-tune both.
The floor is a ratio, not a score
/**
* The floor of the time-weighted decay, as a fraction of the question's points.
* Expressed as a ratio rather than an absolute score so it scales with each
* question's own value.
*/
export const MIN_TIME_POINT_RATIO = 0.1
An absolute floor of "20 points minimum" breaks requirement 4 the moment someone writes a 10 point question, because now the floor is above the ceiling. Ratios compose, absolutes do not.
Clamping in three places, all deliberate
const T = Math.max(1, timeLimitSeconds) // a zero window would divide by zero
const t = Math.max(0, Math.min(elapsedSeconds, T)) // negative elapsed is a clock skew artifact
const base = Number.isFinite(basePoints) ? Math.max(0, Math.round(basePoints)) : 0
The third one matters more than it looks. basePoints comes from a database column. A null that became NaN on the way in produces a score of NaN, which propagates into the leaderboard sum, and now every team's total is NaN for the rest of the night. One bad row, whole session ruined, and no error anywhere.
Any pure function that feeds an aggregate should treat non-finite input as a value to be neutralised rather than propagated.
Negative elapsed time is not hypothetical either: the client measures against a synced server clock, and a resync between the question opening and the answer landing can produce a small negative. The clamp turns that into "instant", which is the generous reading, and generous is the right default when the alternative is accusing a customer's phone of cheating.
Flat mode exists and is the default for a reason
if (scoringMode === 'flat') return base
Speed scoring is fun in a quiz where everyone is on their own phone. It is actively unfair in a pub quiz where six people share a table and have to agree before anyone taps. The team that discusses is, structurally, the slow team.
So it is a per-session setting, not a product opinion. The general lesson: when a scoring rule encodes an assumption about how people play, make it a switch, because someone's Tuesday night works differently from the way you imagined it.
Ties are a separate decision, and you must make it
Sorting a leaderboard by score descending is not enough. Two teams on 740 points have to be ordered somehow, and "whatever the sort happens to do" is not a policy.
1. total_score descending
2. correct_count descending
3. updated_at ascending
More correct answers beats fewer correct answers at the same total, which rewards consistency over one lucky fast tie-breaker. Then, if still tied, whoever got there first. That last one is doing important work: it is stable. Without a deterministic final key, a leaderboard re-render can reorder two tied teams, and the room sees positions swap for no reason anybody can see. A leaderboard that moves without a cause looks broken even when the numbers are right.
Keep it pure
The whole scoring module has no I/O. No database, no clock, no network. It takes a struct and returns a number, and the calling server action does the writing.
That is what makes the table above testable as a table: for every mode, every base value and every elapsed time, assert the result. It also means the curve can be reasoned about in isolation, which is how you notice that a non-finite basePoints would have poisoned a whole session.
There used to be a second copy of this file mirrored into our WebSocket server. Nothing there imported it. The guarantee it appeared to give was vacuous while the maintenance cost was real, so it was deleted: scoring happens in exactly one place.
Have a go
pub-trivia.app/features/team-scoring covers what this looks like for a host, and the scoring systems guide is the non-technical version of the same argument, written for people running quiz nights rather than people writing decay functions. The free tier runs a real session with no card, if you want to watch the curve pay out.
Top comments (0)