Whack A Mole
by Pratik Rai
Read the full write-upBuild whack-a-mole. Press Start and a mole appears in a random hole, moving every 800ms. Hit it to score. The round lasts 20 seconds.
Requirements
Start begins a round: score back to zero, timer back to 20, mole moving.
The mole jumps to a random hole every 800ms while the round is running.
Clicking the hole with the mole scores a point; clicking an empty hole does nothing.
The same mole cannot be scored twice — a second click before it moves is worth nothing.
The timer counts down once a second and the round ends at zero, with the mole gone and Start available again.
Every timer is cleared when the round ends or the component unmounts.
Notes
Difficulty ramps, combos and a high score are natural follow-ups and are out of scope.
The
.hole-upclass already styles a hole with a mole in it.
Hints
Whack A Mole (reference solution)
One way to do it, not the only way. Yours passes if it renders.
Solution files
1import { useState, useEffect, useRef } from 'react';
2
3const HOLES = 9;
4const ROUND_MS = 20000;
5const MOLE_MS = 800;
6
7export default function App() {
8 const [running, setRunning] = useState(false);
9 const [score, setScore] = useState(0);
10 const [timeLeft, setTimeLeft] = useState(ROUND_MS / 1000);
11 const [mole, setMole] = useState(null);
12 // A hit is recorded outside state so the guard is exact: two clicks in the
13 // same tick both see the old state value, but both see this ref.
14 const hitRef = useRef(false);
15
16 // Move the mole. Each interval is created when the round starts and cleared
17 // when it stops — without the cleanup, pressing Start twice would leave two
18 // intervals fighting over the same mole.
19 useEffect(() => {
20 if (!running) return undefined;
21 const timer = setInterval(() => {
22 hitRef.current = false;
23 setMole(Math.floor(Math.random() * HOLES));
24 }, MOLE_MS);
25 return () => clearInterval(timer);
26 }, [running]);
27
28 // Count the round down.
29 useEffect(() => {
30 if (!running) return undefined;
31 const timer = setInterval(() => {
32 setTimeLeft((seconds) => {
33 if (seconds <= 1) {
34 setRunning(false);
35 setMole(null);
36 return 0;
37 }
38 return seconds - 1;
39 });
40 }, 1000);
41 return () => clearInterval(timer);
42 }, [running]);
43
44 const whack = (index) => {
45 if (!running || index !== mole || hitRef.current) return;
46 hitRef.current = true;
47 setScore((n) => n + 1);
48 setMole(null);
49 };
50
51 const start = () => {
52 setScore(0);
53 setTimeLeft(ROUND_MS / 1000);
54 hitRef.current = false;
55 setRunning(true);
56 };
57
58 const finished = !running && timeLeft === 0;
59
60 return (
61 <div>
62 <h1>Whack a mole</h1>
63
64 <div className="hud">
65 <span>Score: <strong>{score}</strong></span>
66 <span>Time: <strong>{timeLeft}s</strong></span>
67 <button className="start" onClick={start} disabled={running}>
68 {running ? 'Running' : 'Start'}
69 </button>
70 </div>
71
72 <div className="grid">
73 {Array.from({ length: HOLES }, (_, index) => (
74 <button
75 key={index}
76 className={'hole' + (mole === index ? ' hole-up' : '')}
77 onClick={() => whack(index)}
78 aria-label={'Hole ' + (index + 1) + (mole === index ? ', mole up' : '')}
79 >
80 {mole === index ? '🐹' : ''}
81 </button>
82 ))}
83 </div>
84
85 {finished && (
86 <p className="over" role="status">Time. You scored {score}.</p>
87 )}
88 </div>
89 );
90}How it works
This problem is really about intervals and stale closures, and interviewers know it.
An interval callback closes over the state from the render that created it. Read timeLeft inside it and you read the same number forever; the counter freezes at 19 and never reaches zero. The updater form asks React for the current value instead of remembering an old one, which is why the countdown is written setTimeLeft(s => ...).
The cleanups matter for the same reason the modal needed one. Every effect that starts an interval must return the clearInterval, or a round that ends leaves its timers running against a component that has moved on.
The hitRef is the one place a ref beats state. Two rapid clicks on the same mole are processed before React re-renders, so both see mole unchanged and both score. A ref is written and read synchronously, so the second click sees the first one immediately.