A reaction-time test becomes unfair if it measures how long the screen waited before turning green. I built the test as a small state machine: idle, waiting, ready, result, and early. The timer starts only when the ready state begins, so the result represents the click-to-signal interval rather than the random delay.
Random delay prevents anticipation
Starting a round chooses a delay between 1.2 and 4 seconds:
function startWaiting() {
phase.value = "waiting";
const delay = 1200 + Math.random() * 2800;
clearWait();
waitTimer = setTimeout(() => {
phase.value = "ready";
startTime = performance.now();
}, delay);
}
The random interval makes memorizing a fixed countdown less useful. clearWait runs before scheduling a new timer, which prevents an old round from changing the state after the player has already started another one.
A state machine makes early clicks explicit
The pad handler treats the same click differently depending on the phase:
function onPad() {
switch (phase.value) {
case "idle":
case "result":
case "early":
startWaiting();
break;
case "waiting":
clearWait();
phase.value = "early";
break;
case "ready": {
const ms = Math.round(performance.now() - startTime);
lastMs.value = ms;
attempts.value.push(ms);
phase.value = "result";
break;
}
}
}
An early click cancels the timer and records no fake reaction time. A result click starts the next round. This is simpler to reason about than a collection of booleans such as isStarted, isReady, and hasError, where contradictory combinations are possible.
Use a monotonic clock for elapsed time
The test records performance.now() at the exact transition to ready, then subtracts another performance.now() on the click. That clock is designed for intervals and is not affected by a user changing the wall clock. Rounding to an integer millisecond gives the display a stable unit while preserving the basic measurement.
The computed average is also intentionally transparent:
const avgMs = computed(() => {
if (!attempts.value.length) return null;
const sum = attempts.value.reduce((a, b) => a + b, 0);
return Math.round(sum / attempts.value.length);
});
The best score is saved separately in local storage:
if (bestMs.value === null || ms < bestMs.value) {
bestMs.value = ms;
saveBest();
}
Only the best value is restored on mount; the current attempt history is a session concern. Storage errors and quota failures are ignored so a privacy setting cannot break the game.
The limitations are significant. Browser scheduling, display refresh rate, input devices, touch latency, and human anticipation all affect the result. A four-second random delay is not cryptographic randomness, and this is not a medical or scientific reflex measurement. A fast score on one device is not directly comparable with a score on another.
Cleanup is part of measuring correctly
The component calls onBeforeUnmount(clearWait). That is easy to overlook, but a pending timeout is still a future state transition. If the user navigates away while the test is waiting, the old callback should not wake up and mutate a component that is no longer visible. The same cleanup happens when an early click cancels the round.
The phase names also make the user experience testable. An idle pad can start a round; a waiting pad can only produce an early result; a ready pad records a number; and a result pad starts again. I can test those transitions without relying on the color of the button. In a small game, that is often a better test seam than trying to simulate an entire browser repaint.
performance.now() is intentionally paired with a click event, but event timing still includes the browser and hardware pipeline. A high-refresh monitor may show the signal earlier than a low-refresh one, and touch events usually have different latency from a mouse. The right takeaway is relative practice on one setup, not a universal ranking.
The saved best score has a similarly narrow promise. It stores one integer under bgt_reaction_best_v1, catches storage errors, and restores it on mount. It does not sync across devices or prove that a score was produced honestly. That modest scope keeps the game reliable when storage is blocked and keeps the result’s limitations visible.
The rating thresholds are deliberately simple: under 200 ms is fast, under 300 is good, under 400 is average, and everything else is slow. They are labels for feedback, not a scientific classification. Keeping those boundaries in a computed value means changing the copy or adding localization does not alter how elapsed time is recorded.
In other words, the state machine measures one interval, while the rating only interprets it afterward. Keeping those responsibilities separate makes both pieces easier to explain and replace.
That separation also keeps the game responsive when copy changes, localization changes, or a device reports an unusual input event.
The test also benefits from refusing to manufacture a number after an early click. An early result is a distinct phase with no lastMs update, so the average is based only on completed ready-state attempts. That keeps the summary honest and gives the player a clear way to try again instead of quietly adding a penalty or an impossible zero to the history.
In practice, I would use several attempts rather than treating the best result as a personal measurement. The component exposes the average and best values separately for that reason. One unusually quick click can be interesting, but repeated measurements are a better description of how a particular person and device behave together.
I turned this state-machine experiment into a small free tool: Reaction Time Test.
Top comments (0)