DEV Community

Cover image for Building a Reaction Time Test in JavaScript
Krishna
Krishna

Posted on

Building a Reaction Time Test in JavaScript

How I built a browser-based reaction time test with multi-round scoring, audio feedback, and localStorage — all in plain JS.


I recently shipped a reaction time test — the classic "click when the screen turns green" game. It runs entirely in the browser with zero dependencies. In this post I'll walk through how it works, what made it interesting to build, and the exact patterns I used.


How the game works

  1. Player clicks the zone to start a round
  2. The screen turns red — player must wait
  3. After a random delay (1.5 – 4 seconds), the screen turns green
  4. Player clicks as fast as possible — their reaction time is recorded in ms
  5. After 5 rounds, a final results panel shows average, best, worst, and a percentile rating

If the player clicks while waiting (before green), they get an "early click" penalty and must retry.


The state machine

Everything hinges on a single state variable. This keeps conditional logic clean and prevents weird edge cases.

let state = 'idle'; // idle | waiting | ready | done
Enter fullscreen mode Exit fullscreen mode

The click handler just delegates to the right function based on current state:

window.handleClick = function () {
  if (state === 'idle')    { startRound();      return; }
  if (state === 'waiting') { earlyClick();      return; }
  if (state === 'ready')   { recordReaction();  return; }
  if (state === 'done')    { resetGame();       return; }
};
Enter fullscreen mode Exit fullscreen mode

This pattern keeps each phase completely isolated. Adding a new state is one function and one if line.


Randomising the delay

The whole game breaks if the delay is predictable. A fixed delay lets players time it by muscle memory rather than actually reacting to the visual.

function startRound() {
  state = 'waiting';
  const delay = 1500 + Math.random() * 2500; // 1.5s – 4s
  goTimer = setTimeout(showGreen, delay);
}
Enter fullscreen mode Exit fullscreen mode

Math.random() gives a float in [0, 1), so the wait is anywhere from 1.5 to 4 seconds. Users can never predict it.


Measuring reaction time accurately

Date.now() is fine for most things, but performance.now() gives sub-millisecond precision and isn't affected by system clock changes mid-game. Always use it for timing.

function showGreen() {
  state = 'ready';
  startTime = performance.now(); // mark the moment green appears
  zone.className = 'ready';
  zText.textContent = 'CLICK NOW!';
}

function recordReaction() {
  const rt = Math.round(performance.now() - startTime); // delta in ms
  scores.push(rt);
}
Enter fullscreen mode Exit fullscreen mode

Math.round() keeps results as clean integers — nobody needs to know they reacted in 247.3821 ms.


Handling the early click

If the player clicks while the screen is still red (state === 'waiting'), you need to:

  • Cancel the pending setTimeout so green never fires
  • Show a warning
  • Let them retry the same round (don't advance the round counter)
function earlyClick() {
  clearTimeout(goTimer); // cancel the green trigger
  state = 'idle';        // allow retry
  zone.className = 'early';
  zText.textContent = 'Too Early! 😬';
  zSub.textContent = 'Click to retry this round';
}
Enter fullscreen mode Exit fullscreen mode

Forgetting clearTimeout is the classic bug here — the green would still fire after the player is already looking at the early-click screen.


Persisting the all-time best with localStorage

Round best is easy (just Math.min(...scores)), but an all-time personal best needs to survive page refreshes.

const STORAGE_KEY = 'rt_alltime_best';
let alltimeBest = parseInt(localStorage.getItem(STORAGE_KEY) || '99999');

function recordReaction() {
  const rt = Math.round(performance.now() - startTime);
  scores.push(rt);

  if (rt < alltimeBest) {
    alltimeBest = rt;
    localStorage.setItem(STORAGE_KEY, rt);
    renderBestBadge();
  }
}

function renderBestBadge() {
  bestBadge.textContent = alltimeBest < 99999 ? alltimeBest + ' ms' : '';
}
Enter fullscreen mode Exit fullscreen mode

'99999' is the sentinel "no score yet" value. Any real reaction time will beat it on the first attempt.


Audio feedback with the Web Audio API

Adding a short beep on "go" and "success" makes the experience feel snappier without importing a sound library.

function beep(frequency, duration, volume) {
  try {
    const ctx = new (window.AudioContext || window.webkitAudioContext)();
    const osc = ctx.createOscillator();
    const gain = ctx.createGain();

    osc.connect(gain);
    gain.connect(ctx.destination);
    osc.frequency.value = frequency;
    gain.gain.value = volume || 0.15;

    osc.start();
    osc.stop(ctx.currentTime + duration);
    // fade out to avoid click artifacts
    gain.gain.setTargetAtTime(0, ctx.currentTime + duration * 0.7, 0.05);
  } catch (e) {
    // silently fail if AudioContext isn't supported
  }
}

// Usage
beep(880, 0.2, 0.2); // high beep on green
beep(660, 0.12, 0.15); // lighter beep on recording
beep(220, 0.3, 0.15); // low beep on early click
Enter fullscreen mode Exit fullscreen mode

The try/catch is important — mobile Safari and some older browsers can throw on AudioContext creation. Never let audio break your game logic.


Computing the final stats

After 5 rounds:

function showFinal() {
  const avg   = Math.round(scores.reduce((a, b) => a + b, 0) / scores.length);
  const best  = Math.min(...scores);
  const worst = Math.max(...scores);
}
Enter fullscreen mode Exit fullscreen mode

For the rating, I mapped averages to rough human percentile brackets based on published reaction time research:

let rating;
if      (avg < 200) rating = { emoji: '🏆', title: 'Elite Reflexes!',   sub: 'Top 1% globally.' };
else if (avg < 230) rating = { emoji: '', title: 'Exceptional',        sub: 'Above 95th percentile.' };
else if (avg < 260) rating = { emoji: '', title: 'Above Average',      sub: 'Better than most.' };
else if (avg < 300) rating = { emoji: '👍', title: 'Average',            sub: 'Around the human average of 250 ms.' };
else                rating = { emoji: '🔄', title: 'Keep Practising',    sub: 'Warm up and try again.' };
Enter fullscreen mode Exit fullscreen mode

The human average for a simple visual reaction test is roughly 200–250 ms. Anything under 200 ms is genuinely elite.


Visual state feedback with CSS classes

Instead of toggling individual style properties from JS, the zone element gets a single class that drives all visual changes:

#react-zone                { background: var(--c-surface); border: 2px solid var(--c-border); }
#react-zone.waiting        { background: linear-gradient(135deg, #1a0a0a, #2a0505); border-color: rgba(255, 50, 50, .3); }
#react-zone.ready          { background: linear-gradient(135deg, #0a2a10, #0a4020); border-color: rgba(0, 255, 100, .5); animation: pulse-green .6s ease-out; }
#react-zone.early          { background: linear-gradient(135deg, #2a1a00, #3a2a00); border-color: rgba(255, 200, 0, .5); }

@keyframes pulse-green {
  0%   { box-shadow: 0 0 0 0   rgba(0, 255, 100, .6); }
  100% { box-shadow: 0 0 0 40px rgba(0, 255, 100, 0); }
}
Enter fullscreen mode Exit fullscreen mode

From JS, changing state is just:

zone.className = 'waiting'; // or 'ready', 'early', ''
Enter fullscreen mode Exit fullscreen mode

One assignment controls background, border, and animation all at once.


Round progress dots

Five dots track which rounds are done vs. active:

const dots = Array.from({ length: ROUNDS }, (_, i) => document.getElementById('dot' + i));

function updateDots() {
  dots.forEach((dot, i) => {
    dot.classList.remove('active', 'done');
    if (i < currentRound)      dot.classList.add('done');
    else if (i === currentRound) dot.classList.add('active');
  });
}
Enter fullscreen mode Exit fullscreen mode

The CSS for the active dot uses a blink animation so the player always knows which round they're on:

.round-dot.active {
  border-color: var(--c-glow);
  animation: blink .8s infinite;
}
@keyframes blink {
  0%, 100% { opacity: 1; }
  50%       { opacity: .4; }
}
Enter fullscreen mode Exit fullscreen mode

Wrapping it in an IIFE

The whole script lives inside an immediately invoked function expression to avoid polluting the global scope (except for handleClick, which the HTML needs):

(function () {
  'use strict';

  // all state, DOM refs, and functions live here

  window.handleClick = function () { /* ... */ };
})();
Enter fullscreen mode Exit fullscreen mode

Key takeaways

  • State machine over booleans — one state string beats a tangle of isWaiting, isReady, hasFired flags
  • performance.now() over Date.now() — higher precision, unaffected by clock drift
  • clearTimeout on early click — easy to forget, hard to debug if you do
  • CSS classes over inline styles — let CSS own the visual states, JS just sets the class
  • Try/catch AudioContext — audio should enhance, never break

You can try the live version at Reaction Test.

If you have questions or spot something I could improve, drop a comment below!

Top comments (0)