Reaction time is the oldest lab test there is — the gap between a signal appearing and your body responding. I built a full five-round tester with a running average, a best time, a false-start penalty and a little bar chart of every attempt, and the whole thing came to about 90 lines of vanilla JavaScript: no sprite sheet, no game engine, one coloured div, one random timer and a high-resolution stopwatch. Here is how it fits together.
The whole game is five states
There are no objects and no animation loop. The entire game is one string naming which of five moments we are in, plus a handful of numbers. idle waits for your first click, wait is the red pause, go is the green flash, result shows a round's time, and done is the summary after five rounds. Every function below just carries the game from one state to the next.
const TOTAL = 5; // rounds in one series
const MIN_WAIT = 1500; // shortest red delay (ms)
const MAX_WAIT = 4500; // longest red delay (ms)
let phase; // "idle" | "wait" | "go" | "result" | "done"
let times = []; // reaction time of each round (ms)
let falseStarts; // count of too-early taps
let startTime; // performance.now() at the green flip
let timer; // setTimeout handle for red -> green
performance.now(), not Date — a real stopwatch
The instinct is to subtract two Date.now() readings. Don't. Date.now() tracks wall-clock time, which the OS can nudge forward or backward when it syncs to a time server or crosses a daylight-saving boundary, and it only resolves to whole milliseconds. performance.now() is a monotonic clock — it starts at page load and only ever counts up, in fractions of a millisecond, immune to any adjustment. The difference between two readings is a clean, trustworthy elapsed time, which is exactly what you want when the answer is a couple of hundred milliseconds.
// Date.now() → wall clock, whole ms, can jump backward ✗
// performance.now() → monotonic, sub-ms, never jumps ✓
const t0 = performance.now();
// ... something happens ...
const elapsedMs = performance.now() - t0;
Arm a round behind a random delay, then stamp time zero
Starting a round paints the pad red and schedules the green flip for some moment in the near future. The delay is random — a uniform pick between 1.5 and 4.5 seconds — and that unpredictability is the whole point: if it were fixed you would learn the rhythm and tap on a count instead of on the signal. I keep the setTimeout handle so an early tap can cancel it.
function arm(){
phase = "wait";
const delay = MIN_WAIT + Math.random() * (MAX_WAIT - MIN_WAIT);
timer = setTimeout(goGreen, delay); // flip, someday soon
}
function goGreen(){
phase = "go";
startTime = performance.now(); // ← time zero: the exact flip
}
Reading the stopwatch inside the same callback that flips the colour keeps it honest: the clock starts the moment the eye could first see green.
The measurement is one subtraction
The payoff is a single line. The instant the player taps, read the stopwatch again, subtract the startTime I stored at the flip, round to a whole millisecond, and push it onto the list. Five rounds in, the series is done.
function tap(){
const ms = Math.round(performance.now() - startTime); // the reaction!
times.push(ms);
if (times.length >= TOTAL){ phase = "done"; finish(); }
else { phase = "result"; }
}
Punish the early tap
Without a guard you could just hammer the pad and land on green. So a tap during the red wait phase is a false start: cancel the pending green flip so it can't fire late, count the false start, and drop back to idle so the player retries the same round. Crucially nothing is pushed to times — an early tap is never recorded as a fast time. It is exactly the jump-the-gun rule an athletics starter enforces.
function tooSoon(){
clearTimeout(timer); // kill the pending green flip
falseStarts++;
phase = "idle"; // retry this round — nothing recorded
}
One handler routes every state
A tap means something different in each phase, so one router reads phase and dispatches. That five-way branch is the game loop — there is no ticking update, the game only advances when you tap.
function handleTap(){
if (phase === "idle") arm(); // start a round
else if (phase === "wait") tooSoon(); // red → false start
else if (phase === "go") tap(); // green → measure
else if (phase === "result") arm(); // next round
else if (phase === "done") reset(); // new series
}
Because every round lives in one array, the summary stats are one-liners: Math.min(...times) is the best, the sum over the count is the average, and a fixed 600 ms scale turns each time into a coloured bar. A typical human sits around 200–270 ms; break 250 and you're quick.
Play it (Space and Enter also tap):
https://dev48v.infy.uk/game/day41-reaction-time.html
Top comments (0)