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
- Player clicks the zone to start a round
- The screen turns red — player must wait
- After a random delay (1.5 – 4 seconds), the screen turns green
- Player clicks as fast as possible — their reaction time is recorded in ms
- 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
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; }
};
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);
}
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);
}
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
setTimeoutso 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';
}
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' : '—';
}
'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
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);
}
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.' };
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); }
}
From JS, changing state is just:
zone.className = 'waiting'; // or 'ready', 'early', ''
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');
});
}
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; }
}
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 () { /* ... */ };
})();
Key takeaways
-
State machine over booleans — one
statestring beats a tangle ofisWaiting,isReady,hasFiredflags -
performance.now()overDate.now()— higher precision, unaffected by clock drift -
clearTimeouton 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)