DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

A reaction-time tester in about 90 lines: performance.now(), a random green flip, and a false-start guard

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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"; }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)