Dev.to WebDev ๐Ÿ›  Dev ๐Ÿ‘ 0 ๐Ÿ“– 5 min read

Every constant in this file is a promise to a player

There is a file in our quiz app called constants.ts. It is fifty eight lines and it holds seven numbers. It is also, in a way that no document in the repo manages, the actual specification of what it feels like to play.

There is a file in our quiz app called constants.ts. It is fifty eight lines and it holds seven numbers. It is also, in a way that no document in the repo manages, the actual specification of what it feels like to play.

I want to walk through it, because every one of those numbers started as a complaint from a real room.

Three seconds of nothing

export const QUESTION_REVEAL_SECONDS = 3
export const QUESTION_REVEAL_MS = QUESTION_REVEAL_SECONDS * 1000

When the host launches a question, it appears on the big screen and on every phone. The answer clock does not start.

For three seconds, the question is just readable. Then the countdown begins.

Without that gap, a time weighted scoring mode measures reading speed. The fastest reader in the room wins, or worse, the person who happens to already be looking at their phone wins over the person who looked up at the screen. Three seconds does not equalise that completely, but it converts "who noticed first" into "who knows the answer", which is the game people came for.

The important part is not the three. It is that the clock which the client renders, the clock which server side scoring uses, and the auto close timer on the WebSocket server all start after the same lead in. If any one of them started at launch instead, a player answering in the first second of the answer window would be scored as though they had taken four seconds.

One second of forgiveness

export const CLOSE_GRACE_SECONDS = 1

The server owns the deadline. It arms a single timeout for reveal + limit + grace and auto closes the question when it fires.

The grace exists because a player tapping their answer at the buzzer is not the same event as the server receiving it. There is a phone touch handler, a WebSocket frame, a venue WiFi access point with ninety other devices on it, and a few hundred milliseconds of transit. Without the grace, the honest experience of a player who answered in time is a rejection, and they will tell you about it.

One second is enough for that and short enough that it is not exploitable. Nobody is going to build a bot to gain one extra second in a pub quiz.

The floor is as hard as the ceiling

export const MIN_TIME_LIMIT_SECONDS = 5
export const MAX_TIME_LIMIT_SECONDS = 15
export const DEFAULT_TIME_LIMIT_SECONDS = 15

The ceiling is a product decision. The server arms one setTimeout for the entire window, and a ninety second question makes for a room where everybody is looking at their phone in silence. Fifteen seconds keeps a quiz moving.

The floor is a correctness decision. A zero or negative time limit arms a timer that fires immediately, so the question opens and closes in the same tick, and every player sees a question that was never answerable. That is why the minimum is enforced as strictly as the maximum, which is not the usual instinct with validation ranges.

Both are clamped on the server, not just in the form:

// The form in PackBuilder.tsx and the server actions that write questions
// both read these. HTML min/max attributes are advisory, because a Server
// Action is a directly-POSTable endpoint, so the server clamps to the same
// numbers rather than trusting the form.

A Next.js Server Action is an HTTP endpoint with a generated identifier. It is not a private channel between your form and your database. If the only thing stopping a timeLimitSeconds of -1 is a min attribute in JSX, then nothing is stopping it.

Points are a range, not a constant

export const MIN_QUESTION_POINTS = 10
export const MAX_QUESTION_POINTS = 1000
export const DEFAULT_QUESTION_POINTS = 100

We started with every question worth the same, which is what almost every quiz app does, and it is wrong for how pub quizzes actually work. Real quizzes have a warm up round and a tie breaker, and a quizmaster who cannot make the tie breaker matter more will do it with their voice instead and hope people notice.

A hundred times range means a five hundred point tie breaker really is worth five easy questions. And crucially it works in both scoring modes, because the decay multiplies the question's own value rather than a global constant.

The floor of the curve is not the number in the file

This is my favourite one, because the constant is quietly lying and the comment tells you so.

/**
 * The floor of the time-weighted decay, as a fraction of the question's points.
 */
export const MIN_TIME_POINT_RATIO = 0.1

The scoring 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)))

The curve is R + (1 - R)ยทe^(-kt) with R = 0.1. Its asymptote is ten percent. But t is clamped to T, and k is chosen as ln(9)/T so that at t = T the exponential term is exactly 1/9. Work it through: 0.1 + 0.9/9 = 0.2.

So the real minimum a correct answer can earn is twenty percent of the question's value, not ten. The asymptote is never reached because the clamp gets there first, and k was picked to make the value at the clamp a round number.

For a hundred point question on a fifteen second window:

Answered at Points
0s 100
1s 88
3s 68
5s 53
8s 38
10s 31
15s 20

The shape is the argument. It falls steeply early, so a fast answer is genuinely worth defending. It flattens late, so a player who took twelve seconds is not robbed of another six points because their packet took an extra moment to arrive. And it never reaches zero, because a slow correct answer must always beat a wrong one. That last property is not an accident of the curve, it is the reason there is a floor at all.

Why they live in one file

Two of these numbers are needed by a separate process. The WebSocket server is standalone and cannot import from the Next.js package, so it keeps its own copy of the two timing constants it needs for the auto close timer, and a test reads both files and asserts the values still agree. The mirror is allowed to exist, but it is not allowed to drift silently.

That test is the only reason I trust the mirror. A comment saying "keep these in sync" is a wish.

Feel it rather than read about it

Numbers in a table are not a feeling. The question timer page walks through the same sequence from the player's side, including the section called "The buzzer, and the second after it", which is the grace period written for somebody who does not care what a setTimeout is.

Better: start a free session at pub-trivia.app, no card needed, switch the scoring mode to time weighted, and answer one question at about one second and the next at about fourteen. The gap between 88 and 20 is the whole design argument, and it is more persuasive on a scoreboard than in a paragraph.

๐Ÿ“ฐ Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes โ€” full credit and traffic to the original publisher.