Build a Refresh-Rate-Safe Canvas Game

Build a Canvas arcade game with delta-time animation, controls, and collisions.

Introduction

30 Second Summary

Fast games can behave differently from one screen to another. A smooth challenge on one display can become too easy or frustrating on another.

In this project, you will build Neon Snack Attack with the Canvas API plus JavaScript. You will use delta time with a built-in half-rate test to prove that movement keeps the same speed when fewer frames appear.

What You'll Build

You will launch Neon Snack Attack in Microsoft Edge. You will steer a glowing catcher through falling cyan targets while red hazards threaten your lives and combo.

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

  • A complete neon arcade game you can start, play, lose, and replay with keyboard or pointer controls.
  • A refresh-rate-safe animation loop that keeps movement consistent across different display speeds.
  • A half-rate diagnostic that visibly proves your timing fix works, plus an optional localhost version packaged with Docker Desktop and served through nginx.
  • Secret Mission: A timed Frenzy power-up that doubles collectible points and accelerates target spawning for five seconds.

Are there any prerequisites?

You need advanced HTML, CSS, and JavaScript experience. You also need a Windows computer with Visual Studio Code and Microsoft Edge installed.

Docker Desktop is optional because the local-file game is the primary path.

Before We Start

This short checkpoint establishes your goal before the hands-on work begins: a local Canvas arcade game called Neon Snack Attack. The game will use delta time to keep movement consistent when the display refresh rate or rendered frame rate changes.

Build the Game Shell

Every game mechanic needs a stable frame before movement can begin. The Canvas playfield gives every later animation a fixed place to render.

You'll create linked HTML, CSS, and JavaScript files in Visual Studio Code. Microsoft Edge will show the responsive neon shell before you add animation.

In this step, get ready to:
  • Create the neon-snack-attack folder with the three project files.
  • Build the heading, HUD, playfield, controls, and file links.
  • Style the shell and initialize the Canvas rendering context.
Create the project workspace

A folder opened in Visual Studio Code becomes a workspace. Its Explorer keeps the files for your game together.

  • Press the Windows key to open the Windows search bar.
  • Type Visual Studio Code and press Enter to open it.
  • Select File from the top menu.
  • Select Open Folder....
  • Select New Folder in the folder picker.
  • Enter neon-snack-attack as the folder name.
  • Select Select Folder to open the new folder.
  • Select Yes, I trust the authors if the Workspace Trust dialog appears.

What is a workspace?

A workspace gives Visual Studio Code one folder to use as the context for editing files. The neon-snack-attack name should now appear at the top of the Explorer.

  • Select New File... in the Explorer.
  • Enter index.html and press Enter.
  • Select New File... again.
  • Enter styles.css and press Enter.
  • Select New File... once more.
  • Enter game.js and press Enter.

You'll see index.html, styles.css, and game.js listed under neon-snack-attack in the Explorer.

Files missing from the Explorer?

Confirm that the Explorer heading shows neon-snack-attack. A different heading means another folder is open.

If a filename is still in an editable field, press Enter to finish creating it. Help me fix missing files in my Visual Studio Code workspace.

Build the page structure

The document shell loads the stylesheet before the page renders. Its deferred script waits for the HTML to be parsed before accessing the Canvas.

  • Select index.html in the Explorer.
  • Add the document shell by pasting this code into the empty file:
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Neon Snack Attack</title>
  <link rel="stylesheet" href="styles.css">
  <script src="game.js" defer></script>
</head>
<body>
</body>
</html>

What does this document shell do?

  • The viewport metadata lets the shell scale to the width of the browser window.
  • The stylesheet link applies the rules from styles.css.
  • The deferred script loads game.js after the page structure is available.
  • Save index.html by pressing Ctrl+S.
  • Press the Windows key to open the Windows search bar.
  • Type Microsoft Edge and press Enter to open it.
  • Press Ctrl+O in Microsoft Edge.
  • Select index.html from the neon-snack-attack folder.

You'll see a blank page with Neon Snack Attack in the browser tab. This proves that Microsoft Edge can load your local document.

Browser tab shows the wrong title?

Confirm that you selected the index.html inside neon-snack-attack. Check that the title text sits between the opening and closing title tags.

Help me troubleshoot why my local HTML page has the wrong browser tab title.

The body now needs the elements that the rest of the game uses. Each HUD value and control has a stable identifier for JavaScript to access.

  • Switch back to the index.html tab in Visual Studio Code.
  • Place your cursor between the opening and closing body tags.
  • Add the game interface by pasting this code:
  <main class="game-shell">
    <header>
      <p class="eyebrow">REFRESH-RATE-SAFE ARCADE</p>
      <h1>Neon Snack Attack</h1>
      <p>Catch cyan targets. Dodge red hazards. Protect your combo.</p>
    </header>
    <section class="hud" aria-label="Game statistics">
      <span>Score <strong id="score">0</strong></span>
      <span>Combo <strong id="combo">x1</strong></span>
      <span>Lives <strong id="lives">3</strong></span>
      <span>Best <strong id="best">0</strong></span>
    </section>
    <canvas id="game" width="720" height="480" tabindex="0" aria-label="Neon Snack Attack game area"></canvas>
    <section class="controls">
      <p>Move with Arrow keys, A and D, or your pointer.</p>
      <div class="button-row">
        <button id="start" type="button">Start Game</button>
        <button id="frame-test" type="button">Half-rate Test: Off</button>
      </div>
    </section>
  </main>

How is the game interface organized?

  • The HUD exposes score, combo, lives, and best-score values through unique element identifiers.
  • The game Canvas uses an internal size of 720 by 480 pixels.
  • The start and frame-test identifiers let later steps connect gameplay controls.
  • Save index.html.
  • Switch back to Microsoft Edge.
  • Reload the page from the browser toolbar.

You'll see the Neon Snack Attack heading, four HUD values, an empty playfield, movement instructions, and two buttons. The interface is present before styling.

Interface still looks blank?

Check that the main element sits between the opening and closing body tags. Confirm that index.html was saved before reloading Microsoft Edge.

Help me find why the Neon Snack Attack HTML interface is not visible.

Style and initialize the game shell

The Canvas 2D context gives JavaScript a drawing surface inside the page. A small render function paints the playfield before the animation loop exists.

Why use the Canvas API?

Canvas keeps the player, targets, hazards, and effects on one drawing surface. This keeps later work focused on timing, input, collision, and game state.

A game framework could hide the animation loop behind its own helpers. Direct Canvas rendering keeps the refresh-rate experiment visible.

  • Select game.js in the Explorer.
  • Add the rendering setup by pasting this code:
const canvas = document.getElementById("game");
const ctx = canvas.getContext("2d");
const startButton = document.getElementById("start");
const frameTestButton = document.getElementById("frame-test");
const scoreElement = document.getElementById("score");
const comboElement = document.getElementById("combo");
const livesElement = document.getElementById("lives");
const bestElement = document.getElementById("best");
const WIDTH = canvas.width;
const HEIGHT = canvas.height;
function render() { ctx.fillStyle = "#050816"; ctx.fillRect(0, 0, WIDTH, HEIGHT); }
render();

What does this rendering setup do?

  • The element references connect JavaScript to the Canvas, buttons, and HUD values.
  • The ctx constant holds the Canvas 2D drawing context.
  • The WIDTH and HEIGHT constants preserve the internal Canvas dimensions.
  • The render() function fills the complete playfield with its dark base color.
  • Save game.js.
  • Switch back to Microsoft Edge.
  • Reload the page from the browser toolbar.

You'll see the playfield filled with a uniform dark color. That visible rectangle proves that the script found the Canvas and rendered into it.

Playfield did not turn dark?

Confirm that game.js is saved beside index.html. Check that the script source and Canvas identifier match the filenames and markup exactly.

Help me debug why game.js is not painting the Canvas.

The structure works, so the final layer controls presentation. Responsive rules keep the game shell readable when the browser becomes narrower.

  • Select styles.css in the Explorer.
  • Add the responsive neon styling by pasting this code:
:root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, sans-serif; background: #03050d; color: #f4f7ff; }
* { box-sizing: border-box; }
body { min-height: 100vh; margin: 0; display: grid; place-items: center; padding: 24px; background: radial-gradient(circle at 20% 20%, #102653 0, transparent 34%), radial-gradient(circle at 80% 10%, #34145a 0, transparent 30%), #03050d; }
.game-shell { width: min(920px, 100%); padding: 24px; border: 1px solid #263a68; border-radius: 24px; background: rgb(6 10 24 / 88%); box-shadow: 0 24px 80px rgb(0 0 0 / 55%); }
header { text-align: center; }
h1 { margin: 4px 0 8px; font-size: clamp(2rem, 6vw, 4rem); letter-spacing: -0.06em; color: #7ffcff; text-shadow: 0 0 28px rgb(41 241 255 / 55%); }
header p { margin: 0; color: #aab6d6; }
.eyebrow { color: #ff6edf; font-size: 0.75rem; font-weight: 800; letter-spacing: 0.2em; }
.hud { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin: 22px 0 12px; }
.hud span { padding: 10px 12px; border: 1px solid #25365f; border-radius: 12px; background: #0a1025; color: #8392b8; text-align: center; }
.hud strong { display: block; color: #ffffff; font-size: 1.3rem; }
canvas { display: block; width: 100%; height: auto; border: 1px solid #29406f; border-radius: 16px; background: #050816; touch-action: none; box-shadow: inset 0 0 40px rgb(32 87 165 / 20%); }
canvas:focus-visible { outline: 3px solid #7ffcff; outline-offset: 4px; }
.controls { display: flex; justify-content: space-between; gap: 16px; align-items: center; margin-top: 14px; color: #98a7ca; }
.button-row { display: flex; gap: 10px; flex-wrap: wrap; justify-content: flex-end; }
button { border: 0; border-radius: 999px; padding: 11px 16px; color: #07111b; background: #7ffcff; font: inherit; font-weight: 800; cursor: pointer; }
button:last-child { color: #f7eaff; background: #5a2778; }
button:hover { filter: brightness(1.12); }
@media (max-width: 650px) { .hud { grid-template-columns: repeat(2, 1fr); } .controls { align-items: stretch; flex-direction: column; } .button-row { justify-content: flex-start; } }

How does the shell stay responsive?

  • The game shell uses the available browser width while staying below its maximum width.
  • The Canvas keeps its internal proportions while its displayed width changes.
  • The responsive rule switches the HUD to two columns on screens narrower than 650px.
  • Glow, borders, gradients, and rounded controls create the neon arcade treatment without external assets.
  • Save styles.css.

Before you open the saved page, which parts of the unstyled interface do you expect to gain the neon treatment?

  • Switch back to Microsoft Edge.
  • Press Ctrl+O.
  • Select index.html from the neon-snack-attack folder.

You'll see the Neon Snack Attack heading, score HUD, combo HUD, lives HUD, best-score HUD, dark playfield, Start Game button, and Half-rate Test button. The shell also narrows to fit the Microsoft Edge window.

Your first visible win is complete. The browser is now loading all three files as one responsive game shell.

Shell still looks unstyled?

Confirm that the file is named styles.css with the final letter included. Check that it sits beside index.html in the Explorer.

Help me troubleshoot why the Neon Snack Attack CSS is not loading.

✔️ Awesome, I've got everything!

Great. Confirm that index.html, styles.css, and game.js are saved in the neon-snack-attack folder.

ⓧ I'd like to double check the full code

Compare each saved file with the complete shell code below.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Neon Snack Attack</title>
  <link rel="stylesheet" href="styles.css">
  <script src="game.js" defer></script>
</head>
<body>
  <main class="game-shell">
    <header>
      <p class="eyebrow">REFRESH-RATE-SAFE ARCADE</p>
      <h1>Neon Snack Attack</h1>
      <p>Catch cyan targets. Dodge red hazards. Protect your combo.</p>
    </header>
    <section class="hud" aria-label="Game statistics">
      <span>Score <strong id="score">0</strong></span>
      <span>Combo <strong id="combo">x1</strong></span>
      <span>Lives <strong id="lives">3</strong></span>
      <span>Best <strong id="best">0</strong></span>
    </section>
    <canvas id="game" width="720" height="480" tabindex="0" aria-label="Neon Snack Attack game area"></canvas>
    <section class="controls">
      <p>Move with Arrow keys, A and D, or your pointer.</p>
      <div class="button-row">
        <button id="start" type="button">Start Game</button>
        <button id="frame-test" type="button">Half-rate Test: Off</button>
      </div>
    </section>
  </main>
</body>
</html>

What should match in index.html?

This file links the stylesheet and script. Its body contains the complete heading, HUD, Canvas, and controls.

:root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, sans-serif; background: #03050d; color: #f4f7ff; }
* { box-sizing: border-box; }
body { min-height: 100vh; margin: 0; display: grid; place-items: center; padding: 24px; background: radial-gradient(circle at 20% 20%, #102653 0, transparent 34%), radial-gradient(circle at 80% 10%, #34145a 0, transparent 30%), #03050d; }
.game-shell { width: min(920px, 100%); padding: 24px; border: 1px solid #263a68; border-radius: 24px; background: rgb(6 10 24 / 88%); box-shadow: 0 24px 80px rgb(0 0 0 / 55%); }
header { text-align: center; }
h1 { margin: 4px 0 8px; font-size: clamp(2rem, 6vw, 4rem); letter-spacing: -0.06em; color: #7ffcff; text-shadow: 0 0 28px rgb(41 241 255 / 55%); }
header p { margin: 0; color: #aab6d6; }
.eyebrow { color: #ff6edf; font-size: 0.75rem; font-weight: 800; letter-spacing: 0.2em; }
.hud { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin: 22px 0 12px; }
.hud span { padding: 10px 12px; border: 1px solid #25365f; border-radius: 12px; background: #0a1025; color: #8392b8; text-align: center; }
.hud strong { display: block; color: #ffffff; font-size: 1.3rem; }
canvas { display: block; width: 100%; height: auto; border: 1px solid #29406f; border-radius: 16px; background: #050816; touch-action: none; box-shadow: inset 0 0 40px rgb(32 87 165 / 20%); }
canvas:focus-visible { outline: 3px solid #7ffcff; outline-offset: 4px; }
.controls { display: flex; justify-content: space-between; gap: 16px; align-items: center; margin-top: 14px; color: #98a7ca; }
.button-row { display: flex; gap: 10px; flex-wrap: wrap; justify-content: flex-end; }
button { border: 0; border-radius: 999px; padding: 11px 16px; color: #07111b; background: #7ffcff; font: inherit; font-weight: 800; cursor: pointer; }
button:last-child { color: #f7eaff; background: #5a2778; }
button:hover { filter: brightness(1.12); }
@media (max-width: 650px) { .hud { grid-template-columns: repeat(2, 1fr); } .controls { align-items: stretch; flex-direction: column; } .button-row { justify-content: flex-start; } }

What should match in styles.css?

This file contains the complete neon presentation and responsive layout. Every selector supports an element or class from index.html.

const canvas = document.getElementById("game");
const ctx = canvas.getContext("2d");
const startButton = document.getElementById("start");
const frameTestButton = document.getElementById("frame-test");
const scoreElement = document.getElementById("score");
const comboElement = document.getElementById("combo");
const livesElement = document.getElementById("lives");
const bestElement = document.getElementById("best");
const WIDTH = canvas.width;
const HEIGHT = canvas.height;
function render() { ctx.fillStyle = "#050816"; ctx.fillRect(0, 0, WIDTH, HEIGHT); }
render();

What should match in game.js?

This file captures every interface reference needed by later mechanics. Its first render paints the complete Canvas with the dark playfield color.

Your game frame is live in Microsoft Edge. Next, you'll draw the player and first falling target before exposing a frame-rate timing bug.

Make the First Target Fall

The shell you built in Visual Studio Code is already open in Microsoft Edge. Its Canvas still shows a static scene.

The game needs motion before it can become playable. You will build a simple animation loop that moves one target by a fixed distance each rendered frame.

The half-rate diagnostic skips alternate animation callbacks. This gives you a repeatable way to judge whether frame count affects movement.

In this step, get ready to:
  • Draw a neon player bar plus one circular target inside the Canvas.
  • Start a naive animation loop that advances the target by a fixed number of pixels per rendered frame.
  • Use the half-rate diagnostic to test how the fixed movement responds to fewer callbacks.
Draw the player and first target

The Canvas 2D context redraws shapes from the game state. A player object holds the catcher's position while targetY records the target's vertical position.

  • In game.js, locate the existing render() function plus the final render(); call.
  • Replace that section with the following drawing code:
const player = { x: WIDTH / 2 - 45, y: HEIGHT - 42, width: 90, height: 18 };
let gameState = "ready";
let targetY = 40;
// Draw the catcher from its stored geometry.
function drawPlayer() {
  ctx.save();
  ctx.shadowColor = "#7ffcff";
  ctx.shadowBlur = 20;
  ctx.fillStyle = "#7ffcff";
  ctx.fillRect(player.x, player.y, player.width, player.height);
  ctx.restore();
}
// Draw the first target at its current vertical position.
function drawTarget() {
  ctx.save();
  ctx.shadowColor = "#7ffcff";
  ctx.shadowBlur = 18;
  ctx.fillStyle = "#18dbe8";
  ctx.beginPath();
  ctx.arc(WIDTH / 2, targetY, 15, 0, Math.PI * 2);
  ctx.fill();
  ctx.restore();
}
// Repaint the complete scene from current state.
function render() { ctx.fillStyle = "#050816"; ctx.fillRect(0, 0, WIDTH, HEIGHT); drawTarget(); drawPlayer(); }
render();

What does this code draw?

  • The player object stores the catcher's position plus its dimensions.
  • The drawPlayer() function paints a glowing cyan rectangle near the bottom of the Canvas.
  • The drawTarget() function paints a glowing circle at the position stored in targetY.
  • The render() function clears the previous scene before drawing the target plus the player.
  • Save game.js.
  • Reload the page in Microsoft Edge.

You should see a glowing cyan circle near the top of the dark playfield. You should also see a cyan player bar near the bottom.

Missing one of the shapes?

Check that drawTarget(); plus drawPlayer(); both appear inside render().

Confirm that the replacement still ends with render();.

Help me find why my Canvas does not show the target or player.

Start the naive animation loop

Each animation callback gives the game one opportunity to update its state before repainting. This first loop moves the target down by exactly 3 pixels every time it renders.

  • In game.js, add the following code below the final render(); call:
function gameLoop() {
  if (gameState !== "playing") return;
  // Move the target by a fixed distance for every rendered frame.
  targetY += 3;
  if (targetY - 15 > HEIGHT) targetY = 40;
  render();
  requestAnimationFrame(gameLoop);
}
startButton.addEventListener("click", () => {
  targetY = 40;
  gameState = "playing";
  startButton.hidden = true;
  requestAnimationFrame(gameLoop);
});

How does the loop create motion?

  • The gameState check allows the loop to update only while the game is playing.
  • The targetY += 3; line moves the target by a fixed distance during each rendered frame.
  • The boundary check returns the target to 40 after it moves below the Canvas.
  • The next requestAnimationFrame() call schedules another update before a future repaint.
  • Save game.js.
  • Reload the page in Microsoft Edge.

Before you start the loop, where do you expect the cyan target to move?

  • Select Start Game below the Canvas.

You should see the cyan target fall from the top of the playfield. It returns to the top after crossing the bottom edge.

Target staying still?

Check that the Start Game handler sets gameState to "playing" before requesting the first frame.

Confirm that gameLoop() calls both render() plus requestAnimationFrame(gameLoop).

Help me debug why the target stays still after I select Start Game.

Run the half-rate diagnostic

A diagnostic mode can deliberately skip every other animation callback. The same fixed movement code still runs whenever a frame reaches the update.

  • In game.js, locate the current gameLoop() function plus the Start Game click handler beneath it.
  • Use this current section as your replacement boundary:
function gameLoop() {
  if (gameState !== "playing") return;
  // Move the target by a fixed distance for every rendered frame.
  targetY += 3;
  if (targetY - 15 > HEIGHT) targetY = 40;
  render();
  requestAnimationFrame(gameLoop);
}
startButton.addEventListener("click", () => {
  targetY = 40;
  gameState = "playing";
  startButton.hidden = true;
  requestAnimationFrame(gameLoop);
});

What are you replacing?

This is the current fixed-distance loop plus its Start Game handler. The next version keeps that movement unchanged while adding the diagnostic around it.

  • Replace that entire section with the diagnostic version below:
let halfRate = false;
let skipFrame = false;

function gameLoop() {
  if (gameState !== "playing") return;
  if (halfRate) {
    skipFrame = !skipFrame;
    if (skipFrame) {
      requestAnimationFrame(gameLoop);
      return;
    }
  }
  // Move the target by a fixed distance for every rendered frame.
  targetY += 3;
  if (targetY - 15 > HEIGHT) targetY = 40;
  render();
  requestAnimationFrame(gameLoop);
}
startButton.addEventListener("click", () => {
  targetY = 40;
  gameState = "playing";
  startButton.hidden = true;
  requestAnimationFrame(gameLoop);
});
frameTestButton.addEventListener("click", () => {
  halfRate = !halfRate;
  frameTestButton.textContent = `Half-rate Test: ${halfRate ? "On" : "Off"}`;
});

How does the diagnostic work?

  • The halfRate value records whether the diagnostic is active.
  • The skipFrame value alternates between callbacks while the diagnostic is active.
  • A skipped callback schedules another frame before returning without movement or rendering.
  • The Half-rate Test handler toggles the mode plus updates the button text.
  • Save game.js.
  • Reload the page in Microsoft Edge.

Before you test it, do you expect skipping alternate callbacks to preserve the target's speed or change it?

  • Select Start Game.
  • Watch the target complete part of its fall.
  • Select Half-rate Test: Off to switch the diagnostic on.
  • Watch the target travel through the same playfield again.

You should see the button change to Half-rate Test: On. The target should move at roughly half its previous speed.

That slowdown is the intended shortfall. Fewer rendered frames now produce fewer fixed 3-pixel movements each second.

Half-rate mode not slowing the target?

Check that the halfRate block appears before targetY += 3; inside gameLoop().

Confirm that the skipped branch returns before the target movement runs.

Help me debug why Half-rate Test does not slow the target.

✔️ Awesome, I've got everything!

Your target falls when you select Start Game. Half-rate Test now exposes the fixed-per-frame timing problem.

ⓧ I'd like to double check the full code

Compare your complete game.js file with this version. Your index.html plus styles.css files remain unchanged.

const canvas = document.getElementById("game");
const ctx = canvas.getContext("2d");
const startButton = document.getElementById("start");
const frameTestButton = document.getElementById("frame-test");
const scoreElement = document.getElementById("score");
const comboElement = document.getElementById("combo");
const livesElement = document.getElementById("lives");
const bestElement = document.getElementById("best");
const WIDTH = canvas.width;
const HEIGHT = canvas.height;
const player = { x: WIDTH / 2 - 45, y: HEIGHT - 42, width: 90, height: 18 };
let gameState = "ready";
let targetY = 40;
// Draw the catcher from its stored geometry.
function drawPlayer() {
  ctx.save();
  ctx.shadowColor = "#7ffcff";
  ctx.shadowBlur = 20;
  ctx.fillStyle = "#7ffcff";
  ctx.fillRect(player.x, player.y, player.width, player.height);
  ctx.restore();
}
// Draw the first target at its current vertical position.
function drawTarget() {
  ctx.save();
  ctx.shadowColor = "#7ffcff";
  ctx.shadowBlur = 18;
  ctx.fillStyle = "#18dbe8";
  ctx.beginPath();
  ctx.arc(WIDTH / 2, targetY, 15, 0, Math.PI * 2);
  ctx.fill();
  ctx.restore();
}
// Repaint the complete scene from current state.
function render() { ctx.fillStyle = "#050816"; ctx.fillRect(0, 0, WIDTH, HEIGHT); drawTarget(); drawPlayer(); }
render();
let halfRate = false;
let skipFrame = false;

function gameLoop() {
  if (gameState !== "playing") return;
  if (halfRate) {
    skipFrame = !skipFrame;
    if (skipFrame) {
      requestAnimationFrame(gameLoop);
      return;
    }
  }
  // Move the target by a fixed distance for every rendered frame.
  targetY += 3;
  if (targetY - 15 > HEIGHT) targetY = 40;
  render();
  requestAnimationFrame(gameLoop);
}
startButton.addEventListener("click", () => {
  targetY = 40;
  gameState = "playing";
  startButton.hidden = true;
  requestAnimationFrame(gameLoop);
});
frameTestButton.addEventListener("click", () => {
  halfRate = !halfRate;
  frameTestButton.textContent = `Half-rate Test: ${halfRate ? "On" : "Off"}`;
});

You have built motion plus a diagnostic that reveals its timing flaw. Next, you will replace fixed-per-frame movement with elapsed-time movement so the target keeps a consistent speed.

Make Movement Refresh-Rate Safe

The half-rate diagnostic exposed the timing bug in your first animation. Your target loses speed whenever fewer frames reach the screen.

This step replaces frame-count movement with delta time. The animation will measure elapsed time so your Canvas game keeps a stable speed across different refresh rates.

In this step, get ready to:
  • Read the animation timestamp from requestAnimationFrame().
  • Calculate capped elapsed time for each rendered update.
  • Prove that half-rate mode changes smoothness without substantially changing speed.
Track elapsed animation time

Each requestAnimationFrame() callback receives a timestamp measured in milliseconds. Comparing it with the previous timestamp reveals how much real time passed between rendered updates.

  • Switch back to game.js in Visual Studio Code.
  • Locate the declarations for halfRate and skipFrame.
  • Add the previous timestamp state beside those declarations by pasting this line:
let lastTime = 0;

What Does This State Track?

The lastTime variable stores the timestamp from the previous rendered update. Its initial value tells the loop that no earlier timestamp exists yet.

  • Locate the declaration for gameLoop().
  • Add the callback timestamp parameter so the declaration starts with this line:
function gameLoop(timestamp) {

Why Accept the Timestamp?

The browser supplies timestamp whenever it invokes the animation callback. This value gives every update a shared clock that stays independent of the number of frames rendered.

  • Save game.js.
  • Return to Microsoft Edge.
  • Reload the local game page.
  • Select Start Game.

You should still see the target fall across the playfield. The loop now receives timing data without changing its movement yet.

Target No Longer Moving?

Check that timestamp sits inside the existing parentheses for gameLoop(). Keep the opening brace after the closing parenthesis.

Make sure the existing calls to requestAnimationFrame(gameLoop) still pass the function itself.

Help me repair the animation callback signature.

Calculate capped delta time

Elapsed seconds become the multiplier for movement. A small cap keeps a suspended browser tab from producing one huge update when it becomes active again.

  • Locate the half-rate branch inside gameLoop(timestamp).
  • Find the closing brace after the branch that flips skipFrame.
  • Add the elapsed-time calculation immediately after that branch by pasting:
if (lastTime === 0) lastTime = timestamp;
const deltaSeconds = Math.min((timestamp - lastTime) / 1000, 0.1);
lastTime = timestamp;

What Does This Calculation Do?

  • The first line gives the initial callback a safe starting timestamp.
  • Dividing the millisecond difference by 1000 converts it into seconds.
  • The 0.1 cap limits one update to one tenth of a second.
  • The final line saves the current timestamp for the next rendered update.
  • Change the target's existing movement amount so it represents distance traveled per second.
  • Multiply that movement amount by deltaSeconds in the target position update.
  • Save game.js.

Before you retest the diagnostic, do you expect skipped callbacks to reduce the target's speed or only its visual smoothness?

  • Return to Microsoft Edge.
  • Reload the local game page.
  • Select Start Game.
  • Watch the target descend with the diagnostic switched off.
  • Select Half-rate Test to switch it on.

You should see fewer visual updates while the target keeps approximately the same falling speed. Each rendered update now covers the real time that passed since the previous one.

Target Still Slowing Down?

Check that the target movement uses deltaSeconds as a multiplier. A fixed position increment still ties speed to the number of rendered frames.

Confirm that lastTime = timestamp; runs after calculating deltaSeconds.

Help me find why half-rate mode still changes the target speed.

Prove the speed stays stable

A fresh round needs a fresh timing baseline. Resetting lastTime prevents the next run from inheriting elapsed time from the previous run.

  • Locate the code that resets the target when Start Game begins a round.
  • Add this timing reset beside the existing round-state resets:
lastTime = 0;

Why Reset the Clock?

Setting lastTime to zero makes the first callback establish a new baseline. Restarting the game therefore cannot produce movement from stale timing data.

  • Save game.js.

Before the final check, what should remain stable when the diagnostic skips every other animation callback?

  • Return to Microsoft Edge.
  • Reload the local game page.
  • Select Start Game.
  • Observe one target descent with Half-rate Test: Off.
  • Select Half-rate Test.
  • Observe another target descent with Half-rate Test: On.

The target should take approximately the same time to cross the playfield in both modes. Half-rate mode should make the motion look less smooth without substantially changing its speed.

Before you test the cap, do you expect the target to leap across the playfield after the tab has been hidden?

  • Switch to another browser tab while the target is falling.
  • Wait for a few seconds.
  • Return to the game tab.

The target should resume without a large jump. The capped deltaSeconds value limits the first movement update after the tab returns.

That timing bug is closed. Your animation now responds to elapsed time while the diagnostic changes only how often you see it update.

✔️ Awesome, I've got everything!

Your target keeps approximately the same falling speed in both diagnostic modes. Save game.js before continuing.

ⓧ I'd like to double check the full code

Keep the drawing functions from the previous step unchanged. Compare the timing state below with the declarations in game.js.

let lastTime = 0;

This declaration stores the timestamp from the previous rendered update.

Your animation loop declaration should accept the browser timestamp.

function gameLoop(timestamp) {

This signature gives the loop access to the callback's millisecond timestamp.

Inside that loop, the elapsed-time calculation should appear after the half-rate skip branch.

if (lastTime === 0) lastTime = timestamp;
const deltaSeconds = Math.min((timestamp - lastTime) / 1000, 0.1);
lastTime = timestamp;

The target movement below this calculation should use its pixels-per-second speed multiplied by deltaSeconds.

The round-reset code should also restore the timing baseline.

lastTime = 0;

This reset prepares the first callback of every round to establish a fresh starting timestamp.

Your animation now survives skipped frames without losing speed. Next, you will turn this stable loop into a playable game with input, collisions, scoring, lives, and hazards.

Turn the Animation into a Game

Your falling target now keeps a consistent speed because delta time separates movement from the number of rendered frames. That stable motion gives your Canvas game a reliable foundation.

Stable motion becomes gameplay when you can steer the catcher. A finite game state then connects collisions to score, combo, lives, difficulty, and the end of each round.

In this step, get ready to:
  • Capture keyboard input and scaled pointer movement.
  • Spawn collectible snacks plus dangerous hazards.
  • Connect collisions to the HUD and game-over state.
Build the gameplay systems

The game needs one source of truth for every active object. The state below holds the catcher, falling items, keyboard input, HUD values, and round timing.

  • Switch back to game.js in Visual Studio Code.
  • Select the current contents of game.js.
  • Replace the selected code with this first half of the gameplay file:
const canvas = document.getElementById("game");
const ctx = canvas.getContext("2d");
const startButton = document.getElementById("start");
const frameTestButton = document.getElementById("frame-test");
const scoreElement = document.getElementById("score");
const comboElement = document.getElementById("combo");
const livesElement = document.getElementById("lives");
const bestElement = document.getElementById("best");
const WIDTH = canvas.width;
const HEIGHT = canvas.height;
const keys = new Set();
const items = [];
const player = { x: WIDTH / 2 - 45, y: HEIGHT - 42, width: 90, height: 18, speed: 420, targetX: null };
let gameState = "ready";
let score = 0;
let combo = 1;
let lives = 3;
let bestScore = 0;
let elapsed = 0;
let spawnTimer = 0;
let lastTime = 0;
let halfRate = false;
let skipFrame = false;
function clamp(value, minimum, maximum) { return Math.max(minimum, Math.min(maximum, value)); }
function updateHud() { scoreElement.textContent = score; comboElement.textContent = `x${combo}`; livesElement.textContent = lives; bestElement.textContent = bestScore; }
function resetGame() { score = 0; combo = 1; lives = 3; elapsed = 0; spawnTimer = 0; lastTime = 0; items.length = 0; player.x = WIDTH / 2 - player.width / 2; player.targetX = null; updateHud(); }
function startGame() { resetGame(); gameState = "playing"; startButton.hidden = true; canvas.focus(); requestAnimationFrame(gameLoop); }
function endGame() { gameState = "gameover"; bestScore = Math.max(bestScore, score); updateHud(); startButton.textContent = "Play Again"; startButton.hidden = false; }
function spawnItem() { const kind = Math.random() < 0.78 ? "snack" : "hazard"; const radius = kind === "snack" ? 15 : 17; items.push({ kind, x: radius + Math.random() * (WIDTH - radius * 2), y: -radius, radius, speed: 150 + elapsed * 3 + Math.random() * 70 }); }
function collides(item) { const nearestX = clamp(item.x, player.x, player.x + player.width); const nearestY = clamp(item.y, player.y, player.y + player.height); const dx = item.x - nearestX; const dy = item.y - nearestY; return dx * dx + dy * dy < item.radius * item.radius; }

How does the game state work?

  • The keys set remembers which movement keys are currently held down.
  • The items array stores every falling snack or hazard.
  • The player object gives keyboard input and pointer input control over the same catcher.
  • The gameState value controls whether the round is ready, playing, or over.
  • The clamp() helper keeps values inside a defined range.
  • The collides() helper measures the shortest distance between a circular item and the rectangular catcher.

The second half turns that state into movement, collisions, drawing, and input. Together, both halves form one complete runnable file.

  • Place your cursor directly after the final closing brace in game.js.
  • Append this second half below the first half:
function updatePlayer(deltaSeconds) { let direction = 0; if (keys.has("arrowleft") || keys.has("a")) direction -= 1; if (keys.has("arrowright") || keys.has("d")) direction += 1; if (direction !== 0) { player.targetX = null; player.x += direction * player.speed * deltaSeconds; } else if (player.targetX !== null) { const desiredX = player.targetX - player.width / 2; const distance = desiredX - player.x; const maximumStep = player.speed * deltaSeconds; player.x += clamp(distance, -maximumStep, maximumStep); } player.x = clamp(player.x, 0, WIDTH - player.width); }
function updateItems(deltaSeconds) { for (let index = items.length - 1; index >= 0; index -= 1) { const item = items[index]; item.y += item.speed * deltaSeconds; if (collides(item)) { if (item.kind === "snack") { score += 10 * combo; combo = Math.min(combo + 1, 8); } else { lives -= 1; combo = 1; if (lives <= 0) endGame(); } items.splice(index, 1); updateHud(); continue; } if (item.y - item.radius > HEIGHT) { if (item.kind === "snack") combo = 1; items.splice(index, 1); updateHud(); } } }
function update(deltaSeconds) { elapsed += deltaSeconds; updatePlayer(deltaSeconds); updateItems(deltaSeconds); spawnTimer -= deltaSeconds; if (spawnTimer <= 0 && gameState === "playing") { spawnItem(); spawnTimer = Math.max(0.35, 0.9 - elapsed * 0.01); } }
function drawBackground() { ctx.fillStyle = "#050816"; ctx.fillRect(0, 0, WIDTH, HEIGHT); ctx.strokeStyle = "#102248"; ctx.lineWidth = 1; for (let x = 0; x <= WIDTH; x += 48) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, HEIGHT); ctx.stroke(); } for (let y = 0; y <= HEIGHT; y += 48) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(WIDTH, y); ctx.stroke(); } }
function drawPlayer() { ctx.save(); ctx.shadowColor = "#7ffcff"; ctx.shadowBlur = 20; ctx.fillStyle = "#7ffcff"; ctx.fillRect(player.x, player.y, player.width, player.height); ctx.fillStyle = "#ffffff"; ctx.fillRect(player.x + 18, player.y + 5, player.width - 36, 4); ctx.restore(); }
function drawItem(item) { ctx.save(); ctx.translate(item.x, item.y); if (item.kind === "snack") { ctx.shadowColor = "#7ffcff"; ctx.shadowBlur = 18; ctx.fillStyle = "#18dbe8"; ctx.beginPath(); ctx.arc(0, 0, item.radius, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = "#dfffff"; ctx.beginPath(); ctx.arc(0, 0, item.radius * 0.4, 0, Math.PI * 2); ctx.fill(); } else { ctx.rotate(Math.PI / 4); ctx.shadowColor = "#ff4d6d"; ctx.shadowBlur = 18; ctx.fillStyle = "#ff4d6d"; ctx.fillRect(-item.radius, -item.radius, item.radius * 2, item.radius * 2); ctx.fillStyle = "#24050d"; ctx.fillRect(-4, -12, 8, 24); ctx.fillRect(-12, -4, 24, 8); } ctx.restore(); }
function drawOverlay(title, subtitle) { ctx.fillStyle = "rgb(2 5 15 / 72%)"; ctx.fillRect(0, 0, WIDTH, HEIGHT); ctx.textAlign = "center"; ctx.fillStyle = "#7ffcff"; ctx.font = "700 42px system-ui"; ctx.fillText(title, WIDTH / 2, HEIGHT / 2 - 10); ctx.fillStyle = "#d7def3"; ctx.font = "20px system-ui"; ctx.fillText(subtitle, WIDTH / 2, HEIGHT / 2 + 32); }
function render() { drawBackground(); for (const item of items) drawItem(item); drawPlayer(); if (gameState === "ready") drawOverlay("READY", "Select Start Game to begin"); else if (gameState === "gameover") drawOverlay("GAME OVER", `Final score: ${score}`); }
function gameLoop(timestamp) { if (gameState !== "playing") return; if (halfRate) { skipFrame = !skipFrame; if (skipFrame) { requestAnimationFrame(gameLoop); return; } } if (lastTime === 0) lastTime = timestamp; const deltaSeconds = Math.min((timestamp - lastTime) / 1000, 0.1); lastTime = timestamp; update(deltaSeconds); render(); if (gameState === "playing") requestAnimationFrame(gameLoop); }
window.addEventListener("keydown", (event) => { const key = event.key.toLowerCase(); if (["arrowleft", "arrowright", "a", "d"].includes(key)) { event.preventDefault(); keys.add(key); } });
window.addEventListener("keyup", (event) => { keys.delete(event.key.toLowerCase()); });
canvas.addEventListener("pointermove", (event) => { const bounds = canvas.getBoundingClientRect(); const scaleX = canvas.width / bounds.width; player.targetX = (event.clientX - bounds.left) * scaleX; });
startButton.addEventListener("click", startGame);
frameTestButton.addEventListener("click", () => { halfRate = !halfRate; frameTestButton.textContent = `Half-rate Test: ${halfRate ? "On" : "Off"}`; });
updateHud();
render();

What does this code do?

  • The updatePlayer() function converts keyboard direction or pointer position into delta-time movement.
  • The updateItems() function moves each item before applying collision and missed-item rules.
  • The update() function reduces the spawn interval as elapsed time increases.
  • The drawing functions give snacks a cyan circle and hazards a red diamond.
  • The event listeners feed keyboard and pointer input into the same player state.
  • The existing half-rate diagnostic continues to skip callbacks without changing the simulation speed.
  • Save game.js.
  • Switch back to Microsoft Edge.
  • Refresh the game page.

You should see the dark grid with a cyan catcher near the bottom. The Canvas should display the ready overlay until you start a round.

Game missing after the refresh?

Confirm the second half starts on a new line after collides(). A missing brace can prevent the file from loading.

Check that the final two lines call updateHud() and render().

Ask for help with the exact location of the syntax problem:

Test keyboard and pointer movement

Keyboard input moves the catcher by a direction multiplied by speed and elapsed seconds. Pointer input converts the displayed Canvas position back into its internal 720-pixel coordinate width.

  • Select Start Game below the Canvas.
  • Hold the Left Arrow key to move the catcher left.

The catcher should glide left without crossing the Canvas edge.

  • Hold D to move the catcher right.

The catcher should move right at the same steady speed.

  • Move your pointer horizontally over the Canvas.

The catcher should follow your pointer across the full playfield. This confirms the displayed width is being scaled into the Canvas coordinate system.

Catcher not responding?

Select the Canvas once before testing the Arrow keys. Starting a round normally gives it keyboard focus automatically.

If pointer movement stops early, check that scaleX divides the internal Canvas width by bounds.width.

Troubleshoot the input that is failing:

Verify scoring and game state

Collision detection connects movement to consequences. Snacks reward accurate positioning while hazards reduce the remaining lives.

Before you play the final round, make a mental prediction about what the Canvas and HUD show after the third life is lost.

  • Guide the catcher under a cyan circular snack.

Your score should increase by 10 times the current combo. The combo should increase by one up to its maximum of 8.

  • Guide the catcher into a red diamond hazard.

Your lives should decrease by one. The combo should return to x1.

  • Move your pointer across the Canvas to confirm pointer control during the same round.
  • Continue touching red hazards until all three lives are gone.

You should see the game-over overlay with your final score. The Best HUD value should update when the round ends.

That is the core game working: both input methods steer one catcher, collisions change the HUD, and the round reaches a clear ending.

HUD not changing after collisions?

Check that updateItems() calls updateHud() after removing a collided item.

Confirm that snacks use snack while the red items use hazard.

Get help tracing the collision result:

✔️ Awesome, I've got everything!

Great work. Save game.js after confirming movement, collisions, HUD updates, and the game-over overlay.

ⓧ I'd like to double check the full code

Compare your complete game.js file with this gameplay version.

const canvas = document.getElementById("game");
const ctx = canvas.getContext("2d");
const startButton = document.getElementById("start");
const frameTestButton = document.getElementById("frame-test");
const scoreElement = document.getElementById("score");
const comboElement = document.getElementById("combo");
const livesElement = document.getElementById("lives");
const bestElement = document.getElementById("best");
const WIDTH = canvas.width;
const HEIGHT = canvas.height;
const keys = new Set();
const items = [];
const player = { x: WIDTH / 2 - 45, y: HEIGHT - 42, width: 90, height: 18, speed: 420, targetX: null };
let gameState = "ready";
let score = 0;
let combo = 1;
let lives = 3;
let bestScore = 0;
let elapsed = 0;
let spawnTimer = 0;
let lastTime = 0;
let halfRate = false;
let skipFrame = false;
function clamp(value, minimum, maximum) { return Math.max(minimum, Math.min(maximum, value)); }
function updateHud() { scoreElement.textContent = score; comboElement.textContent = `x${combo}`; livesElement.textContent = lives; bestElement.textContent = bestScore; }
function resetGame() { score = 0; combo = 1; lives = 3; elapsed = 0; spawnTimer = 0; lastTime = 0; items.length = 0; player.x = WIDTH / 2 - player.width / 2; player.targetX = null; updateHud(); }
function startGame() { resetGame(); gameState = "playing"; startButton.hidden = true; canvas.focus(); requestAnimationFrame(gameLoop); }
function endGame() { gameState = "gameover"; bestScore = Math.max(bestScore, score); updateHud(); startButton.textContent = "Play Again"; startButton.hidden = false; }
function spawnItem() { const kind = Math.random() < 0.78 ? "snack" : "hazard"; const radius = kind === "snack" ? 15 : 17; items.push({ kind, x: radius + Math.random() * (WIDTH - radius * 2), y: -radius, radius, speed: 150 + elapsed * 3 + Math.random() * 70 }); }
function collides(item) { const nearestX = clamp(item.x, player.x, player.x + player.width); const nearestY = clamp(item.y, player.y, player.y + player.height); const dx = item.x - nearestX; const dy = item.y - nearestY; return dx * dx + dy * dy < item.radius * item.radius; }
function updatePlayer(deltaSeconds) { let direction = 0; if (keys.has("arrowleft") || keys.has("a")) direction -= 1; if (keys.has("arrowright") || keys.has("d")) direction += 1; if (direction !== 0) { player.targetX = null; player.x += direction * player.speed * deltaSeconds; } else if (player.targetX !== null) { const desiredX = player.targetX - player.width / 2; const distance = desiredX - player.x; const maximumStep = player.speed * deltaSeconds; player.x += clamp(distance, -maximumStep, maximumStep); } player.x = clamp(player.x, 0, WIDTH - player.width); }
function updateItems(deltaSeconds) { for (let index = items.length - 1; index >= 0; index -= 1) { const item = items[index]; item.y += item.speed * deltaSeconds; if (collides(item)) { if (item.kind === "snack") { score += 10 * combo; combo = Math.min(combo + 1, 8); } else { lives -= 1; combo = 1; if (lives <= 0) endGame(); } items.splice(index, 1); updateHud(); continue; } if (item.y - item.radius > HEIGHT) { if (item.kind === "snack") combo = 1; items.splice(index, 1); updateHud(); } } }
function update(deltaSeconds) { elapsed += deltaSeconds; updatePlayer(deltaSeconds); updateItems(deltaSeconds); spawnTimer -= deltaSeconds; if (spawnTimer <= 0 && gameState === "playing") { spawnItem(); spawnTimer = Math.max(0.35, 0.9 - elapsed * 0.01); } }
function drawBackground() { ctx.fillStyle = "#050816"; ctx.fillRect(0, 0, WIDTH, HEIGHT); ctx.strokeStyle = "#102248"; ctx.lineWidth = 1; for (let x = 0; x <= WIDTH; x += 48) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, HEIGHT); ctx.stroke(); } for (let y = 0; y <= HEIGHT; y += 48) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(WIDTH, y); ctx.stroke(); } }
function drawPlayer() { ctx.save(); ctx.shadowColor = "#7ffcff"; ctx.shadowBlur = 20; ctx.fillStyle = "#7ffcff"; ctx.fillRect(player.x, player.y, player.width, player.height); ctx.fillStyle = "#ffffff"; ctx.fillRect(player.x + 18, player.y + 5, player.width - 36, 4); ctx.restore(); }
function drawItem(item) { ctx.save(); ctx.translate(item.x, item.y); if (item.kind === "snack") { ctx.shadowColor = "#7ffcff"; ctx.shadowBlur = 18; ctx.fillStyle = "#18dbe8"; ctx.beginPath(); ctx.arc(0, 0, item.radius, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = "#dfffff"; ctx.beginPath(); ctx.arc(0, 0, item.radius * 0.4, 0, Math.PI * 2); ctx.fill(); } else { ctx.rotate(Math.PI / 4); ctx.shadowColor = "#ff4d6d"; ctx.shadowBlur = 18; ctx.fillStyle = "#ff4d6d"; ctx.fillRect(-item.radius, -item.radius, item.radius * 2, item.radius * 2); ctx.fillStyle = "#24050d"; ctx.fillRect(-4, -12, 8, 24); ctx.fillRect(-12, -4, 24, 8); } ctx.restore(); }
function drawOverlay(title, subtitle) { ctx.fillStyle = "rgb(2 5 15 / 72%)"; ctx.fillRect(0, 0, WIDTH, HEIGHT); ctx.textAlign = "center"; ctx.fillStyle = "#7ffcff"; ctx.font = "700 42px system-ui"; ctx.fillText(title, WIDTH / 2, HEIGHT / 2 - 10); ctx.fillStyle = "#d7def3"; ctx.font = "20px system-ui"; ctx.fillText(subtitle, WIDTH / 2, HEIGHT / 2 + 32); }
function render() { drawBackground(); for (const item of items) drawItem(item); drawPlayer(); if (gameState === "ready") drawOverlay("READY", "Select Start Game to begin"); else if (gameState === "gameover") drawOverlay("GAME OVER", `Final score: ${score}`); }
function gameLoop(timestamp) { if (gameState !== "playing") return; if (halfRate) { skipFrame = !skipFrame; if (skipFrame) { requestAnimationFrame(gameLoop); return; } } if (lastTime === 0) lastTime = timestamp; const deltaSeconds = Math.min((timestamp - lastTime) / 1000, 0.1); lastTime = timestamp; update(deltaSeconds); render(); if (gameState === "playing") requestAnimationFrame(gameLoop); }
window.addEventListener("keydown", (event) => { const key = event.key.toLowerCase(); if (["arrowleft", "arrowright", "a", "d"].includes(key)) { event.preventDefault(); keys.add(key); } });
window.addEventListener("keyup", (event) => { keys.delete(event.key.toLowerCase()); });
canvas.addEventListener("pointermove", (event) => { const bounds = canvas.getBoundingClientRect(); const scaleX = canvas.width / bounds.width; player.targetX = (event.clientX - bounds.left) * scaleX; });
startButton.addEventListener("click", startGame);
frameTestButton.addEventListener("click", () => { halfRate = !halfRate; frameTestButton.textContent = `Half-rate Test: ${halfRate ? "On" : "Off"}`; });
updateHud();
render();

Your animation is now a complete playable round with two input methods, collision consequences, increasing difficulty, and a session-best score. Next, you will add arcade feedback and package the same static game with optional local hosting.

Add Arcade Polish and Package the Game

Your Canvas game in Microsoft Edge now responds to keyboard input and pointer input. Its HUD reflects every collision.

A playable loop still needs feedback that makes each catch feel meaningful. Replay turns one finished round into a game you can demo repeatedly. Optional packaging serves the same static files through a local web server.

In this step, get ready to:
  • Add visual feedback for collisions.
  • Add ready, game-over, and replay states.
  • Package the unchanged static files in an optional nginx container.
Add particle feedback and replay

Particle bursts make successful catches visible beyond the score change. Screen shake gives hazards a heavier impact. Overlays make the current game state clear before and after each round.

  • In game.js, use Visual Studio Code search to locate const items = [];, let lastTime = 0;, and each matching function name shown below.
  • Apply this reference patch to the matching declarations and functions in game.js:
const particles = [];
let shake = 0;
function resetGame() { score = 0; combo = 1; lives = 3; elapsed = 0; spawnTimer = 0; lastTime = 0; shake = 0; items.length = 0; particles.length = 0; player.x = WIDTH / 2 - player.width / 2; player.targetX = null; updateHud(); }
function endGame() { gameState = "gameover"; bestScore = Math.max(bestScore, score); updateHud(); startButton.textContent = "Play Again"; startButton.hidden = false; }
function burst(x, y, color, amount = 12) { for (let index = 0; index < amount; index += 1) { const angle = Math.random() * Math.PI * 2; const speed = 70 + Math.random() * 150; particles.push({ x, y, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, life: 0.65, color }); } }
function updateItems(deltaSeconds) { for (let index = items.length - 1; index >= 0; index -= 1) { const item = items[index]; item.y += item.speed * deltaSeconds; if (collides(item)) { if (item.kind === "snack") { score += 10 * combo; combo = Math.min(combo + 1, 8); burst(item.x, item.y, "#7ffcff"); shake = Math.max(shake, 4); } else { lives -= 1; combo = 1; burst(item.x, item.y, "#ff4d6d", 18); shake = 12; if (lives <= 0) endGame(); } items.splice(index, 1); updateHud(); continue; } if (item.y - item.radius > HEIGHT) { if (item.kind === "snack") combo = 1; items.splice(index, 1); updateHud(); } } }
function updateParticles(deltaSeconds) { for (let index = particles.length - 1; index >= 0; index -= 1) { const particle = particles[index]; particle.x += particle.vx * deltaSeconds; particle.y += particle.vy * deltaSeconds; particle.vy += 220 * deltaSeconds; particle.life -= deltaSeconds; if (particle.life <= 0) particles.splice(index, 1); } }
function update(deltaSeconds) { elapsed += deltaSeconds; shake = Math.max(0, shake - 30 * deltaSeconds); updatePlayer(deltaSeconds); updateItems(deltaSeconds); updateParticles(deltaSeconds); spawnTimer -= deltaSeconds; if (spawnTimer <= 0 && gameState === "playing") { spawnItem(); spawnTimer = Math.max(0.35, 0.9 - elapsed * 0.01); } }
function drawPlayer() { ctx.save(); ctx.shadowColor = "#7ffcff"; ctx.shadowBlur = 20; ctx.fillStyle = "#7ffcff"; ctx.fillRect(player.x, player.y, player.width, player.height); ctx.fillStyle = "#ffffff"; ctx.fillRect(player.x + 18, player.y + 5, player.width - 36, 4); ctx.restore(); }
function drawItem(item) { ctx.save(); ctx.translate(item.x, item.y); if (item.kind === "snack") { ctx.shadowColor = "#7ffcff"; ctx.shadowBlur = 18; ctx.fillStyle = "#18dbe8"; ctx.beginPath(); ctx.arc(0, 0, item.radius, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = "#dfffff"; ctx.beginPath(); ctx.arc(0, 0, item.radius * 0.4, 0, Math.PI * 2); ctx.fill(); } else { ctx.rotate(Math.PI / 4); ctx.shadowColor = "#ff4d6d"; ctx.shadowBlur = 18; ctx.fillStyle = "#ff4d6d"; ctx.fillRect(-item.radius, -item.radius, item.radius * 2, item.radius * 2); ctx.fillStyle = "#24050d"; ctx.fillRect(-4, -12, 8, 24); ctx.fillRect(-12, -4, 24, 8); } ctx.restore(); }
function drawParticles() { for (const particle of particles) { ctx.globalAlpha = clamp(particle.life / 0.65, 0, 1); ctx.fillStyle = particle.color; ctx.fillRect(particle.x - 3, particle.y - 3, 6, 6); } ctx.globalAlpha = 1; }
function drawOverlay(title, subtitle) { ctx.fillStyle = "rgb(2 5 15 / 72%)"; ctx.fillRect(0, 0, WIDTH, HEIGHT); ctx.textAlign = "center"; ctx.fillStyle = "#7ffcff"; ctx.font = "700 42px system-ui"; ctx.fillText(title, WIDTH / 2, HEIGHT / 2 - 10); ctx.fillStyle = "#d7def3"; ctx.font = "20px system-ui"; ctx.fillText(subtitle, WIDTH / 2, HEIGHT / 2 + 32); }
function render() { ctx.save(); if (shake > 0) ctx.translate((Math.random() - 0.5) * shake, (Math.random() - 0.5) * shake); drawBackground(); for (const item of items) drawItem(item); drawPlayer(); drawParticles(); ctx.restore(); if (gameState === "ready") drawOverlay("READY", "Select Start Game to begin"); else if (gameState === "gameover") drawOverlay("GAME OVER", `Final score: ${score}`); }

What does this patch add?

  • The particles array stores short-lived squares created by burst() after a collision.
  • The shake value temporarily offsets the drawing context after a catch or hazard collision.
  • The updated drawing functions add cyan glow to snacks and red glow to hazards.
  • The ready and game-over overlays communicate the current state inside the Canvas.
  • The updated endGame() function reveals the start button with its replay label.
  • Save game.js.
  • Return to the local game page in Microsoft Edge.
  • Reload the page.
  • Select Start Game.
  • Catch one cyan target.

You should see cyan particles scatter from the collision point. The Canvas should shift slightly before settling.

  • Move the catcher into one red hazard.

You should see a larger red particle burst. The stronger screen shake makes the lost life feel heavier.

Before you lose the final life, what state do you expect the Canvas and start button to show?

  • Keep touching red hazards until the round ends.

You should see the game-over overlay with your final score. The start button should now read Play Again.

  • Select Play Again.

The score should return to 0. Combo should return to x1. Lives should return to 3.

Missing particles or replay?

Confirm that update() calls updateParticles(deltaSeconds). Confirm that render() calls drawParticles().

If the replay button appears but the previous round remains, check that resetGame() clears both items and particles.

Help me debug the arcade feedback in game.js.

✔️ Awesome, I've got everything!

Your local game now has collision feedback, clear state overlays, and instant replay. Save game.js before continuing.

ⓧ I'd like to double check the full code

Compare your complete game.js file with this reference.

const canvas = document.getElementById("game");
const ctx = canvas.getContext("2d");
const startButton = document.getElementById("start");
const frameTestButton = document.getElementById("frame-test");
const scoreElement = document.getElementById("score");
const comboElement = document.getElementById("combo");
const livesElement = document.getElementById("lives");
const bestElement = document.getElementById("best");
const WIDTH = canvas.width;
const HEIGHT = canvas.height;
const keys = new Set();
const items = [];
const particles = [];
const player = { x: WIDTH / 2 - 45, y: HEIGHT - 42, width: 90, height: 18, speed: 420, targetX: null };
let gameState = "ready";
let score = 0;
let combo = 1;
let lives = 3;
let bestScore = 0;
let elapsed = 0;
let spawnTimer = 0;
let lastTime = 0;
let shake = 0;
let halfRate = false;
let skipFrame = false;
function clamp(value, minimum, maximum) { return Math.max(minimum, Math.min(maximum, value)); }
function updateHud() { scoreElement.textContent = score; comboElement.textContent = `x${combo}`; livesElement.textContent = lives; bestElement.textContent = bestScore; }
function resetGame() { score = 0; combo = 1; lives = 3; elapsed = 0; spawnTimer = 0; lastTime = 0; shake = 0; items.length = 0; particles.length = 0; player.x = WIDTH / 2 - player.width / 2; player.targetX = null; updateHud(); }
function startGame() { resetGame(); gameState = "playing"; startButton.hidden = true; canvas.focus(); requestAnimationFrame(gameLoop); }
function endGame() { gameState = "gameover"; bestScore = Math.max(bestScore, score); updateHud(); startButton.textContent = "Play Again"; startButton.hidden = false; }
function spawnItem() { const kind = Math.random() < 0.78 ? "snack" : "hazard"; const radius = kind === "snack" ? 15 : 17; items.push({ kind, x: radius + Math.random() * (WIDTH - radius * 2), y: -radius, radius, speed: 150 + elapsed * 3 + Math.random() * 70 }); }
function burst(x, y, color, amount = 12) { for (let index = 0; index < amount; index += 1) { const angle = Math.random() * Math.PI * 2; const speed = 70 + Math.random() * 150; particles.push({ x, y, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, life: 0.65, color }); } }
function collides(item) { const nearestX = clamp(item.x, player.x, player.x + player.width); const nearestY = clamp(item.y, player.y, player.y + player.height); const dx = item.x - nearestX; const dy = item.y - nearestY; return dx * dx + dy * dy < item.radius * item.radius; }
function updatePlayer(deltaSeconds) { let direction = 0; if (keys.has("arrowleft") || keys.has("a")) direction -= 1; if (keys.has("arrowright") || keys.has("d")) direction += 1; if (direction !== 0) { player.targetX = null; player.x += direction * player.speed * deltaSeconds; } else if (player.targetX !== null) { const desiredX = player.targetX - player.width / 2; const distance = desiredX - player.x; const maximumStep = player.speed * deltaSeconds; player.x += clamp(distance, -maximumStep, maximumStep); } player.x = clamp(player.x, 0, WIDTH - player.width); }
function updateItems(deltaSeconds) { for (let index = items.length - 1; index >= 0; index -= 1) { const item = items[index]; item.y += item.speed * deltaSeconds; if (collides(item)) { if (item.kind === "snack") { score += 10 * combo; combo = Math.min(combo + 1, 8); burst(item.x, item.y, "#7ffcff"); shake = Math.max(shake, 4); } else { lives -= 1; combo = 1; burst(item.x, item.y, "#ff4d6d", 18); shake = 12; if (lives <= 0) endGame(); } items.splice(index, 1); updateHud(); continue; } if (item.y - item.radius > HEIGHT) { if (item.kind === "snack") combo = 1; items.splice(index, 1); updateHud(); } } }
function updateParticles(deltaSeconds) { for (let index = particles.length - 1; index >= 0; index -= 1) { const particle = particles[index]; particle.x += particle.vx * deltaSeconds; particle.y += particle.vy * deltaSeconds; particle.vy += 220 * deltaSeconds; particle.life -= deltaSeconds; if (particle.life <= 0) particles.splice(index, 1); } }
function update(deltaSeconds) { elapsed += deltaSeconds; shake = Math.max(0, shake - 30 * deltaSeconds); updatePlayer(deltaSeconds); updateItems(deltaSeconds); updateParticles(deltaSeconds); spawnTimer -= deltaSeconds; if (spawnTimer <= 0 && gameState === "playing") { spawnItem(); spawnTimer = Math.max(0.35, 0.9 - elapsed * 0.01); } }
function drawBackground() { ctx.fillStyle = "#050816"; ctx.fillRect(0, 0, WIDTH, HEIGHT); ctx.strokeStyle = "#102248"; ctx.lineWidth = 1; for (let x = 0; x <= WIDTH; x += 48) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, HEIGHT); ctx.stroke(); } for (let y = 0; y <= HEIGHT; y += 48) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(WIDTH, y); ctx.stroke(); } }
function drawPlayer() { ctx.save(); ctx.shadowColor = "#7ffcff"; ctx.shadowBlur = 20; ctx.fillStyle = "#7ffcff"; ctx.fillRect(player.x, player.y, player.width, player.height); ctx.fillStyle = "#ffffff"; ctx.fillRect(player.x + 18, player.y + 5, player.width - 36, 4); ctx.restore(); }
function drawItem(item) { ctx.save(); ctx.translate(item.x, item.y); if (item.kind === "snack") { ctx.shadowColor = "#7ffcff"; ctx.shadowBlur = 18; ctx.fillStyle = "#18dbe8"; ctx.beginPath(); ctx.arc(0, 0, item.radius, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = "#dfffff"; ctx.beginPath(); ctx.arc(0, 0, item.radius * 0.4, 0, Math.PI * 2); ctx.fill(); } else { ctx.rotate(Math.PI / 4); ctx.shadowColor = "#ff4d6d"; ctx.shadowBlur = 18; ctx.fillStyle = "#ff4d6d"; ctx.fillRect(-item.radius, -item.radius, item.radius * 2, item.radius * 2); ctx.fillStyle = "#24050d"; ctx.fillRect(-4, -12, 8, 24); ctx.fillRect(-12, -4, 24, 8); } ctx.restore(); }
function drawParticles() { for (const particle of particles) { ctx.globalAlpha = clamp(particle.life / 0.65, 0, 1); ctx.fillStyle = particle.color; ctx.fillRect(particle.x - 3, particle.y - 3, 6, 6); } ctx.globalAlpha = 1; }
function drawOverlay(title, subtitle) { ctx.fillStyle = "rgb(2 5 15 / 72%)"; ctx.fillRect(0, 0, WIDTH, HEIGHT); ctx.textAlign = "center"; ctx.fillStyle = "#7ffcff"; ctx.font = "700 42px system-ui"; ctx.fillText(title, WIDTH / 2, HEIGHT / 2 - 10); ctx.fillStyle = "#d7def3"; ctx.font = "20px system-ui"; ctx.fillText(subtitle, WIDTH / 2, HEIGHT / 2 + 32); }
function render() { ctx.save(); if (shake > 0) ctx.translate((Math.random() - 0.5) * shake, (Math.random() - 0.5) * shake); drawBackground(); for (const item of items) drawItem(item); drawPlayer(); drawParticles(); ctx.restore(); if (gameState === "ready") drawOverlay("READY", "Select Start Game to begin"); else if (gameState === "gameover") drawOverlay("GAME OVER", `Final score: ${score}`); }
function gameLoop(timestamp) { if (gameState !== "playing") return; if (halfRate) { skipFrame = !skipFrame; if (skipFrame) { requestAnimationFrame(gameLoop); return; } } if (lastTime === 0) lastTime = timestamp; const deltaSeconds = Math.min((timestamp - lastTime) / 1000, 0.1); lastTime = timestamp; update(deltaSeconds); render(); if (gameState === "playing") requestAnimationFrame(gameLoop); }
window.addEventListener("keydown", (event) => { const key = event.key.toLowerCase(); if (["arrowleft", "arrowright", "a", "d"].includes(key)) { event.preventDefault(); keys.add(key); } });
window.addEventListener("keyup", (event) => { keys.delete(event.key.toLowerCase()); });
canvas.addEventListener("pointermove", (event) => { const bounds = canvas.getBoundingClientRect(); const scaleX = canvas.width / bounds.width; player.targetX = (event.clientX - bounds.left) * scaleX; });
startButton.addEventListener("click", startGame);
frameTestButton.addEventListener("click", () => { halfRate = !halfRate; frameTestButton.textContent = `Half-rate Test: ${halfRate ? "On" : "Off"}`; });
updateHud();
render();

How to use this reference

Check declarations first. Compare each function by name after that. Your file should end with the two button handlers followed by updateHud() and render().

Package the game with nginx

A static web server can deliver the same three game files without changing the game code. nginx serves those files from a lightweight container image.

This packaging path is optional. Your direct-file game is already complete.

Why a static nginx image?

The game has no backend or build process. A static nginx image keeps the package focused on serving index.html, styles.css, and game.js.

The browser runs the game logic exactly as it does from the local file. Packaging changes how the files arrive.

  • In the Explorer sidebar in Visual Studio Code, select the neon-snack-attack folder.
  • Use the sidebar's new-file control to create a file named Dockerfile.
  • Paste this configuration into Dockerfile:
FROM nginx:1.31.8-alpine
COPY index.html /usr/share/nginx/html/index.html
COPY styles.css /usr/share/nginx/html/styles.css
COPY game.js /usr/share/nginx/html/game.js

What does this Dockerfile do?

The FROM instruction uses the pinned nginx:1.31.8-alpine image as the server.

Each COPY instruction places one static game file in /usr/share/nginx/html. nginx serves that directory by default in this image.

  • Save Dockerfile.
  • Confirm that the Explorer sidebar lists Dockerfile beside index.html, styles.css, and game.js.

Dockerfile missing or named incorrectly?

Confirm that the filename is exactly Dockerfile with no extension. Make sure it sits directly inside neon-snack-attack.

Help me check my Dockerfile location and contents.

✔️ Awesome, I've got everything!

Your optional container configuration is ready. Continue below if you want to build and run it.

ⓧ I'd like to double check the full code

Your complete Dockerfile should match this reference exactly.

FROM nginx:1.31.8-alpine
COPY index.html /usr/share/nginx/html/index.html
COPY styles.css /usr/share/nginx/html/styles.css
COPY game.js /usr/share/nginx/html/game.js

How to use this reference

Compare the image tag and all three destination paths. Every static file should have its own COPY instruction.

The optional build uses Docker Desktop from your Windows setup. Docker Desktop supplies the local engine that builds the image and runs the container.

  • Press the Windows key to open Windows search.
  • Type Docker Desktop.
  • Press Enter.
  • Wait for Docker Desktop to finish starting.
  • Return to Visual Studio Code.
  • Open a PowerShell terminal from the terminal menu.
  • Confirm that the terminal prompt ends inside the neon-snack-attack folder.

The first build can take longer while Docker downloads the base image. A pause during that download is expected.

  • Build the some-content-nginx image by running this command:
docker build -t some-content-nginx .

What does this command do?

The command builds an image from the Dockerfile in the current folder. The -t option names the result some-content-nginx.

The final . makes the current neon-snack-attack folder the build context.

The terminal should complete the build without an error. Docker Desktop should now list the some-content-nginx image.

Image build not completing?

Confirm that Docker Desktop has finished starting. Check that the terminal prompt points to the folder containing Dockerfile.

Confirm that index.html, styles.css, and game.js are visible in the same folder.

Help me troubleshoot the Docker image build.

The next command publishes the container's web server through host port 8080. This development game contains no backend or credentials.

  • Start the some-nginx container by running this command:
docker run --name some-nginx -d -p 8080:80 some-content-nginx

What does this command do?

The command starts a detached container named some-nginx from the some-content-nginx image.

The -p 8080:80 option sends traffic from host port 8080 to nginx on container port 80.

You should see a container identifier in the terminal. Docker Desktop should list some-nginx as running.

Before you open the local URL, do you expect the container version to look different from the direct-file version?

  • Return to Microsoft Edge.
  • Open a new browser tab.
  • Enter http://localhost:8080 in the address bar.

You should see the same Neon Snack Attack heading, HUD, dark playfield, and controls. Start a round to confirm that particles, screen shake, game over, and replay behave the same way.

Localhost game not loading?

Confirm that Docker Desktop shows some-nginx as running. Check that another local program is not already using port 8080.

If the shell loads without the latest polish, save game.js before rebuilding the image.

Help me troubleshoot the localhost container.

Your finished game now delivers clear collision feedback and instant replay. The optional container proves that the same static project can run behind a local nginx server without changing its game code.

Secret mission

Add Frenzy Mode

Add a gold F power-up that transforms the game for five seconds. Frenzy doubles snack points, accelerates spawning, and turns the playfield gold while a countdown tracks the remaining time.

Clean Up Your Resources

Clean Up Your Resources

Everything in this project runs on your Windows computer, so there are no ongoing cloud charges. Decide whether to keep the local files, pause the optional container, or delete the resources entirely.

Resources you used:

  • Local neon-snack-attack project folder containing your Frenzy-enabled game.
  • Optional local Docker container named some-nginx, which serves the game at http://localhost:8080.
  • Optional local nginx image named some-content-nginx.

Keep everything running

No action is needed while you are still playing or extending Neon Snack Attack. Your local resources do not create cloud charges.

  • Keep the neon-snack-attack folder so you can continue playing the game directly in Microsoft Edge.
  • Leave the optional some-nginx container running to keep the game available at http://localhost:8080.
  • Keep the optional some-content-nginx image so you can recreate the container later.

Pause - I'll come back to this later

This option stops the optional container to free local resources. Your project files and image remain available for your return.

If you skipped the optional packaging, your direct-file game has no running process to pause.

  • Press the Windows key to open system search.
  • Type Docker Desktop into the search field.
  • Press Enter to open Docker Desktop.
  • Select the Containers view.
  • Locate some-nginx.
  • Select the Stop action in the Actions column.

The container stops serving http://localhost:8080. Your project folder and image remain untouched.

Delete - I don't want to use this again

This option removes the project resources for a fresh start. Deleting the local folder permanently removes your game code.

The container must be removed before its image. Skip these Docker Desktop actions if you did not create the optional container or image.

  • Press the Windows key to open system search.
  • Type Docker Desktop into the search field.
  • Press Enter to open Docker Desktop.
  • Select the Containers view.
  • Locate some-nginx.
  • Use the container action to delete it.
  • Approve the deletion confirmation if Docker Desktop displays one.

You should no longer see some-nginx in the Containers view. The local server at http://localhost:8080 is now unavailable.

  • Select the Images view.
  • Locate some-content-nginx.
  • Select the bin icon on its image row.
  • Approve the deletion confirmation if Docker Desktop displays one.

You should no longer see some-content-nginx in the Images view. Its local disk space is now reclaimed.

The neon-snack-attack folder is the final resource. Removing it deletes index.html, styles.css, game.js, and the optional Dockerfile.

  • Close Visual Studio Code.
  • Use the Windows file browser to return to the location where you created the project.
  • Select the neon-snack-attack folder.
  • Remove the selected folder permanently through the file browser.
  • Approve the deletion confirmation.

You should no longer see the neon-snack-attack folder at its original location. Your local project resources are now removed.

Nice Work!

Nice Work!

You did it! Neon Snack Attack is now a polished arcade game powered by the Canvas 2D API.

You've learned how to:

  • Built a replayable arcade game where the catcher responds to keyboard or pointer input. Cyan targets raise the score. Red hazards remove lives.
  • Exposed a frame-count timing bug with the half-rate test. Replaced fixed movement with delta-time movement that preserves approximate speed across different render rates.
  • Prepared containerized static hosting with Docker plus nginx. The optional container serves the same game at http://localhost:8080 without changing the game code.
  • Completed the Secret Mission by adding a timed Frenzy mode. Frenzy doubles collectible points for five seconds. It also accelerates target spawning.

Ready to quiz yourself?