Reaction Speed Game in JavaScript

Build a browser game while hitting and fixing JS's trickiest bugs.

Introduction

30 Second Summary

Every JavaScript developer eventually runs into the same three bugs. A variable that mysteriously changes value inside a loop. A this keyword that points to the wrong thing. A number that turns into a string when you're not looking.

In this project, you will build a reaction speed game from scratch while deliberately hitting each of these bugs firsthand. You will see them break your game, understand why they happen, and fix them with the same techniques professional developers use every day.

What You'll Build

A playable browser game where colorful targets pop up at random positions on a game board, and you race to click them before they vanish while a 30-second countdown timer ticks toward zero.

By the end of this project, you'll have:

  • A working reaction speed game you can play in any browser, where colored circles spawn at random positions and vanish after 1.5 seconds. Click them to score.
  • First-hand experience diagnosing and fixing JavaScript's three most common bugs: closures in loops, lost this binding in callbacks, and type coercion with strings and numbers.
  • A persistent high score powered by localStorage that survives browser restarts, proving you can manage state across sessions.
  • Secret Mission: Add difficulty levels (Easy, Medium, Hard) that change spawn speed and target lifetime, with separate high scores per level.

Are there any prerequisites?

You should be comfortable with basic JavaScript syntax: variables, functions, if/else, and arrays. You also need VS Code installed on your machine.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters. This step is your moment to commit to the goal before any code hits the screen.

Set Up Your Project Files

Every browser game starts with three files working together. HTML defines the structure you see. CSS controls how it looks. JavaScript makes it interactive.

In this step, you'll create the project folder, write the HTML skeleton that holds the game's scoreboard, timer, and game board, then launch it in your browser with Live Server.

In this step, get ready to:
  • Create the reaction-game project folder with HTML, CSS, and JS files.
  • Write the HTML game container structure.
  • Launch the page with Live Server to see it in your browser.
Create the project folder and files

You need a dedicated folder on your Desktop so you can find your game project easily.

  • In VS Code, open the integrated terminal by pressing Ctrl+` (backtick key, usually above Tab).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir reaction-game
cd reaction-game
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the reaction-game folder, and click Open.

VS Code reloads with reaction-game shown at the top of the Explorer sidebar.

  • In the Explorer sidebar on the left, hover over the REACTION-GAME heading and click the New File icon (a page with a plus sign).
  • Type index.html and press Enter.
  • Create a second file the same way and name it style.css.
  • Create a third file and name it script.js.

Your Explorer sidebar now shows all three files: index.html, style.css, and script.js.

Don't see the New File icon?

Make sure you're hovering over the folder name heading in the Explorer sidebar (the file tree panel on the left), not in the editor area. If the sidebar is collapsed, click the top file icon in VS Code's activity bar to expand it.

Help me find the New File icon in VS Code's Explorer sidebar.

Write the HTML game structure

The HTML file sets up the game's visual skeleton. It defines the scoreboard, timer, game board, game-over screen, and start button that your JavaScript will control later.

  • Click index.html in the Explorer sidebar to open it in the editor.
  • Paste the following code into the file:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Reaction Speed Game</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <div id="game-container">
        <div id="header">
            <span id="score-display">Score: 0</span>
            <span id="timer-display">Time: 30s</span>
        </div>
        <div id="game-board"></div>
        <div id="game-over" class="hidden">
            <h2>Game Over!</h2>
            <p id="final-score"></p>
            <p id="high-score"></p>
        </div>
        <button id="start-btn">Start Game</button>
    </div>
    <script src="script.js"></script>
</body>
</html>

What does this code do?

  • <link rel="stylesheet" href="style.css"> connects your CSS file so styles apply to the page.
  • <div id="game-board"> is the empty area where clickable targets will spawn later.
  • <div id="game-over" class="hidden"> is pre-built but invisible until the game ends. The hidden class keeps it out of sight.
  • <script src="script.js"> loads your JavaScript at the bottom so all HTML elements exist before your code runs.
  • Save index.html by pressing Ctrl+S (Windows) or Cmd+S (macOS).

✔️ Awesome, I've got everything!

Great. Make sure you've saved index.html before moving on.

ⓧ I'd like to double check the full code

Your index.html should look exactly like this:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Reaction Speed Game</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <div id="game-container">
        <div id="header">
            <span id="score-display">Score: 0</span>
            <span id="timer-display">Time: 30s</span>
        </div>
        <div id="game-board"></div>
        <div id="game-over" class="hidden">
            <h2>Game Over!</h2>
            <p id="final-score"></p>
            <p id="high-score"></p>
        </div>
        <button id="start-btn">Start Game</button>
    </div>
    <script src="script.js"></script>
</body>
</html>

Your style.css and script.js files should be empty for now.

Launch the page with Live Server

Live Server runs a local web server and auto-refreshes your browser whenever you save a file. This gives you instant feedback as you build.

  • In the Explorer sidebar, right-click index.html.
  • Select Open with Live Server.

Before you look at the browser, what do you expect to see on the page given the HTML you just wrote?

Your browser opens showing "Score: 0" and "Time: 30s" near the top, a "Game Over!" heading in the middle, and a "Start Game" button below. The background is plain white for now because style.css is still empty.

Why is the page unstyled?

You linked style.css in the HTML, but the file is empty. Without any CSS rules, the browser shows the default white background and serif-font text. You will add styling in the next step.

  • Test auto-refresh by opening index.html in the editor and changing the button text from Start Game to Start Game!.
  • Save the file.
  • Confirm your browser updates the button text instantly without a manual refresh.
  • Change the text back to Start Game and save again.

Browser didn't open automatically?

  • Check the VS Code status bar at the bottom. If Live Server is running, you'll see "Port: 5500" (or similar). Click it to open the browser manually.
  • If you don't see a port number, right-click index.html again and make sure Open with Live Server appears in the context menu. If it does not, the Live Server extension may not be installed.

Help me get Live Server working in VS Code.

Your project structure is ready and Live Server is watching for changes. Next up, you'll add CSS styling and write JavaScript to spawn your first clickable target on the game board.

Spawn Your First Clickable Target

You have a page structure, but it does nothing yet. Before building the full game loop, you need proof that the core mechanic works: a colored circle appears, and clicking it makes it vanish.

In this step, you'll style the game board into a visible play area and write the JavaScript that spawns a clickable target on demand.

In this step, get ready to:
  • Style the game board and target elements with CSS.
  • Write a function that creates and positions colored circles.
  • Wire the Start button and test the click-to-remove mechanic.
Style the page layout

Your page currently has raw, unstyled HTML. You need a dark background, centered content, and a header that separates the score from the timer.

  • Open style.css in VS Code and add the base layout styles:
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    font-family: Arial, sans-serif;
    background-color: #1a1a2e;
    color: #ffffff;
    display: flex;
    justify-content: center;
    align-items: center;
    min-height: 100vh;
}

#game-container {
    text-align: center;
    width: 500px;
}

#header {
    display: flex;
    justify-content: space-between;
    padding: 10px 20px;
    font-size: 1.2rem;
    margin-bottom: 10px;
}

What does this code do?

The universal selector * resets all margins and padding so elements render consistently across browsers.

The body uses flexbox to center the game container vertically and horizontally on a dark navy background. The header uses justify-content: space-between to push the score to the left and the timer to the right.

  • Save style.css.
  • Check your browser. You should see a dark navy background with "Score: 0" on the left and "Time: 30s" on the right, both centered on the page.

Page still looks unstyled?

  • Confirm you saved style.css. Live Server only reloads on save.
  • Check that your HTML <link> tag references style.css (not styles.css or another name).

Why isn't my CSS loading in the browser?

Style the game board and targets

The game board is the 500x500 pixel area where targets will appear. Targets are 60px colored circles that pop in with an animation.

  • Add the game board and target styles below your existing CSS in style.css:
#game-board {
    width: 500px;
    height: 500px;
    background-color: #16213e;
    border-radius: 10px;
    position: relative;
    overflow: hidden;
    border: 2px solid #0f3460;
}

.target {
    width: 60px;
    height: 60px;
    border-radius: 50%;
    position: absolute;
    cursor: pointer;
    animation: pop-in 0.3s ease-out;
}

@keyframes pop-in {
    0% { transform: scale(0); }
    100% { transform: scale(1); }
}

What does this code do?

  • The game board uses position: relative so that targets inside it can be placed with position: absolute at exact pixel coordinates.
  • overflow: hidden clips any target that spawns near the edge, keeping it inside the board.
  • The pop-in keyframe animation scales each target from 0 to full size over 0.3 seconds, giving it a satisfying "pop" effect.
  • Save style.css and check your browser.
  • You should see a large dark rounded rectangle below the header. This is your game board.

Don't see the game board?

  • Make sure id="game-board" in your HTML matches #game-board in your CSS exactly.
  • Confirm your CSS is below the previous block (not inside it). A missing closing brace } can swallow all the rules that follow.

Help me debug why my game board isn't showing up.

Style the button and game-over screen
  • Add the remaining styles for the Start button, game-over screen, and the hidden utility class below your existing CSS in style.css:
#start-btn {
    margin-top: 20px;
    padding: 12px 30px;
    font-size: 1.1rem;
    background-color: #e94560;
    color: #ffffff;
    border: none;
    border-radius: 5px;
    cursor: pointer;
}

#start-btn:hover {
    background-color: #ff6b6b;
}

#game-over {
    padding: 30px;
}

#game-over h2 {
    margin-bottom: 15px;
    font-size: 1.8rem;
    color: #e94560;
}

#game-over p {
    margin-bottom: 10px;
    font-size: 1.2rem;
}

.hidden {
    display: none;
}

What does this code do?

  • The Start button gets a bold red background that lightens on hover, making it feel interactive.
  • The .hidden class sets display: none. You'll toggle this class with JavaScript to show and hide elements like the game-over screen.
  • Save style.css and check your browser.
  • The "Start Game" button is now a red rounded pill shape. The "Game Over!" text that was previously visible has disappeared because the .hidden class now hides it.

Button still looks plain?

  • Check that #start-btn in your CSS matches id="start-btn" in your HTML.
  • Make sure there is no missing closing brace from the previous selector block.

Why isn't my button picking up its CSS styles?

Write the target-spawning function

Now for the JavaScript. You need a function that creates a colored circle, places it at a random position inside the game board, and removes it when the player clicks it.

  • Open script.js and add the following code:
// Five vibrant colors targets can randomly receive
const colors = ['#e94560', '#ff6b6b', '#ffd93d', '#6bcb77', '#4d96ff'];

function createTarget() {
    const board = document.getElementById('game-board');
    const target = document.createElement('div');
    target.classList.add('target');

    // Random position within 420px (500 board - 60 target - 20 padding)
    const x = Math.floor(Math.random() * 420);
    const y = Math.floor(Math.random() * 420);
    const colorIndex = Math.floor(Math.random() * colors.length);

    target.style.left = x + 'px';
    target.style.top = y + 'px';
    target.style.backgroundColor = colors[colorIndex];

    // Remove the target when the player clicks it
    target.addEventListener('click', () => {
        target.remove();
    });

    board.appendChild(target);
}

// Spawn one target each time the player clicks Start
document.getElementById('start-btn').addEventListener('click', () => {
    createTarget();
});

What does this code do?

  • colors is an array of five hex color strings. Each target gets a random pick from this palette.
  • createTarget() builds a new <div>, adds the target class (which your CSS styles as a 60px circle with the pop animation), picks a random x/y position within 420px bounds, and assigns a random background color.
  • The click listener calls target.remove() to instantly delete the element from the DOM when clicked.
  • The last three lines attach an event listener to the Start button. Each click calls createTarget() to spawn one circle.

Why 420 and not 500?

The board is 500px wide, and each target is 60px. If a target's left edge spawns at 500px, it overflows off the right side. Using 420 (500 minus 60 minus a small buffer) keeps every target fully visible inside the board.

Before you test this, what do you expect to happen when you click the "Start Game" button?

  • Save script.js and switch to your browser.
  • Click the **Start Game** button.

A colored circle pops onto the game board with a quick scale animation. Click the circle and it disappears instantly. Click Start Game a few more times to see different colors and positions each time.

No circle appears when you click Start?

  • Open DevTools (F12), go to the Console tab, and look for errors. A typo in getElementById or a missing closing parenthesis will show here.
  • Make sure your HTML has <script src="script.js"></script> at the bottom of the body. If it loads before the DOM, the button listener will fail silently.
  • If a circle appears but is invisible, check that .target in your CSS has width: 60px and height: 60px. Without dimensions, the div collapses to 0x0.

Help me debug why my target isn't appearing on the game board.

✔️ Awesome, I've got everything!

Great. Double check you've saved both style.css and script.js.

ⓧ I'd like to double check the full code

Here is the full script.js so far:

const colors = ['#e94560', '#ff6b6b', '#ffd93d', '#6bcb77', '#4d96ff'];

function createTarget() {
    const board = document.getElementById('game-board');
    const target = document.createElement('div');
    target.classList.add('target');

    const x = Math.floor(Math.random() * 420);
    const y = Math.floor(Math.random() * 420);
    const colorIndex = Math.floor(Math.random() * colors.length);

    target.style.left = x + 'px';
    target.style.top = y + 'px';
    target.style.backgroundColor = colors[colorIndex];

    target.addEventListener('click', () => {
        target.remove();
    });

    board.appendChild(target);
}

document.getElementById('start-btn').addEventListener('click', () => {
    createTarget();
});

For style.css, scroll up and confirm you have all three CSS blocks in order: the page layout (reset, body, container, header), the game board and target styles, and the button/game-over/hidden styles.

Your core mechanic is proven. One target spawns on demand, you click it, and it vanishes. Next up, you'll make targets spawn automatically on a timer and run straight into one of JavaScript's most infamous bugs.

Add Timed Spawning and Hit the Closure Bug

Your game board can spawn one target per click. That proves the core mechanic works, but one target at a time is not a game.

In this step, you will make targets spawn automatically on a timer. Your first attempt will look perfectly logical, but it hides one of the most common bugs in all of JavaScript. You will hit it, see it break, and fix it.

In this step, get ready to:
  • Spawn targets on a timer using a for loop and hit the closure bug.
  • Fix the bug by changing one keyword.
  • Build a continuous spawning pattern that keeps targets coming until the game ends.
Write a timed spawning loop with var

The plan is simple: use a for loop to spawn 5 targets, each delayed by 1 second using setTimeout. Inside each callback, you pick a color from the array using the loop index.

  • Replace the entire contents of script.js with this code:
const colors = ['#e94560', '#ff6b6b', '#ffd93d', '#6bcb77', '#4d96ff'];

document.getElementById('start-btn').addEventListener('click', () => {
    for (var i = 0; i < 5; i++) {
        setTimeout(function() {
            const board = document.getElementById('game-board');
            const target = document.createElement('div');
            target.classList.add('target');

            const x = Math.floor(Math.random() * 420);
            const y = Math.floor(Math.random() * 420);

            target.style.left = x + 'px';
            target.style.top = y + 'px';
            // Use loop index to pick a color
            target.style.backgroundColor = colors[i];

            target.addEventListener('click', () => {
                target.remove();
            });

            board.appendChild(target);
        }, i * 1000);
    }
});

What does this code do?

The loop runs 5 times. Each iteration schedules a setTimeout callback to fire after i * 1000 milliseconds (0s, 1s, 2s, 3s, 4s).

Inside each callback, a target is created and its color is set to colors[i]. This looks perfectly reasonable.

Before you run this, make a prediction: will each target appear with a different color from the array?

  • Save script.js and click Start Game in your browser.

You will see 5 targets appear at 1-second intervals, but they are all invisible. No colors show up. The targets exist in the DOM (you can see them with DevTools), but their background color is undefined.

This is the closure bug in action

The for loop uses var i. In JavaScript, var is function-scoped, not block-scoped. That means there is only ONE i variable shared across all 5 callbacks.

The loop finishes instantly (it just schedules the timeouts). By the time the first callback actually runs 1 second later, i is already 5. Every callback reads colors[5], which is undefined.

Still seeing colored targets?

Make sure you used var i in the for loop (not let i). The bug only appears with var. Also confirm you are using colors[i] inside the setTimeout callback, not a randomly selected index.

Help me understand why my targets still have colors with var.

Fix the closure bug with let

The fix is exactly one word. The keyword let creates a new binding for each iteration of the loop, so each callback captures its own copy of i.

  • In script.js, find this line in the for loop:
    for (var i = 0; i < 5; i++) {
  • Change var to let, so it now reads:
    for (let i = 0; i < 5; i++) {
  • Save script.js and click Start Game in your browser.

Now all 5 targets appear with distinct colors from the array. Each callback captured its own i value (0, 1, 2, 3, 4) instead of all sharing the same final value of 5.

Why does let fix this?

The let keyword is block-scoped. Inside a for loop, JavaScript creates a fresh binding of i for every single iteration. Each setTimeout callback closes over its own copy.

With var, there is only one i for the entire function. All callbacks point to that same variable, which equals 5 after the loop ends. This is the single most common JavaScript interview question.

Build continuous spawning with recursive setTimeout

A fixed loop that spawns exactly 5 targets is not a real game. You need targets that keep appearing until the game ends. The pattern for this is recursive setTimeout: a function that spawns one target, then schedules itself to run again.

  • Replace the entire contents of script.js with this code that defines the spawnTargets function:
const colors = ['#e94560', '#ff6b6b', '#ffd93d', '#6bcb77', '#4d96ff'];

function spawnTargets() {
    const spawnOne = () => {
        const board = document.getElementById('game-board');
        const target = document.createElement('div');
        target.classList.add('target');

        const x = Math.floor(Math.random() * 420);
        const y = Math.floor(Math.random() * 420);
        const colorIndex = Math.floor(Math.random() * colors.length);

        target.style.left = x + 'px';
        target.style.top = y + 'px';
        target.style.backgroundColor = colors[colorIndex];

        target.addEventListener('click', () => {
            target.remove();
        });

        board.appendChild(target);

What does this code do?

  • spawnTargets() is a wrapper function that defines an inner function called spawnOne.
  • spawnOne creates a single target with a random position and a random color. This is the same logic from your earlier createTarget() function, now using colorIndex with Math.random() instead of a loop index.
  • Using a random index avoids the closure problem entirely. There is no loop variable to go stale.
  • Directly below the board.appendChild(target); line, add the timing logic and event listener to complete the file:
        // Auto-remove target after 1.5 seconds if missed
        setTimeout(() => {
            if (target.parentNode) {
                target.remove();
            }
        }, 1500);

        // Schedule the next target spawn
        setTimeout(spawnOne, 800);
    };

    spawnOne();
}

document.getElementById('start-btn').addEventListener('click', () => {
    spawnTargets();
});

What does the timing logic do?

  • The first setTimeout auto-removes the target after 1500ms if the player misses it. The if (target.parentNode) check prevents errors when the player already clicked it.
  • The second setTimeout(spawnOne, 800) is the recursive call. After spawning one target, spawnOne schedules itself to run again in 800ms. This creates an infinite chain of spawns.
  • The final spawnOne() call at the bottom of spawnTargets() kicks off the chain. The click handler calls spawnTargets() to start everything.

Why recursive setTimeout instead of setInterval?

Recursive setTimeout guarantees the next spawn only schedules after the current one finishes. With setInterval, callbacks can overlap if one takes longer than the interval. For game loops, recursive scheduling is the safer pattern.

Before you test, predict what you will see: will targets keep appearing forever, or will they stop?

  • Save script.js and click Start Game in your browser.

Targets appear one after another at roughly 800ms intervals. Each has a visible random color. Missed targets vanish after 1.5 seconds. The spawning never stops (you will fix that in the next step with a timer).

  • Open the browser console (press F12 or Cmd+Option+J on macOS) and confirm there are no errors.

Targets not appearing?

  • Check that your second code chunk starts directly after board.appendChild(target); with no extra closing braces between them.
  • Verify that the spawnOne(); call sits inside spawnTargets() but outside the spawnOne arrow function definition. It kicks off the chain.
  • Make sure you replaced the entire file. Leftover code from the for-loop version can conflict.

Help me debug why my recursive setTimeout targets are not spawning.

✔️ Awesome, I've got everything!

Great. Double check you have saved script.js and the game is spawning continuously.

ⓧ I'd like to double check the full code

Your script.js should contain these pieces in order. Scroll up and confirm each one is present:

  • The colors array at the top.
  • The spawnTargets() function containing the spawnOne arrow function (target creation, random position, random color, click listener, appendChild).
  • The auto-removal setTimeout (1500ms) and recursive setTimeout(spawnOne, 800) still inside spawnOne.
  • The spawnOne(); call at the end of spawnTargets() to kick off the chain.
  • The click event listener calling spawnTargets() at the very bottom.

Your index.html and style.css are unchanged from the previous step.

Your game now has continuous action. Targets pop in, targets vanish, and you can click them to remove them early. Next up, you will organize this code into a game object with score tracking and a countdown timer.

Build the Game Object and Hit the this Bug

Your game spawns targets, but there is no score tracking and no countdown timer. Without those, you just have circles popping up forever.

To add score and timer features, you need organized code that holds game state (score, time remaining, whether the game is running). In this step you will refactor everything into a single game object with methods. Along the way, JavaScript's this keyword will break your timer in a way that surprises most developers.

In this step, get ready to:
  • Refactor your code into a game object with state properties and methods.
  • Hit the this binding bug inside a timer callback.
  • Fix it with an arrow function and add game-ending logic.
Create the game object with score tracking

Right now your code is a standalone function and a loose colors array. You will replace it with a single object that holds all game state and behavior together.

  • Switch to the script.js tab in VS Code.
  • Select all the existing code and delete it.
  • Start fresh with the game object skeleton and its start method by pasting this code:
const game = {
    // Game state properties
    score: 0,
    timeLeft: 30,
    isRunning: false,
    timerId: null,
    spawnTimerId: null,
    colors: ['#e94560', '#ff6b6b', '#ffd93d', '#6bcb77', '#4d96ff'],

    // Resets state and kicks off a new round
    start: function () {
        this.score = 0;
        this.timeLeft = 30;
        this.isRunning = true;

        document.getElementById('score-display').textContent = 'Score: 0';
        document.getElementById('timer-display').textContent = 'Time: 30s';
        document.getElementById('start-btn').classList.add('hidden');
        document.getElementById('game-over').classList.add('hidden');
        document.getElementById('game-board').classList.remove('hidden');
        document.getElementById('game-board').innerHTML = '';

        this.spawnTargets();
    },

What does this code do?

You are creating an object literal called game that stores everything the game needs in one place.

  • Properties like score, timeLeft, and isRunning hold the current game state.
  • The start method resets state, updates the DOM to show the board, and calls this.spawnTargets() to begin spawning circles.
  • Using this lets each method reference the same object's properties without hardcoding the variable name.
  • Directly below the start method (after the trailing comma), add the spawnTargets method and close the object:
    // Continuously spawns targets using recursive setTimeout
    spawnTargets: function () {
        const spawnOne = () => {
            if (!this.isRunning) return;
            const board = document.getElementById('game-board');
            const target = document.createElement('div');
            target.classList.add('target');
            const x = Math.floor(Math.random() * 420);
            const y = Math.floor(Math.random() * 420);
            const colorIndex = Math.floor(Math.random() * this.colors.length);
            target.style.left = x + 'px';
            target.style.top = y + 'px';
            target.style.backgroundColor = this.colors[colorIndex];
            target.addEventListener('click', () => {
                if (!this.isRunning) return;
                this.score++;
                document.getElementById('score-display').textContent = 'Score: ' + this.score;
                target.remove();
            });
            board.appendChild(target);
            setTimeout(() => {
                if (target.parentNode) {
                    target.remove();
                }
            }, 1500);
            this.spawnTimerId = setTimeout(spawnOne, 800);
        };
        spawnOne();
    }
};

What does this code do?

This is the same spawning logic from before, now living inside the game object. The key differences are:

  • It checks this.isRunning before spawning so targets stop when the game ends.
  • Clicking a target now increments this.score and updates the score display.
  • The timeout ID is stored in this.spawnTimerId so you can cancel it later.
  • Below the closing }; of the game object, add the event listener that starts the game:
document.getElementById('start-btn').addEventListener('click', () => {
    game.start();
});
  • Save script.js.
  • Switch to your browser and click Start Game.
  • Click several targets and watch the score display increment each time.

Targets not appearing?

  • Make sure the closing }; sits on its own line after the spawnTargets method. A missing brace will cause a syntax error.
  • Open the browser console (F12 then the Console tab) to check for red error messages.
  • Confirm there is a comma after the closing } of start and no comma after the closing } of spawnTargets.

Help me debug why my targets are not appearing

Write the timer and hit the this bug

Score tracking works. Now you need a 30-second countdown timer that ends the game at zero. You will add a startTimer method using setInterval.

  • In script.js, find the this.spawnTargets(); line inside the start method.
  • Add this line directly above it:
        this.startTimer();
  • Now add the startTimer method between the start method and the spawnTargets method by pasting this code:
    // Counts down one second at a time
    startTimer: function () {
        this.timerId = setInterval(function () {
            this.timeLeft--;
            document.getElementById('timer-display').textContent = 'Time: ' + this.timeLeft + 's';

            if (this.timeLeft <= 0) {
                this.endGame();
            }
        }, 1000);
    },

What does this code do?

The setInterval function runs the callback every 1000ms (one second). Inside the callback, this.timeLeft-- decrements the time and the display updates.

Notice this uses a regular function () keyword. That detail is about to matter a lot.

  • Save script.js.

Before you click Start Game, predict: will the timer count down from 30 correctly, or will something go wrong?

  • Switch to the browser and click Start Game.
  • Look at the timer display in the top right.

The timer shows Time: NaNs instead of counting down. That is the bug.

Why does the timer show NaN?

Inside a regular function() callback, this does not point to the game object. When setInterval invokes the callback, it calls it as a standalone function. In non-strict mode, this becomes the global window object.

The window object has no timeLeft property, so this.timeLeft is undefined. Performing undefined - 1 produces NaN (Not a Number), and that propagates into the display string.

Fix with an arrow function and add endGame

Arrow functions do not create their own this binding. They inherit this from the surrounding scope where they are defined. Since startTimer is called as this.startTimer(), the surrounding this is the game object.

  • In script.js, find this line inside startTimer:
        this.timerId = setInterval(function () {
  • Replace function () { with () => { so the line now reads:
        this.timerId = setInterval(() => {
  • The timer callback references this.endGame(), which does not exist yet. Add the endGame method between startTimer and spawnTargets by pasting this code:
    // Stops all timers and clears the board
    endGame: function () {
        this.isRunning = false;
        clearInterval(this.timerId);
        clearTimeout(this.spawnTimerId);

        document.getElementById('game-board').classList.add('hidden');
        document.getElementById('game-board').innerHTML = '';
    },

What does endGame do?

  • Sets isRunning to false so spawnOne stops scheduling new targets.
  • Calls clearInterval and clearTimeout to cancel the countdown and the next spawn.
  • Hides the game board and removes all remaining targets from the DOM.
  • Save script.js.

Before you test, predict: will the timer now count down correctly from 30 to 0?

  • Switch to the browser and click Start Game.
  • Watch the timer count down from 30.
  • Click targets to see the score increase.
  • Wait for the timer to hit 0. The board disappears and targets stop spawning.

The timer counts down from 30 to 0, the score increments each time you click a target, and the game stops when time runs out.

Timer still showing NaN?

  • Make sure you replaced function () { with () => { and did not accidentally delete the opening brace.
  • Confirm there is no second function keyword left on that line. The line should read this.timerId = setInterval(() => { exactly.
  • Do a hard refresh (Ctrl+Shift+R or Cmd+Shift+R) to clear any cached script.

Help me fix my timer NaN issue

✔️ Awesome, I've got everything!

Great. Double check you have saved script.js and the game plays a full 30-second round.

ⓧ I'd like to double check the full code

Your script.js should contain these sections in order. Scroll through your file and confirm each is present:

  • The const game = { declaration with properties: score, timeLeft, isRunning, timerId, spawnTimerId, colors.
  • The start method that resets state, updates the DOM, then calls this.startTimer() and this.spawnTargets().
  • The startTimer method using setInterval(() => { (arrow function, not function).
  • The endGame method that stops timers and hides the board.
  • The spawnTargets method with recursive spawnOne and the click handler that increments this.score.
  • The closing }; followed by the start-btn event listener calling game.start().

Your game now has real structure. Score goes up when you click, a timer counts down, and the round ends at zero. Next up, you will add a proper game-over screen with a high score that survives browser restarts.

Add Game Over Screen with High Score and Type Coercion Trap

Your game runs and scores, but it ends silently. There is no payoff for the player and no motivation to replay.

In this step, you will add a game-over screen that shows the player's final score and tracks their all-time high score using localStorage. But reading values back from localStorage introduces a sneaky type coercion bug that you will hit firsthand before fixing.

In this step, get ready to:
  • Show a game-over screen with the player's final score.
  • Track high scores across sessions with localStorage.
  • Hit and fix the type coercion bug that trips up most JavaScript developers.
Write the endGame method with localStorage

Your current endGame method stops the timers and hides the board, but it never shows the player their results. You need it to display the game-over screen, save the score, and compare it against a stored high score.

  • In script.js, find the endGame method.
  • Replace the entire endGame method with this version that reads from localStorage and shows the game-over screen:
    endGame: function () {
        this.isRunning = false;
        clearInterval(this.timerId);
        clearTimeout(this.spawnTimerId);

        document.getElementById('game-board').classList.add('hidden');
        document.getElementById('game-board').innerHTML = '';

        const finalScore = this.score;
        const savedHigh = localStorage.getItem('highScore');
        let highScore = 0;

        // Read the stored high score and add 1 as the "score to beat"
        if (savedHigh !== null) {
            highScore = savedHigh + 1;
        }

        // Save the new high score if the player beat it
        if (finalScore > highScore) {
            highScore = finalScore;
            localStorage.setItem('highScore', highScore);
        }

        document.getElementById('final-score').textContent = 'Your score: ' + finalScore;
        document.getElementById('high-score').textContent = 'High score: ' + highScore;
        document.getElementById('game-over').classList.remove('hidden');
        document.getElementById('start-btn').classList.remove('hidden');
        document.getElementById('start-btn').textContent = 'Play Again';
    }

What does this code do?

  • localStorage.getItem('highScore') reads the stored high score from the browser's persistent storage.
  • savedHigh + 1 attempts to calculate one above the stored score as the threshold to beat.
  • The game-over div and start button are revealed so the player sees their results and can replay.
  • Save script.js.
  • Switch to your browser. Click Start Game and click a few targets to score some points. Wait for the timer to hit zero.

You should see the game-over screen appear with your final score and a high score of 0 (since nothing was stored yet). The button now reads Play Again.

  • Click Play Again to start a second round. Score a few points and let the timer run out again.

Before you look at the high score display, predict: if your first game scored 5 points and that was saved to localStorage, what should the high score show on this second game?

Look at the high score now. Instead of showing a number like 6, it displays something like 51. That is not a math error. That is JavaScript's type coercion at work, and it is completely intentional for you to see this.

Not seeing the game-over screen at all?

  • Make sure the new endGame code is inside the game object (between the opening const game = { and the final };).
  • Check that you replaced the entire old endGame method and did not accidentally leave a duplicate.
  • Open the browser console (F12) and look for syntax errors.

Help me debug why my game-over screen isn't showing up after the timer reaches zero.

Understand and fix the type coercion bug

Why does "51" appear instead of 6?

localStorage always returns strings. When you stored the number 5, localStorage saved the string "5". When your code runs savedHigh + 1, JavaScript sees a string on the left side of the + operator and switches to string concatenation mode.

So "5" + 1 produces "51" (two strings glued together), not 6 (two numbers added). This is one of the most common bugs in JavaScript.

The fix is to explicitly convert the string to a number before doing any math or comparisons with it.

  • In script.js, find this section inside endGame:
        // Read the stored high score and add 1 as the "score to beat"
        if (savedHigh !== null) {
            highScore = savedHigh + 1;
        }

        // Save the new high score if the player beat it
        if (finalScore > highScore) {
            highScore = finalScore;
            localStorage.setItem('highScore', highScore);
        }
  • Replace that entire section with this fixed version that uses parseInt to convert the string to a number:
        // Convert the stored string to a real number
        if (savedHigh !== null) {
            highScore = parseInt(savedHigh, 10);
        }

        // Save the new high score if the player beat it
        if (finalScore > highScore) {
            highScore = finalScore;
            localStorage.setItem('highScore', String(highScore));
        }

What changed and why?

  • parseInt(savedHigh, 10) explicitly converts the localStorage string to a base-10 integer. Now 5 + 1 gives 6, not "51".
  • String(highScore) makes the conversion explicit when saving back, so you always know what type you are storing.
  • The comparison finalScore > highScore now compares two real numbers instead of a number against a string.

What about == versus ===?

The loose equality operator == allows type coercion, so 5 == "5" returns true. The strict equality operator === requires both the value AND the type to match, so 5 === "5" returns false.

By converting with parseInt first, you eliminate the coercion problem entirely. Always convert localStorage values before comparing or calculating.

Verify the fix and test persistence

Now you need to clear out the corrupted "51" value from your earlier test and prove the fix works across browser sessions.

  • Save script.js.
  • In your browser, open DevTools by pressing F12.
  • Click the Application tab (Chrome) or Storage tab (Firefox).
  • Expand Local Storage in the left sidebar and click on your site's URL.
  • Right-click the highScore entry and select Delete to clear the corrupted value.

Before you play your next round, predict: after two games where you score 5 then 3, what should the high score display show?

  • Close DevTools and click Start Game. Play a full round and let the timer run out.
  • Note your score on the game-over screen.
  • Click Play Again and play a second round.

The high score now displays a real number. If your second score is lower than your first, the high score persists correctly from the first game.

  • Close the browser tab entirely.
  • Return to VS Code, right-click index.html, and select Open with Live Server to reopen the page fresh.
  • Click Start Game, play a round, and check the game-over screen.

Your previous high score still shows up correctly. localStorage persists even after the browser tab is closed.

High score still showing a weird value?

  • Make sure you deleted the old highScore entry from localStorage via DevTools before testing the fix.
  • Check that your code uses parseInt(savedHigh, 10) and not savedHigh + 1.
  • Confirm you saved the file. Unsaved changes do not appear in the browser.

Help me debug why my high score is still displaying incorrectly after fixing the type coercion bug.

✔️ Awesome, I've got everything!

Great. Double check you have saved script.js with the fixed endGame method.

ⓧ I'd like to double check the full code

Here is the complete script.js file. Scroll through and confirm each section matches yours:

  • The game object properties (score, timeLeft, isRunning, timerId, spawnTimerId, colors).
  • The start method that resets state and shows the board.
  • The startTimer method with an arrow function callback.
  • The spawnTargets method with recursive spawnOne.
  • The endGame method using parseInt(savedHigh, 10) and String(highScore).
  • The start-btn event listener at the bottom calling game.start().

You now have a complete, replayable game with persistent high scores. Next up, you will tackle the Secret Mission and add difficulty levels that make the game progressively harder.

Secret mission

Add Difficulty Levels

The game plays the same way every time. What if you could choose between Easy, Medium, and Hard? Each difficulty changes how fast targets spawn and how long they stay on screen. You will also track separate high scores per difficulty using localStorage.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources on your machine, clear your game data, or delete everything entirely.

Resources you used:

  • The reaction-game project folder on your Desktop (contains index.html, style.css, script.js).
  • Browser localStorage entries (high score data stored under keys like highScore-easy, highScore-medium, highScore-hard).
  • Live Server VS Code extension.

Keep everything running

No action needed. Choose this if you want to keep playing your reaction speed game or continue tweaking the code.

  • Your reaction-game folder stays on your Desktop. Open index.html with Live Server anytime to play.
  • Your high scores remain saved in localStorage across browser sessions.
  • The Live Server extension stays installed in VS Code for future projects.

Pause - I'll come back to this later

Clear your browser data to free up localStorage, but keep your project files for later.

  • Open your browser's DevTools by pressing F12.
  • Go to the Application tab, then select Local Storage in the left sidebar.
  • Right-click the entry for your Live Server URL and select Clear to remove your high score data.
  • Your project files stay on your Desktop. You can reopen them anytime.

Delete - I don't want to use this again

Remove all project files and game data completely.

  • Clear localStorage using the same steps as the Pause tab: open DevTools (F12), go to Application > Local Storage, right-click your Live Server URL entry, and select Clear.
  • Delete the project folder by running this command in your terminal:

macOS

rm -rf ~/Desktop/reaction-game

Windows

Remove-Item -Recurse -Force ~\Desktop\reaction-game
  • To uninstall the Live Server extension, open the Extensions sidebar in VS Code (Ctrl+Shift+X), find Live Server by Ritwick Dey, click the gear icon, and select Uninstall.

That's a Wrap!

That's a Wrap!

Nice work! You just built a fully playable reaction speed game from scratch and debugged three of the trickiest problems in JavaScript along the way.

You've learned how to:

  • Build a browser-based game with randomized targets, a countdown timer, and persistent high scores using only HTML, CSS, and vanilla JavaScript.
  • Diagnose and fix the three bugs that trip up most developers: closures in loops (var vs let), lost this binding in callbacks (fixed with arrow functions), and type coercion from localStorage strings (fixed with parseInt and ===).
  • Practice the state-driven render pattern where an object holds game state and functions update the DOM. This is exactly how React works under the hood.
  • Secret Mission: Added difficulty levels with separate high scores per difficulty, extending the game object pattern.

Ready to quiz yourself?