Build Neon Pong Against the Computer

Build a Canvas Pong game with a computer opponent, scoring, and selectable AI.

Introduction

30 Second Summary

A quick browser game can turn a spare minute into a tense rematch. The simple screen hides constant decisions about movement, timing, and score.

In this project, you will build a single-player Neon Pong game with plain HTML, CSS, and JavaScript rendered on a Canvas. You will steer one paddle against a computer opponent through timed serves, live scoring, and a first-to-7 finish.

What You'll Build

Your finished index.html opens in Google Chrome as a responsive neon court where your paddle trades shots with a computer opponent beneath a live scoreboard.

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

  • A browser arcade game you can start, pause, restart, and play to 7 points in Google Chrome.
  • Responsive keyboard controls that move the left paddle with W/S or Arrow Up/Arrow Down and return shots at different angles.
  • A speed-limited computer opponent that tracks the ball while the live scoreboard records every goal and announces the winner.
  • Secret Mission: Add Easy, Normal, and Hard AI difficulty shortcuts using the 1, 2, and 3 keys.

Are there any prerequisites?

You need a Chromebook with Google Chrome plus access to Visual Studio Code for the Web.

Comfortable reading and editing JavaScript is enough because no packages, terminal, or local server are required.

Before We Start

A clear goal gives every later choice a purpose. This checkpoint locks in your one-player game as three local files that Google Chrome can run directly.

Set Up the Three-File Game

Because the web editor from earlier has no terminal or built-in runtime, your game needs files that run directly in Google Chrome. Visual Studio Code for the Web gives you a browser-based place to edit those files.

You'll connect HTML, CSS, and JavaScript across three files. ChromeOS Files will open the result directly as a static Canvas court.

In this step, get ready to:
  • Save the three project files together in Downloads.
  • Build the accessible page shell.
  • Present a stationary neon court through linked CSS and JavaScript.
Create the three local files

Keeping the files together allows the relative links in index.html to find the stylesheet and game script. Downloads gives all three files one shared location on your Chromebook.

  • Enter https://vscode.dev in the Chrome address bar.
  • Grant local file access if Chrome requests it.

Why Use Three Local Files?

This project does not need a package manager or build process. The browser can read the page structure from index.html directly.

The relative links load style.css and script.js from the same location. That keeps the game portable across folders.

  • Click File in the top menu.
  • Select New File.
  • Return to the File menu.
  • Select Save.
  • Choose Downloads in the file picker.
  • Enter index.html as the file name.
  • Confirm the save.

Your First Project File

The editor now associates the open tab with index.html in Downloads. The next two files need to use that same location.

  • Repeat the new-file flow to save style.css in Downloads.
  • Repeat the new-file flow to save script.js in Downloads.
  • Confirm the editor shows all three file names.

Good progress. Your three-file skeleton is now ready for the game shell.

Build the accessible HTML shell

The page shell gives the game its heading, score display, controls, instructions, and fallback text. It also links the two files that supply the presentation and drawing logic.

  • Switch to index.html in the editor.
  • Replace its empty contents by pasting this code:
<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Neon Pong</title>
  <link rel="stylesheet" href="style.css" />
</head>
<body>
  <main class="game-shell">
    <header class="game-header">
      <div><p class="eyebrow">Single-player arcade</p><h1>Neon Pong</h1></div>
      <div class="scoreboard" aria-label="Score" aria-live="polite"><div><span id="playerScore">0</span><small>You</small></div><strong>:</strong><div><span id="computerScore">0</span><small>CPU</small></div></div>
    </header>
    <section class="game-stage" aria-label="Neon Pong game area">
      <canvas id="gameCanvas" width="900" height="500" aria-label="Pong court. Use W and S or the up and down arrow keys to move.">Your browser does not support Canvas. Use a current version of Chrome.</canvas>
      <div class="overlay" id="overlay" aria-live="polite"><div><h2 id="overlayTitle">Ready?</h2><p id="overlayMessage">First to 7 wins.</p></div></div>
    </section>
    <div class="controls"><button id="startButton" type="button">Start game</button><button id="pauseButton" type="button" disabled>Pause</button><button id="restartButton" type="button">Restart</button></div>
    <p class="instructions" id="instructions">Move with W/S or Arrow Up/Arrow Down. Press Space to pause or resume.</p>
  </main>
  <script src="script.js"></script>
</body>
</html>

What Does This Page Shell Do?

  • The game-shell element groups the heading, scoreboard, court, controls, and instructions.
  • The gameCanvas element provides a drawing area with internal coordinates of 900 by 500.
  • The fallback sentence gives the learner useful text if Canvas is unavailable.
  • The live score and overlay labels prepare changing game messages for assistive technology.
  • The relative links load style.css and script.js from Downloads.
  • Save index.html from the File menu.
  • Use your Chromebook app search to open ChromeOS Files.
  • Select Downloads.
  • Double-click index.html.

You should see the Neon Pong heading, both zero scores, three controls, instructions, and the Ready message. The page still has plain browser styling because the stylesheet is empty.

Page Missing Its Content?

Confirm that Chrome opened index.html from Downloads. A different empty file produces a blank page.

Check that the closing </html> line is present at the bottom of the file.

Ask for help if the structure still does not appear. Help me check why my local index.html page is blank in Chrome.

✔️ Awesome, I've got everything!

Your index.html file now contains the complete page shell for this step.

ⓧ I'd like to double check the full code

This is the complete index.html file for comparison:

<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Neon Pong</title>
  <link rel="stylesheet" href="style.css" />
</head>
<body>
  <main class="game-shell">
    <header class="game-header">
      <div><p class="eyebrow">Single-player arcade</p><h1>Neon Pong</h1></div>
      <div class="scoreboard" aria-label="Score" aria-live="polite"><div><span id="playerScore">0</span><small>You</small></div><strong>:</strong><div><span id="computerScore">0</span><small>CPU</small></div></div>
    </header>
    <section class="game-stage" aria-label="Neon Pong game area">
      <canvas id="gameCanvas" width="900" height="500" aria-label="Pong court. Use W and S or the up and down arrow keys to move.">Your browser does not support Canvas. Use a current version of Chrome.</canvas>
      <div class="overlay" id="overlay" aria-live="polite"><div><h2 id="overlayTitle">Ready?</h2><p id="overlayMessage">First to 7 wins.</p></div></div>
    </section>
    <div class="controls"><button id="startButton" type="button">Start game</button><button id="pauseButton" type="button" disabled>Pause</button><button id="restartButton" type="button">Restart</button></div>
    <p class="instructions" id="instructions">Move with W/S or Arrow Up/Arrow Down. Press Space to pause or resume.</p>
  </main>
  <script src="script.js"></script>
</body>
</html>
Style and draw the static court

The stylesheet turns the plain page into an arcade layout that scales with the browser width. The script uses the Canvas 2D context to paint the court, two paddles, and one stationary ball.

  • Switch back to the Visual Studio Code for the Web tab.
  • Select style.css in the editor.
  • Paste this styling into style.css.
:root { color-scheme: dark; --page: #020711; --court: #04101b; --cyan: #37f6ff; --pink: #ff4fd8; --text: #f4fbff; --muted: #91a9ba; }
* { box-sizing: border-box; }
body { min-height: 100vh; margin: 0; display: grid; place-items: center; background: radial-gradient(circle at top, #10213d 0, transparent 42%), var(--page); color: var(--text); font-family: system-ui, sans-serif; }
.game-shell { width: calc(100% - 24px); max-width: 980px; padding: 24px 0; }
.game-header,.scoreboard,.controls { display: flex; }
.game-header { align-items: end; justify-content: space-between; gap: 20px; margin-bottom: 14px; }
.scoreboard { align-items: center; gap: 14px; padding: 10px 18px; border: 1px solid rgb(55 246 255 / 45%); border-radius: 14px; background: rgb(7 20 33 / 85%); }
.scoreboard div { min-width: 54px; text-align: center; }.scoreboard span { display:block; font-size:2rem; font-weight:900; }.scoreboard small,.instructions { color:var(--muted); }
.game-stage { position:relative; overflow:hidden; border:2px solid var(--cyan); border-radius:18px; background:var(--court); box-shadow:0 0 22px rgb(55 246 255 / 30%); }
canvas { width:100%; height:auto; display:block; background:var(--court); }.overlay { position:absolute; inset:0; display:grid; place-items:center; background:rgb(2 7 17 / 72%); text-align:center; }.controls { flex-wrap:wrap; justify-content:center; gap:10px; margin-top:16px; }.instructions { text-align:center; }
@media (max-width:640px) { .game-header { align-items:center; }.scoreboard { gap:8px; padding:8px 10px; } }

What Does This Styling Do?

  • The color variables keep the court, text, cyan paddle, and pink ball on one neon palette.
  • The game-shell width keeps the page inside the browser while capping it at 980px.
  • The game-stage rules create the glowing frame around the court.
  • The Canvas keeps its internal game coordinates while scaling visually to the available width.
  • The responsive rule tightens the scoreboard when the browser is narrower than 640px.
  • Save style.css from the File menu.
  • Switch to the Chrome tab showing index.html.
  • Reload the page.

You should see a centered neon layout with a cyan court border, a dark scoreboard, and a translucent Ready overlay. The court should shrink with the Chrome window without losing its shape.

Still Seeing a Plain Page?

Confirm that style.css is saved in the same Downloads location as index.html.

Check that the stylesheet link uses the exact file name style.css.

Ask for help if the neon presentation is still missing. Help me find why style.css is not loading in my local Neon Pong page.

✔️ Awesome, I've got everything!

Your style.css file now provides the complete neon presentation for this step.

ⓧ I'd like to double check the full code

This is the complete style.css file for comparison:

:root { color-scheme: dark; --page: #020711; --court: #04101b; --cyan: #37f6ff; --pink: #ff4fd8; --text: #f4fbff; --muted: #91a9ba; }
* { box-sizing: border-box; }
body { min-height: 100vh; margin: 0; display: grid; place-items: center; background: radial-gradient(circle at top, #10213d 0, transparent 42%), var(--page); color: var(--text); font-family: system-ui, sans-serif; }
.game-shell { width: calc(100% - 24px); max-width: 980px; padding: 24px 0; }
.game-header,.scoreboard,.controls { display: flex; }
.game-header { align-items: end; justify-content: space-between; gap: 20px; margin-bottom: 14px; }
.scoreboard { align-items: center; gap: 14px; padding: 10px 18px; border: 1px solid rgb(55 246 255 / 45%); border-radius: 14px; background: rgb(7 20 33 / 85%); }
.scoreboard div { min-width: 54px; text-align: center; }.scoreboard span { display:block; font-size:2rem; font-weight:900; }.scoreboard small,.instructions { color:var(--muted); }
.game-stage { position:relative; overflow:hidden; border:2px solid var(--cyan); border-radius:18px; background:var(--court); box-shadow:0 0 22px rgb(55 246 255 / 30%); }
canvas { width:100%; height:auto; display:block; background:var(--court); }.overlay { position:absolute; inset:0; display:grid; place-items:center; background:rgb(2 7 17 / 72%); text-align:center; }.controls { flex-wrap:wrap; justify-content:center; gap:10px; margin-top:16px; }.instructions { text-align:center; }
@media (max-width:640px) { .game-header { align-items:center; }.scoreboard { gap:8px; padding:8px 10px; } }
  • Switch back to the Visual Studio Code for the Web tab.
  • Select script.js in the editor.
  • Paste this static drawing code into script.js.
"use strict";
const canvas = document.getElementById("gameCanvas");
const ctx = canvas.getContext("2d");
const player = { x: 32, y: 204, width: 14, height: 92 };
const computer = { x: canvas.width - 46, y: 204, width: 14, height: 92 };
const ball = { x: 450, y: 250, radius: 9 };
function draw() {
  ctx.fillStyle = "#04101b"; ctx.fillRect(0, 0, canvas.width, canvas.height);
  ctx.fillStyle = "rgba(55, 246, 255, 0.35)"; for (let y = 12; y < canvas.height; y += 28) ctx.fillRect(canvas.width / 2 - 2, y, 4, 15);
  ctx.fillStyle = "#37f6ff"; ctx.fillRect(player.x, player.y, player.width, player.height);
  ctx.fillStyle = "#f4fbff"; ctx.fillRect(computer.x, computer.y, computer.width, computer.height);
  ctx.beginPath(); ctx.arc(ball.x, ball.y, ball.radius, 0, Math.PI * 2); ctx.fillStyle = "#ff4fd8"; ctx.fill();
}
draw();

What Does This Drawing Code Do?

  • The canvas reference finds the drawing area by its gameCanvas identifier.
  • The ctx reference provides the 2d drawing methods used by the game.
  • The player, computer, and ball objects store the stationary dimensions and positions.
  • The draw() function paints the court background, center line, paddles, and ball.
  • The final draw() call renders the complete static scene once.
  • Save script.js from the File menu.

Before you reopen the page, which objects do you expect JavaScript to add inside the neon court?

  • Switch back to ChromeOS Files.
  • Select Downloads.
  • Double-click index.html.

You should see the Neon Pong heading, scoreboard, controls, instructions, and Ready overlay around a stationary court. Through the overlay, you should see two paddles, a center line, and a pink ball.

That is your first visible game state. Chrome is now loading all three local files as one arcade page.

Court Missing Its Paddles or Ball?

Confirm that script.js is saved beside index.html in Downloads.

Check that the script link uses the exact file name script.js. Also confirm the Canvas identifier is gameCanvas in both files.

Ask for help if the court remains empty. Help me debug why my Neon Pong Canvas is not drawing the static court.

✔️ Awesome, I've got everything!

Your script.js file now draws the complete stationary court for this step.

ⓧ I'd like to double check the full code

This is the complete script.js file for comparison:

"use strict";
const canvas = document.getElementById("gameCanvas");
const ctx = canvas.getContext("2d");
const player = { x: 32, y: 204, width: 14, height: 92 };
const computer = { x: canvas.width - 46, y: 204, width: 14, height: 92 };
const ball = { x: 450, y: 250, radius: 9 };
function draw() {
  ctx.fillStyle = "#04101b"; ctx.fillRect(0, 0, canvas.width, canvas.height);
  ctx.fillStyle = "rgba(55, 246, 255, 0.35)"; for (let y = 12; y < canvas.height; y += 28) ctx.fillRect(canvas.width / 2 - 2, y, 4, 15);
  ctx.fillStyle = "#37f6ff"; ctx.fillRect(player.x, player.y, player.width, player.height);
  ctx.fillStyle = "#f4fbff"; ctx.fillRect(computer.x, computer.y, computer.width, computer.height);
  ctx.beginPath(); ctx.arc(ball.x, ball.y, ball.radius, 0, Math.PI * 2); ctx.fillStyle = "#ff4fd8"; ctx.fill();
}
draw();

Your static arcade court is ready. Next, you'll turn the stationary ball into a time-based animation.

Animate the Escaping Ball

Your neon court is visible, but the ball is frozen at the center. A game needs each frame to update the world before the Canvas redraws it.

This step creates a game loop that moves the ball according to elapsed time. You will run that first animation and let its missing rule reveal itself.

In this step, get ready to:
  • Give the ball the state needed for movement.
  • Redraw the court through a timestamp-based animation loop.
  • Reflect the ball from the top and bottom walls.
Define the ball's motion state

The ball needs velocity to describe how far it travels along each axis. Its speed and active state prepare the same object to support later match rules.

  • In script.js from earlier, replace the declarations from const canvas through the current const ball with this code:
const canvas = document.getElementById("gameCanvas");
const ctx = canvas.getContext("2d");

const INITIAL_BALL_SPEED = 340;
const MAX_DELTA = 0.025;

const player = { x: 32, y: 204, width: 14, height: 92 };
const computer = { x: canvas.width - 46, y: 204, width: 14, height: 92 };
const ball = { x: 450, y: 250, radius: 9, vx: 0, vy: 0, speed: INITIAL_BALL_SPEED, active: false };
let lastTime = 0;
let serveDirection = 1;
let verticalDirection = 1;

What Does This State Track?

  • The vx value controls horizontal movement.
  • The vy value controls vertical movement.
  • The speed value keeps the initial pace in one place.
  • The active value records whether the ball is currently moving.
  • The lastTime value stores the previous animation timestamp.

Each redraw must clear the previous pixels before painting the updated scene. This prevents the moving ball from leaving a trail across the court.

  • Replace the existing draw() function in script.js with this version:
function draw() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.fillStyle = "#04101b";
  ctx.fillRect(0, 0, canvas.width, canvas.height);
  ctx.fillStyle = "rgba(55, 246, 255, 0.35)";
  for (let y = 12; y < canvas.height; y += 28) ctx.fillRect(canvas.width / 2 - 2, y, 4, 15);
  ctx.fillStyle = "#37f6ff";
  ctx.fillRect(player.x, player.y, player.width, player.height);
  ctx.fillStyle = "#f4fbff";
  ctx.fillRect(computer.x, computer.y, computer.width, computer.height);
  ctx.beginPath();
  ctx.arc(ball.x, ball.y, ball.radius, 0, Math.PI * 2);
  ctx.fillStyle = "#ff4fd8";
  ctx.fill();
}

Why Redraw the Whole Court?

Each animation frame is a fresh picture. The function clears the previous picture before drawing the background and every game object at its current coordinates.

  • Save script.js.
  • Switch to the browser tab showing index.html from earlier.
  • Refresh the page.

You should see the centered ball and both paddles on a clean court. This confirms that the expanded state still renders correctly.

Court Missing After the State Change?

Check that the player and computer declarations still appear above draw(). Confirm that every object declaration ends with a closing brace.

Ask for help with the declaration error.

A serve converts the stored speed into horizontal and vertical velocity. It also marks the ball as active so later game logic can distinguish motion from a paused serve.

  • Insert this function above draw() in script.js from earlier:
function launchBall() {
  verticalDirection *= -1;
  ball.speed = INITIAL_BALL_SPEED;
  ball.vx = serveDirection * ball.speed;
  ball.vy = verticalDirection * ball.speed * 0.42;
  ball.active = true;
}

How Does the First Serve Start?

  • The horizontal velocity uses serveDirection to choose a side.
  • The vertical velocity adds an angle to the serve.
  • The direction flips before each launch so future serves can vary vertically.
  • Save script.js.
Build the timestamp-based animation loop

A browser can draw frames at different rates on different screens. Delta time measures the elapsed seconds between frames so movement stays tied to time.

The browser's requestAnimationFrame(callback) schedules one callback before a repaint. The callback schedules the next frame to keep the animation running.

  • Replace the final draw(); call at the bottom of script.js with this animation loop:
function loop(timestamp) {
  if (lastTime === 0) lastTime = timestamp;
  let dt = (timestamp - lastTime) / 1000;
  if (dt > MAX_DELTA) dt = MAX_DELTA;
  lastTime = timestamp;
  ball.x += ball.vx * dt;
  ball.y += ball.vy * dt;
  draw();
  window.requestAnimationFrame(loop);
}

launchBall();
window.requestAnimationFrame(loop);

How Does Time-Based Movement Work?

  • The timestamp value records when the current frame begins.
  • The dt value converts the elapsed time into seconds.
  • The MAX_DELTA cap prevents one delayed frame from creating a large jump.
  • Multiplying velocity by dt advances the ball according to elapsed time.
  • The final frame request keeps the redraw cycle running.
  • Save script.js.

Before you refresh, do you think position updates alone can keep the ball inside the court? Hold that prediction.

  • Refresh the browser tab showing index.html.

You will see the ball move diagonally across the court. It passes through an edge because the loop has no boundary response yet.

Ball Still Stationary?

Confirm that launchBall() appears before the first frame request at the bottom of script.js. Check that the frame request also appears inside loop().

Ask for help with the animation loop.

Reflect the ball from the horizontal walls

The escaping ball exposes the missing boundary rules. The first rules reverse its vertical velocity when its edge reaches the top or bottom of the court.

  • In loop() from earlier, find these movement lines:
  ball.x += ball.vx * dt;
  ball.y += ball.vy * dt;

What Are You Locating?

These lines calculate the ball's next horizontal position and vertical position. The boundary checks belong directly after them so they can respond before the frame is drawn.

  • Replace those two lines with this version:
  ball.x += ball.vx * dt;
  ball.y += ball.vy * dt;
  if (ball.y - ball.radius <= 0 && ball.vy < 0) { ball.y = ball.radius; ball.vy *= -1; }
  if (ball.y + ball.radius >= canvas.height && ball.vy > 0) { ball.y = canvas.height - ball.radius; ball.vy *= -1; }

How Do the Wall Checks Work?

  • Each condition includes the ball's radius so the visible edge triggers the reflection.
  • The direction check prevents the same wall from reversing the ball repeatedly.
  • Multiplying vy by -1 sends the ball in the opposite vertical direction.
  • Save script.js.

Before you refresh, do you think two wall checks are enough to keep the ball in play? Hold that prediction.

  • Refresh the browser tab showing index.html.

You will see the ball move across the court and reflect whenever it reaches the top or bottom boundary. It still disappears through a side because side exits and paddle collisions are intentionally absent.

Ball Escaping Through a Horizontal Wall?

Check that both wall conditions sit after the two position updates. Confirm that the top condition uses ball.y - ball.radius while the bottom condition uses ball.y + ball.radius.

Ask for help with the wall reflection.

✔️ Awesome, I've got everything!

Great. Double check that you saved script.js while the ball is moving across the court.

ⓧ I'd like to double check the full code

Compare your complete script.js file with this version:

"use strict";
const canvas = document.getElementById("gameCanvas");
const ctx = canvas.getContext("2d");

const INITIAL_BALL_SPEED = 340;
const MAX_DELTA = 0.025;

const player = { x: 32, y: 204, width: 14, height: 92 };
const computer = { x: canvas.width - 46, y: 204, width: 14, height: 92 };
const ball = { x: 450, y: 250, radius: 9, vx: 0, vy: 0, speed: INITIAL_BALL_SPEED, active: false };
let lastTime = 0;
let serveDirection = 1;
let verticalDirection = 1;

function launchBall() {
  verticalDirection *= -1;
  ball.speed = INITIAL_BALL_SPEED;
  ball.vx = serveDirection * ball.speed;
  ball.vy = verticalDirection * ball.speed * 0.42;
  ball.active = true;
}

function draw() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.fillStyle = "#04101b";
  ctx.fillRect(0, 0, canvas.width, canvas.height);
  ctx.fillStyle = "rgba(55, 246, 255, 0.35)";
  for (let y = 12; y < canvas.height; y += 28) ctx.fillRect(canvas.width / 2 - 2, y, 4, 15);
  ctx.fillStyle = "#37f6ff";
  ctx.fillRect(player.x, player.y, player.width, player.height);
  ctx.fillStyle = "#f4fbff";
  ctx.fillRect(computer.x, computer.y, computer.width, computer.height);
  ctx.beginPath();
  ctx.arc(ball.x, ball.y, ball.radius, 0, Math.PI * 2);
  ctx.fillStyle = "#ff4fd8";
  ctx.fill();
}

function loop(timestamp) {
  if (lastTime === 0) lastTime = timestamp;
  let dt = (timestamp - lastTime) / 1000;
  if (dt > MAX_DELTA) dt = MAX_DELTA;
  lastTime = timestamp;
  ball.x += ball.vx * dt;
  ball.y += ball.vy * dt;
  if (ball.y - ball.radius <= 0 && ball.vy < 0) { ball.y = ball.radius; ball.vy *= -1; }
  if (ball.y + ball.radius >= canvas.height && ball.vy > 0) { ball.y = canvas.height - ball.radius; ball.vy *= -1; }
  draw();
  window.requestAnimationFrame(loop);
}

launchBall();
window.requestAnimationFrame(loop);

Your court now redraws continuously while the ball follows time-based movement. Next, you will give the left paddle control and make it return the escaping ball.

Control Your Paddle

Your last run in Google Chrome proved that the ball can move across the court. It also exposed the intended gap because the ball passes straight through your paddle.

A keyboard state keeps track of which movement keys you are holding. You will use that state to move the player paddle within the Canvas boundaries.

You will also add collision detection for the left paddle. Each hit position will produce a different return angle.

In this step, get ready to:
  • Track held W/S or arrow keys.
  • Move the player paddle within the court.
  • Return the ball from the left paddle at an angle.
Track held keys

A single key press is too brief for smooth paddle movement. Two Boolean values let the game remember whether the upward or downward control is currently held.

  • Switch back to Visual Studio Code for the Web from earlier.
  • Select script.js in the Explorer sidebar.
  • Find the existing player declaration.
  • Replace that declaration with the following block:
const MAX_BALL_SPEED = 520;
const player = { x: 32, y: 0, width: 14, height: 92, speed: 460 };
const keys = { up: false, down: false };

What Does This State Hold?

  • The speed property stores how many Canvas pixels the player can travel per second.
  • The keys object records whether either movement direction is active.
  • MAX_BALL_SPEED limits the extra speed added by paddle returns.

The browser reports physical keyboard positions through event.code. A matching keydown and keyup pair supports smooth movement while a key remains held.

  • Find the final window.requestAnimationFrame(gameLoop); line.
  • Paste the following keyboard code immediately above that line:
function control(code) {
  return code === "KeyW" || code === "KeyS" || code === "ArrowUp" || code === "ArrowDown";
}

window.addEventListener("keydown", (event) => {
  if (control(event.code)) event.preventDefault();
  if (event.code === "KeyW" || event.code === "ArrowUp") keys.up = true;
  else if (event.code === "KeyS" || event.code === "ArrowDown") keys.down = true;
});

window.addEventListener("keyup", (event) => {
  if (control(event.code)) event.preventDefault();
  if (event.code === "KeyW" || event.code === "ArrowUp") keys.up = false;
  else if (event.code === "KeyS" || event.code === "ArrowDown") keys.down = false;
});

How Does Key Tracking Work?

  • control() recognises the four movement key codes used by the game.
  • The keydown listener changes the matching direction to true.
  • The keyup listener changes that direction back to false.
  • preventDefault() stops the arrow keys from scrolling the page while you play.
  • Save script.js.
  • Return to the game in Google Chrome.
  • Refresh index.html.
  • Press Arrow Up.
  • Press Arrow Down.

The page should remain in place when you press either arrow key. That confirms your listeners are intercepting the game controls.

Does the Page Still Scroll?

Confirm that both listeners call event.preventDefault() when control(event.code) returns true.

Check the capital letters in KeyW and KeyS. Keyboard codes are case-sensitive.

Help me inspect my keyboard listeners..

Move and contain the paddle

The animation loop already calculates elapsed time as dt. Multiplying paddle speed by that value keeps movement consistent across different frame rates.

  • Add the following functions above gameLoop(timestamp) in script.js:
function clamp(value, min, max) {
  if (value < min) return min;
  if (value > max) return max;
  return value;
}

function movePaddles(dt) {
  if (keys.up) player.y -= player.speed * dt;
  if (keys.down) player.y += player.speed * dt;
  player.y = clamp(player.y, 0, canvas.height - player.height);
}

What Moves the Paddle?

  • movePaddles(dt) changes the paddle position while a direction is held.
  • Subtracting from player.y moves the paddle upward.
  • Adding to player.y moves the paddle downward.
  • clamp() keeps the full paddle between the top and bottom edges.
  • Find lastTime = timestamp; inside gameLoop(timestamp).
  • Add the paddle update directly below it by making that section match this code:
lastTime = timestamp;
movePaddles(dt);

Why Update Inside the Loop?

The animation loop calls movePaddles(dt) before every redraw. The paddle therefore responds for as long as the matching key state remains active.

Before you test it, which edge do you expect the paddle to stop at when you keep holding a movement key?

  • Save script.js.
  • Return to Google Chrome.
  • Refresh index.html.
  • Hold W or Arrow Up.
  • Hold S or Arrow Down.

You should see the cyan paddle follow each held direction. It should stop with its full height inside the court.

Good. Your player paddle now responds continuously without escaping the Canvas.

Is the Paddle Staying Still?

Confirm that movePaddles(dt) sits inside gameLoop(timestamp) after the delta-time calculation.

Check that the property name speed appears inside the player object.

Help me debug my paddle movement..

Add the first paddle collision

A paddle return begins when the ball overlaps the paddle while travelling left. The vertical hit position becomes a value from -1 to 1.

A hit near the centre produces a flatter return. A hit near either edge produces a steeper return.

  • Add the following collision functions above moveBall(dt) in script.js:
function touches(paddle) {
  return ball.x + ball.radius >= paddle.x && ball.x - ball.radius <= paddle.x + paddle.width &&
    ball.y + ball.radius >= paddle.y && ball.y - ball.radius <= paddle.y + paddle.height;
}

function bounce(paddle, direction) {
  let impact = (ball.y - (paddle.y + paddle.height / 2)) / (paddle.height / 2);
  impact = clamp(impact, -1, 1);
  ball.speed = clamp(ball.speed + 18, INITIAL_BALL_SPEED, MAX_BALL_SPEED);
  ball.vx = direction * ball.speed;
  ball.vy = impact * ball.speed * 0.9;
}

How Does the Return Angle Work?

  • touches(paddle) checks whether the ball overlaps all four paddle boundaries.
  • impact measures the ball against the paddle centre.
  • ball.vx becomes positive for a return toward the right.
  • ball.vy turns the hit position into the vertical return speed.
  • Find the existing moveBall(dt) function.
  • Replace that function with the following version:
function moveBall(dt) {
  ball.x += ball.vx * dt;
  ball.y += ball.vy * dt;
  if (ball.y - ball.radius <= 0 && ball.vy < 0) { ball.y = ball.radius; ball.vy *= -1; }
  if (ball.y + ball.radius >= canvas.height && ball.vy > 0) { ball.y = canvas.height - ball.radius; ball.vy *= -1; }
  if (ball.vx < 0 && touches(player)) { ball.x = player.x + player.width + ball.radius; bounce(player, 1); }
}

What Changed in the Ball Update?

The wall checks still reflect the ball from the top or bottom. The new left-paddle check only runs while the ball travels toward the player.

Moving the ball outside the paddle after contact prevents repeated collisions on consecutive frames. Calling bounce(player, 1) sends the ball back across the court.

Before you refresh, do you expect a centre hit and an edge hit to follow the same path?

  • Save script.js.
  • Return to Google Chrome.
  • Refresh index.html.
  • Hold a movement key to line up the paddle as the ball approaches.
  • Meet the ball near the centre of the paddle.
  • Refresh the page for another return.
  • Meet the ball near one edge of the paddle.

You should see the left paddle remain inside the court. The ball should travel back toward the right after contact.

The centre hit should look flatter. The edge hit should produce a stronger vertical angle.

That is your first playable interaction complete. The computer paddle remains fixed while side exits still leave the ball outside the court.

Does the Ball Pass Through the Paddle?

Confirm that the collision line is inside moveBall(dt) after both wall checks.

Check that the condition uses ball.vx < 0 for the left paddle. Also confirm the function name is exactly touches.

Help me debug the left-paddle collision..

Your side of the court can now defend and redirect the ball. Next, you will bring the computer opponent into the rally and start counting goals.

Add the Computer and Scoring

Your player paddle can now move around the Canvas court and return the ball. The right side still cannot defend its goal or count points.

This step gives the computer a capped movement speed. It also adds the game state needed for scores, delayed serves, and a first-to-7 finish.

In this step, get ready to:
  • Make the computer track incoming shots at a capped speed.
  • Turn goal exits into points with delayed serves.
  • End the match when either side reaches 7 points.
Make the computer defend its side

The computer follows the ball only while it travels toward the right side. Its speed limit keeps each movement gradual enough for the player to beat it.

  • In script.js, replace the existing computer object with this speed-enabled version:
const computer = { x: canvas.width - 46, y: 0, width: 14, height: 92, speed: 330 };

What Does the Speed Control?

The speed value limits how many Canvas units the computer can travel per second. A limit of 330 lets the paddle chase the ball without jumping straight to its destination.

  • Replace the existing movePaddles(dt) function with this version:
function movePaddles(dt) {
  if (keys.up) player.y -= player.speed * dt;
  if (keys.down) player.y += player.speed * dt;
  player.y = clamp(player.y, 0, canvas.height - player.height);

  const target = ball.active && ball.vx > 0 ? ball.y - computer.height / 2 : (canvas.height - computer.height) / 2;
  const difference = target - computer.y;
  const step = computer.speed * dt;
  if (difference > 8) computer.y += difference > step ? step : difference;
  if (difference < -8) computer.y += difference < -step ? -step : difference;
  computer.y = clamp(computer.y, 0, canvas.height - computer.height);
}

How Does the Computer Track the Ball?

  • The target follows the ball when its horizontal velocity points right.
  • The difference measures the distance between the target and the computer paddle.
  • The step value caps movement using elapsed time.
  • The final clamp() call keeps the computer inside the court.

Before you refresh, do you think the computer will follow the ball during its entire journey or only while the ball approaches the right side?

  • Save script.js.
  • Switch back to Google Chrome.
  • Refresh index.html.

You will see the computer move toward incoming shots. The ball still passes through its paddle because the right-side collision is the next missing piece.

  • In moveBall(dt), add this right-paddle collision directly below the existing left-paddle collision:
  if (ball.vx < 0 && touches(player)) { ball.x = player.x + player.width + ball.radius; bounce(player, 1); }
  if (ball.vx > 0 && touches(computer)) { ball.x = computer.x - ball.radius; bounce(computer, -1); }

What Does the Right Collision Do?

The velocity check limits this collision to right-moving shots. The position reset places the ball outside the paddle before bounce() reverses its horizontal direction.

  • Save script.js.
  • Refresh index.html.
  • Watch one incoming shot reach the computer paddle.

The computer now follows the ball and returns shots on contact. That is the right side of the rally working.

Does the Ball Pass Through the Computer?

Check that the new collision uses computer inside touches(). Confirm that its bounce direction is -1.

Make sure the collision line sits inside moveBall(dt) after the ball position updates.

Help me debug the computer paddle collision.

Turn goal exits into points and serves

A scored point needs more than a number change. The game must update the scoreboard, reset the court, pause at the center, and launch the next serve.

  • Add this scoring data beside the existing game constants and state variables:
const WINNING_SCORE = 7;
const scores = { player: 0, computer: 0 };
let state = "ready";
let serveDelay = 0;
let serveDirection = 1;
let verticalDirection = 1;

What Does the Match Track?

  • The scores object stores both sides of the scoreboard.
  • The state value records whether the match can update.
  • The serve variables hold the delay and launch directions for the next round.
  • The WINNING_SCORE constant sets the finish line at 7 points.
  • Add these serve helpers above showOverlay() in script.js:
function centerRound() {
  player.y = (canvas.height - player.height) / 2;
  computer.y = (canvas.height - computer.height) / 2;
  ball.x = canvas.width / 2;
  ball.y = canvas.height / 2;
  ball.vx = 0;
  ball.vy = 0;
  ball.active = false;
}

function prepareServe(direction, delay) {
  centerRound();
  serveDirection = direction;
  serveDelay = delay;
}

function launchBall() {
  verticalDirection *= -1;
  ball.speed = INITIAL_BALL_SPEED;
  ball.vx = serveDirection * ball.speed;
  ball.vy = verticalDirection * ball.speed * 0.42;
  ball.active = true;
}

How Does a New Round Begin?

  • The centerRound() function centers both paddles and makes the ball inactive.
  • The prepareServe() function records the next horizontal direction and delay.
  • The launchBall() function restores the starting speed and activates the ball.
  • The changing vertical direction keeps consecutive serves from following the same diagonal path.
  • Add these scoring functions below showOverlay() in script.js:
function updateScores() {
  playerScore.textContent = scores.player;
  computerScore.textContent = scores.computer;
}

function point(winner) {
  scores[winner] += 1;
  updateScores();
  if (scores[winner] >= WINNING_SCORE) {
    state = "over";
    ball.active = false;
    const name = winner === "player" ? "You" : "CPU";
    showOverlay(`${name} win!`, `Final score: ${scores.player} to ${scores.computer}`, true);
    return;
  }
  prepareServe(winner === "player" ? 1 : -1, 0.85);
}

How Does a Point Change the Match?

The point() function increments the named side before updating the visible score. A score below 7 prepares another serve after a short in-game delay.

A score of 7 changes the state to over. It also displays the winner and final score in the existing overlay.

  • Replace moveBall(dt) and update(dt) with these scoring-aware versions:
function moveBall(dt) {
  ball.x += ball.vx * dt;
  ball.y += ball.vy * dt;
  if (ball.y - ball.radius <= 0 && ball.vy < 0) { ball.y = ball.radius; ball.vy *= -1; }
  if (ball.y + ball.radius >= canvas.height && ball.vy > 0) { ball.y = canvas.height - ball.radius; ball.vy *= -1; }
  if (ball.vx < 0 && touches(player)) { ball.x = player.x + player.width + ball.radius; bounce(player, 1); }
  if (ball.vx > 0 && touches(computer)) { ball.x = computer.x - ball.radius; bounce(computer, -1); }
  if (ball.x + ball.radius < 0) point("computer");
  else if (ball.x - ball.radius > canvas.width) point("player");
}

function update(dt) {
  movePaddles(dt);
  if (!ball.active) {
    serveDelay -= dt;
    if (serveDelay <= 0) launchBall();
  } else moveBall(dt);
}

How Do Goals Trigger the Next Serve?

A left-side exit awards the computer a point. A right-side exit awards the player a point.

An inactive ball counts down serveDelay during each update. The next rally starts when that value reaches zero.

  • Inside loop(timestamp), replace the unconditional update call with this state check:
  if (state === "running") update(dt);

Why Guard the Update?

The loop keeps drawing every frame. Game movement only advances while state is running, so the over state freezes the finished match.

  • At the bottom of script.js, replace the existing animation request with this initial serve setup:
prepareServe(1, 0.5);
state = "running";
window.requestAnimationFrame(loop);

What Starts the Match?

The first serve begins from the center after a 0.5 second in-game delay. The animation loop then keeps drawing and updating the running match.

Before you refresh, which score do you expect to change if the ball escapes beyond the computer paddle?

  • Save script.js.
  • Refresh index.html.
  • Play until one side scores a point.

You will see the matching score increase. The ball pauses at the center before the next serve begins.

Does the Score Stay at Zero?

Confirm that both side-exit checks remain inside moveBall(dt). Check that updateScores() assigns both stored scores to their matching page elements.

If the ball stays at the center, confirm that serveDelay decreases inside update(dt).

Help me debug Neon Pong scoring and delayed serves.

Prove the first-to-7 finish

The match now has a clear finish line. Reaching it must stop movement and preserve the winner overlay instead of starting another serve.

Before the final point lands, what should remain visible once either score reaches 7?

  • Continue playing until either side reaches 7 points.
  • Check the winner named in the overlay.
  • Compare the final overlay score with the scoreboard.

You will see either You win! or CPU win! above the final score. The court remains frozen because the match is now in the over state.

Does the Match Continue After 7?

Check that point(winner) sets state to over before returning. Confirm that loop(timestamp) only calls update(dt) for the running state.

Help me stop the Neon Pong match at 7 points.

✔️ Awesome, I've got everything!

Your computer opponent, scoring, delayed serves, and first-to-7 finish are working. Save script.js before moving on.

ⓧ I'd like to double check the full code

Compare your cumulative script.js with this version:

"use strict";

const canvas = document.getElementById("gameCanvas");
const ctx = canvas.getContext("2d");
const playerScore = document.getElementById("playerScore");
const computerScore = document.getElementById("computerScore");
const overlay = document.getElementById("overlay");
const overlayTitle = document.getElementById("overlayTitle");
const overlayMessage = document.getElementById("overlayMessage");

const WINNING_SCORE = 7;
const INITIAL_BALL_SPEED = 340;
const MAX_BALL_SPEED = 520;
const MAX_DELTA = 0.025;
const player = { x: 32, y: 0, width: 14, height: 92, speed: 460 };
const computer = { x: canvas.width - 46, y: 0, width: 14, height: 92, speed: 330 };
const ball = { x: 450, y: 250, radius: 9, vx: 0, vy: 0, speed: INITIAL_BALL_SPEED, active: false };
const scores = { player: 0, computer: 0 };
const keys = { up: false, down: false, pause: false };
let state = "ready";
let lastTime = 0;
let serveDelay = 0;
let serveDirection = 1;
let verticalDirection = 1;

function clamp(value, min, max) {
  if (value < min) return min;
  if (value > max) return max;
  return value;
}

function centerRound() {
  player.y = (canvas.height - player.height) / 2;
  computer.y = (canvas.height - computer.height) / 2;
  ball.x = canvas.width / 2;
  ball.y = canvas.height / 2;
  ball.vx = 0;
  ball.vy = 0;
  ball.active = false;
}

function prepareServe(direction, delay) {
  centerRound();
  serveDirection = direction;
  serveDelay = delay;
}

function launchBall() {
  verticalDirection *= -1;
  ball.speed = INITIAL_BALL_SPEED;
  ball.vx = serveDirection * ball.speed;
  ball.vy = verticalDirection * ball.speed * 0.42;
  ball.active = true;
}

function showOverlay(title, message, visible) {
  overlayTitle.textContent = title;
  overlayMessage.textContent = message;
  overlay.hidden = !visible;
}

function updateScores() {
  playerScore.textContent = scores.player;
  computerScore.textContent = scores.computer;
}

function point(winner) {
  scores[winner] += 1;
  updateScores();
  if (scores[winner] >= WINNING_SCORE) {
    state = "over";
    ball.active = false;
    const name = winner === "player" ? "You" : "CPU";
    showOverlay(`${name} win!`, `Final score: ${scores.player} to ${scores.computer}`, true);
    return;
  }
  prepareServe(winner === "player" ? 1 : -1, 0.85);
}

function movePaddles(dt) {
  if (keys.up) player.y -= player.speed * dt;
  if (keys.down) player.y += player.speed * dt;
  player.y = clamp(player.y, 0, canvas.height - player.height);

  const target = ball.active && ball.vx > 0 ? ball.y - computer.height / 2 : (canvas.height - computer.height) / 2;
  const difference = target - computer.y;
  const step = computer.speed * dt;
  if (difference > 8) computer.y += difference > step ? step : difference;
  if (difference < -8) computer.y += difference < -step ? -step : difference;
  computer.y = clamp(computer.y, 0, canvas.height - computer.height);
}

function touches(paddle) {
  return ball.x + ball.radius >= paddle.x && ball.x - ball.radius <= paddle.x + paddle.width &&
    ball.y + ball.radius >= paddle.y && ball.y - ball.radius <= paddle.y + paddle.height;
}

function bounce(paddle, direction) {
  let impact = (ball.y - (paddle.y + paddle.height / 2)) / (paddle.height / 2);
  impact = clamp(impact, -1, 1);
  ball.speed = clamp(ball.speed + 18, INITIAL_BALL_SPEED, MAX_BALL_SPEED);
  ball.vx = direction * ball.speed;
  ball.vy = impact * ball.speed * 0.9;
}

function moveBall(dt) {
  ball.x += ball.vx * dt;
  ball.y += ball.vy * dt;
  if (ball.y - ball.radius <= 0 && ball.vy < 0) { ball.y = ball.radius; ball.vy *= -1; }
  if (ball.y + ball.radius >= canvas.height && ball.vy > 0) { ball.y = canvas.height - ball.radius; ball.vy *= -1; }
  if (ball.vx < 0 && touches(player)) { ball.x = player.x + player.width + ball.radius; bounce(player, 1); }
  if (ball.vx > 0 && touches(computer)) { ball.x = computer.x - ball.radius; bounce(computer, -1); }
  if (ball.x + ball.radius < 0) point("computer");
  else if (ball.x - ball.radius > canvas.width) point("player");
}

function update(dt) {
  movePaddles(dt);
  if (!ball.active) {
    serveDelay -= dt;
    if (serveDelay <= 0) launchBall();
  } else moveBall(dt);
}

function draw() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.fillStyle = "#04101b";
  ctx.fillRect(0, 0, canvas.width, canvas.height);
  ctx.fillStyle = "rgba(55, 246, 255, 0.35)";
  for (let y = 12; y < canvas.height; y += 28) ctx.fillRect(canvas.width / 2 - 2, y, 4, 15);
  ctx.fillStyle = "#37f6ff";
  ctx.fillRect(player.x, player.y, player.width, player.height);
  ctx.fillStyle = "#f4fbff";
  ctx.fillRect(computer.x, computer.y, computer.width, computer.height);
  ctx.beginPath();
  ctx.arc(ball.x, ball.y, ball.radius, 0, Math.PI * 2);
  ctx.fillStyle = "#ff4fd8";
  ctx.fill();
}

function loop(timestamp) {
  if (lastTime === 0) lastTime = timestamp;
  let dt = (timestamp - lastTime) / 1000;
  if (dt > MAX_DELTA) dt = MAX_DELTA;
  lastTime = timestamp;
  if (state === "running") update(dt);
  draw();
  window.requestAnimationFrame(loop);
}

function control(code) {
  return code === "KeyW" || code === "KeyS" || code === "ArrowUp" || code === "ArrowDown" || code === "Space";
}

window.addEventListener("keydown", (event) => {
  if (control(event.code)) event.preventDefault();
  if (event.code === "KeyW" || event.code === "ArrowUp") keys.up = true;
  else if (event.code === "KeyS" || event.code === "ArrowDown") keys.down = true;
});

window.addEventListener("keyup", (event) => {
  if (control(event.code)) event.preventDefault();
  if (event.code === "KeyW" || event.code === "ArrowUp") keys.up = false;
  else if (event.code === "KeyS" || event.code === "ArrowDown") keys.down = false;
});

centerRound();
updateScores();
showOverlay("", "", false);
prepareServe(1, 0.5);
state = "running";
window.requestAnimationFrame(loop);

Your computer opponent can defend, score, serve, and finish a match. Next up, you will connect the arcade controls and complete the responsive presentation.

Finish the Arcade Experience

Your computer opponent can now defend its goal. The scoreboard tracks each point until the match reaches its winning score.

A finished game needs predictable controls across every state. It also needs to fit inside Google Chrome without changing the court's internal coordinates.

In this step, get ready to:
  • Finish the accessible game interface.
  • Connect the start, pause, resume, and restart controls.
  • Make the court responsive and verify the complete game.
Finish the accessible game interface

The Canvas displays the court as pixels. The surrounding HTML provides live score text, normal buttons, overlay messages, and fallback text.

  • Return to the index.html tab in Visual Studio Code for the Web.
  • Replace the existing <body> section with this finished interface:
<body>
  <main class="game-shell">
    <header class="game-header">
      <div><p class="eyebrow">Single-player arcade</p><h1>Neon Pong</h1></div>
      <div class="scoreboard" aria-label="Score" aria-live="polite">
        <div><span id="playerScore">0</span><small>You</small></div>
        <strong>:</strong>
        <div><span id="computerScore">0</span><small>CPU</small></div>
      </div>
    </header>

    <section class="game-stage" aria-label="Neon Pong game area">
      <canvas id="gameCanvas" width="900" height="500"
        aria-label="Pong court. Use W and S or the up and down arrow keys to move.">
        Your browser does not support Canvas. Use a current version of Chrome.
      </canvas>
      <div class="overlay" id="overlay" aria-live="polite">
        <div><h2 id="overlayTitle">Ready?</h2><p id="overlayMessage">First to 7 wins.</p></div>
      </div>
    </section>

    <div class="controls">
      <button id="startButton" type="button">Start game</button>
      <button id="pauseButton" type="button" disabled>Pause</button>
      <button id="restartButton" type="button">Restart</button>
    </div>
    <p class="instructions" id="instructions">Move with W/S or Arrow Up/Arrow Down. Press Space to pause or resume.</p>
  </main>
  <script src="script.js"></script>
</body>

What does this markup provide?

  • The score uses live text so updates can be announced without placing text inside the Canvas.
  • The Canvas includes a description of the movement controls.
  • The fallback sentence gives learners a useful message when Canvas rendering is unavailable.
  • The three buttons expose every match action as a normal browser control.
  • Save index.html.
  • Return to the existing game tab in Chrome.
  • Refresh the page.

You'll see the Neon Pong heading, both scores, the Ready overlay, and three control buttons. The instruction line lists both movement options and the Space control.

Missing part of the interface?

Confirm that every opening HTML tag has a matching closing tag. Check that the script reference still points to script.js.

Make sure index.html, style.css, and script.js remain in the same location.

help me compare my Neon Pong HTML with the expected interface

✔️ Awesome, I've got everything!

Great. Double check that you saved index.html with its scoreboard, Canvas fallback, overlay, controls, and instructions.

ⓧ I'd like to double check the full code

Compare your complete index.html file with this reference:

<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Neon Pong</title>
  <link rel="stylesheet" href="style.css" />
</head>
<body>
  <main class="game-shell">
    <header class="game-header">
      <div><p class="eyebrow">Single-player arcade</p><h1>Neon Pong</h1></div>
      <div class="scoreboard" aria-label="Score" aria-live="polite">
        <div><span id="playerScore">0</span><small>You</small></div>
        <strong>:</strong>
        <div><span id="computerScore">0</span><small>CPU</small></div>
      </div>
    </header>

    <section class="game-stage" aria-label="Neon Pong game area">
      <canvas id="gameCanvas" width="900" height="500"
        aria-label="Pong court. Use W and S or the up and down arrow keys to move.">
        Your browser does not support Canvas. Use a current version of Chrome.
      </canvas>
      <div class="overlay" id="overlay" aria-live="polite">
        <div><h2 id="overlayTitle">Ready?</h2><p id="overlayMessage">First to 7 wins.</p></div>
      </div>
    </section>

    <div class="controls">
      <button id="startButton" type="button">Start game</button>
      <button id="pauseButton" type="button" disabled>Pause</button>
      <button id="restartButton" type="button">Restart</button>
    </div>
    <p class="instructions" id="instructions">Move with W/S or Arrow Up/Arrow Down. Press Space to pause or resume.</p>
  </main>
  <script src="script.js"></script>
</body>
</html>
Connect the arcade controls

A game state records whether the match is ready, running, paused, or over. Each control changes that shared state so the animation loop remains the only loop.

  • Return to the script.js tab.
  • Find the existing updateScores() function.
  • Add these control functions immediately below it:
function updateButtons() {
  const active = state === "running" || state === "paused";
  startButton.disabled = active;
  startButton.textContent = state === "over" ? "Play again" : "Start game";
  pauseButton.disabled = !active;
  pauseButton.textContent = state === "paused" ? "Resume" : "Pause";
}

function startGame() {
  scores.player = 0;
  scores.computer = 0;
  updateScores();
  state = "running";
  lastTime = 0;
  showOverlay("", "", false);
  prepareServe(1, 0.5);
  updateButtons();
}

function togglePause() {
  if (state === "running") {
    state = "paused";
    showOverlay("Paused", "Press Space or Resume to continue.", true);
  } else if (state === "paused") {
    state = "running";
    lastTime = 0;
    showOverlay("", "", false);
  }
  updateButtons();
}

How do the states control the match?

  • The active value identifies states where the Start control must stay disabled.
  • The startGame() function resets both scores before preparing a new serve.
  • The togglePause() function switches between the running and paused states.
  • Resetting lastTime prevents paused time from becoming one large movement update.
  • Save script.js.
  • Refresh the existing game tab.

You'll still see the Ready overlay and the stationary starting positions. This confirms the new functions load without stopping the existing game.

Game disappeared after the edit?

Check the closing brace for each new function. Make sure the functions sit outside updateScores().

help me find a syntax problem in my arcade control functions

The functions are ready to respond once keyboard and button events call them. The final event wiring also starts the continuous animation exactly once.

  • Find the existing control(code) function near the bottom of script.js.
  • Replace that function, both keyboard listeners, and the initialization below them with this finished version:
function control(code) {
  return code === "KeyW" || code === "KeyS" || code === "ArrowUp" || code === "ArrowDown" || code === "Space";
}

window.addEventListener("keydown", (event) => {
  if (control(event.code)) event.preventDefault();
  if (event.code === "KeyW" || event.code === "ArrowUp") keys.up = true;
  else if (event.code === "KeyS" || event.code === "ArrowDown") keys.down = true;
  else if (event.code === "Space" && !keys.pause) { keys.pause = true; togglePause(); }
});

window.addEventListener("keyup", (event) => {
  if (control(event.code)) event.preventDefault();
  if (event.code === "KeyW" || event.code === "ArrowUp") keys.up = false;
  else if (event.code === "KeyS" || event.code === "ArrowDown") keys.down = false;
  else if (event.code === "Space") keys.pause = false;
});

startButton.addEventListener("click", startGame);
pauseButton.addEventListener("click", togglePause);
restartButton.addEventListener("click", startGame);
centerRound();
updateScores();
showOverlay("Ready?", "First to 7 wins.", true);
updateButtons();
window.requestAnimationFrame(loop);

Why does this avoid duplicate loops?

  • The keyboard listeners preserve held-key movement while adding Space as a pause control.
  • The keys.pause guard prevents one held press from toggling the state repeatedly.
  • The button listeners call state functions without starting another animation schedule.
  • The single bottom call starts loop() once. The loop schedules each following frame itself.
  • Save script.js.
  • Refresh the game tab.
  • Select Start game.
  • Select Pause.

You'll see the ball begin moving after Start game. The paused overlay appears after you select Pause.

The Pause control changes to Resume while the game is paused. Start game remains disabled during the active match.

Controls responding more than once?

Check that each button has one matching addEventListener() call. Remove any older event listeners left below the replacement.

Keep one window.requestAnimationFrame(loop) call in the initialization. Starting the loop inside startGame() creates duplicate schedules.

help me debug duplicate Neon Pong controls or animation loops

✔️ Awesome, I've got everything!

Your state controls now share one animation loop. Save script.js before moving to the responsive styling.

ⓧ I'd like to double check the full code

Compare your complete script.js file with this reference:

"use strict";

const canvas = document.getElementById("gameCanvas");
const ctx = canvas.getContext("2d");
const playerScore = document.getElementById("playerScore");
const computerScore = document.getElementById("computerScore");
const overlay = document.getElementById("overlay");
const overlayTitle = document.getElementById("overlayTitle");
const overlayMessage = document.getElementById("overlayMessage");
const startButton = document.getElementById("startButton");
const pauseButton = document.getElementById("pauseButton");
const restartButton = document.getElementById("restartButton");

const WINNING_SCORE = 7;
const INITIAL_BALL_SPEED = 340;
const MAX_BALL_SPEED = 520;
const MAX_DELTA = 0.025;
const player = { x: 32, y: 0, width: 14, height: 92, speed: 460 };
const computer = { x: canvas.width - 46, y: 0, width: 14, height: 92, speed: 330 };
const ball = { x: 450, y: 250, radius: 9, vx: 0, vy: 0, speed: INITIAL_BALL_SPEED, active: false };
const scores = { player: 0, computer: 0 };
const keys = { up: false, down: false, pause: false };
let state = "ready";
let lastTime = 0;
let serveDelay = 0;
let serveDirection = 1;
let verticalDirection = 1;

function clamp(value, min, max) {
  if (value < min) return min;
  if (value > max) return max;
  return value;
}

function centerRound() {
  player.y = (canvas.height - player.height) / 2;
  computer.y = (canvas.height - computer.height) / 2;
  ball.x = canvas.width / 2;
  ball.y = canvas.height / 2;
  ball.vx = 0;
  ball.vy = 0;
  ball.active = false;
}

function prepareServe(direction, delay) {
  centerRound();
  serveDirection = direction;
  serveDelay = delay;
}

function launchBall() {
  verticalDirection *= -1;
  ball.speed = INITIAL_BALL_SPEED;
  ball.vx = serveDirection * ball.speed;
  ball.vy = verticalDirection * ball.speed * 0.42;
  ball.active = true;
}

function showOverlay(title, message, visible) {
  overlayTitle.textContent = title;
  overlayMessage.textContent = message;
  overlay.hidden = !visible;
}

function updateScores() {
  playerScore.textContent = scores.player;
  computerScore.textContent = scores.computer;
}

function updateButtons() {
  const active = state === "running" || state === "paused";
  startButton.disabled = active;
  startButton.textContent = state === "over" ? "Play again" : "Start game";
  pauseButton.disabled = !active;
  pauseButton.textContent = state === "paused" ? "Resume" : "Pause";
}

function startGame() {
  scores.player = 0;
  scores.computer = 0;
  updateScores();
  state = "running";
  lastTime = 0;
  showOverlay("", "", false);
  prepareServe(1, 0.5);
  updateButtons();
}

function togglePause() {
  if (state === "running") {
    state = "paused";
    showOverlay("Paused", "Press Space or Resume to continue.", true);
  } else if (state === "paused") {
    state = "running";
    lastTime = 0;
    showOverlay("", "", false);
  }
  updateButtons();
}

function point(winner) {
  scores[winner] += 1;
  updateScores();
  if (scores[winner] >= WINNING_SCORE) {
    state = "over";
    ball.active = false;
    const name = winner === "player" ? "You" : "CPU";
    showOverlay(`${name} win!`, `Final score: ${scores.player} to ${scores.computer}`, true);
    updateButtons();
    return;
  }
  prepareServe(winner === "player" ? 1 : -1, 0.85);
}

function movePaddles(dt) {
  if (keys.up) player.y -= player.speed * dt;
  if (keys.down) player.y += player.speed * dt;
  player.y = clamp(player.y, 0, canvas.height - player.height);

  const target = ball.active && ball.vx > 0 ? ball.y - computer.height / 2 : (canvas.height - computer.height) / 2;
  const difference = target - computer.y;
  const step = computer.speed * dt;
  if (difference > 8) computer.y += difference > step ? step : difference;
  if (difference < -8) computer.y += difference < -step ? -step : difference;
  computer.y = clamp(computer.y, 0, canvas.height - computer.height);
}

function touches(paddle) {
  return ball.x + ball.radius >= paddle.x && ball.x - ball.radius <= paddle.x + paddle.width &&
    ball.y + ball.radius >= paddle.y && ball.y - ball.radius <= paddle.y + paddle.height;
}

function bounce(paddle, direction) {
  let impact = (ball.y - (paddle.y + paddle.height / 2)) / (paddle.height / 2);
  impact = clamp(impact, -1, 1);
  ball.speed = clamp(ball.speed + 18, INITIAL_BALL_SPEED, MAX_BALL_SPEED);
  ball.vx = direction * ball.speed;
  ball.vy = impact * ball.speed * 0.9;
}

function moveBall(dt) {
  ball.x += ball.vx * dt;
  ball.y += ball.vy * dt;
  if (ball.y - ball.radius <= 0 && ball.vy < 0) { ball.y = ball.radius; ball.vy *= -1; }
  if (ball.y + ball.radius >= canvas.height && ball.vy > 0) { ball.y = canvas.height - ball.radius; ball.vy *= -1; }
  if (ball.vx < 0 && touches(player)) { ball.x = player.x + player.width + ball.radius; bounce(player, 1); }
  if (ball.vx > 0 && touches(computer)) { ball.x = computer.x - ball.radius; bounce(computer, -1); }
  if (ball.x + ball.radius < 0) point("computer");
  else if (ball.x - ball.radius > canvas.width) point("player");
}

function update(dt) {
  movePaddles(dt);
  if (!ball.active) {
    serveDelay -= dt;
    if (serveDelay <= 0) launchBall();
  } else moveBall(dt);
}

function draw() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.fillStyle = "#04101b";
  ctx.fillRect(0, 0, canvas.width, canvas.height);
  ctx.fillStyle = "rgba(55, 246, 255, 0.35)";
  for (let y = 12; y < canvas.height; y += 28) ctx.fillRect(canvas.width / 2 - 2, y, 4, 15);
  ctx.fillStyle = "#37f6ff";
  ctx.fillRect(player.x, player.y, player.width, player.height);
  ctx.fillStyle = "#f4fbff";
  ctx.fillRect(computer.x, computer.y, computer.width, computer.height);
  ctx.beginPath();
  ctx.arc(ball.x, ball.y, ball.radius, 0, Math.PI * 2);
  ctx.fillStyle = "#ff4fd8";
  ctx.fill();
}

function loop(timestamp) {
  if (lastTime === 0) lastTime = timestamp;
  let dt = (timestamp - lastTime) / 1000;
  if (dt > MAX_DELTA) dt = MAX_DELTA;
  lastTime = timestamp;
  if (state === "running") update(dt);
  draw();
  window.requestAnimationFrame(loop);
}

function control(code) {
  return code === "KeyW" || code === "KeyS" || code === "ArrowUp" || code === "ArrowDown" || code === "Space";
}

window.addEventListener("keydown", (event) => {
  if (control(event.code)) event.preventDefault();
  if (event.code === "KeyW" || event.code === "ArrowUp") keys.up = true;
  else if (event.code === "KeyS" || event.code === "ArrowDown") keys.down = true;
  else if (event.code === "Space" && !keys.pause) { keys.pause = true; togglePause(); }
});

window.addEventListener("keyup", (event) => {
  if (control(event.code)) event.preventDefault();
  if (event.code === "KeyW" || event.code === "ArrowUp") keys.up = false;
  else if (event.code === "KeyS" || event.code === "ArrowDown") keys.down = false;
  else if (event.code === "Space") keys.pause = false;
});

startButton.addEventListener("click", startGame);
pauseButton.addEventListener("click", togglePause);
restartButton.addEventListener("click", startGame);
centerRound();
updateScores();
showOverlay("Ready?", "First to 7 wins.", true);
updateButtons();
window.requestAnimationFrame(loop);
Make the court responsive

The Canvas keeps a 900 by 500 drawing surface for collision calculations. CSS scales that surface visually so the same coordinates work at different browser sizes.

  • Return to the style.css tab.
  • Update the matching layout rules to these final responsive values:
.game-shell { width: calc(100% - 24px); max-width: 980px; padding: 24px 0; }
.game-header { display: flex; align-items: end; justify-content: space-between; gap: 20px; margin-bottom: 14px; }
canvas { width: 100%; height: auto; display: block; background: var(--court); }

.controls { display: flex; flex-wrap: wrap; justify-content: center; gap: 10px; margin-top: 16px; }
.instructions { margin: 12px 0 0; color: var(--muted); text-align: center; }

@media (max-width: 640px) {
  .game-header { align-items: center; }
  .scoreboard { gap: 8px; padding: 8px 10px; }
  .scoreboard div { min-width: 42px; }
  .scoreboard span { font-size: 1.55rem; }
}

How does the scaling work?

  • The game shell leaves a small gap around the page while limiting the widest layout.
  • The Canvas uses the available width while its automatic height preserves the 900 by 500 aspect ratio.
  • The controls wrap when the available row becomes too narrow.
  • The media query reduces scoreboard spacing on screens up to 640 pixels wide.
  • Save style.css.
  • Refresh the game tab.
  • Narrow the Chrome window.

You'll see the court shrink while remaining fully visible. The scoreboard becomes more compact when the page crosses the smaller-screen layout.

Court overflowing the window?

Check that the Canvas uses width: 100% with height: auto. Keep the HTML width and height attributes at 900 and 500.

Confirm that the media query closes with its final brace. A missing brace can stop the rules below it from applying correctly.

help me fix my overflowing Neon Pong Canvas

✔️ Awesome, I've got everything!

Your court now scales with the browser while preserving its internal game coordinates.

ⓧ I'd like to double check the full code

Compare your complete style.css file with this reference:

:root {
  color-scheme: dark;
  --page: #020711;
  --court: #04101b;
  --cyan: #37f6ff;
  --pink: #ff4fd8;
  --text: #f4fbff;
  --muted: #91a9ba;
}

* { box-sizing: border-box; }

body {
  min-height: 100vh;
  margin: 0;
  display: grid;
  place-items: center;
  background: radial-gradient(circle at top, #10213d 0, transparent 42%), var(--page);
  color: var(--text);
  font-family: system-ui, sans-serif;
}

button { font: inherit; }
.game-shell { width: calc(100% - 24px); max-width: 980px; padding: 24px 0; }
.game-header { display: flex; align-items: end; justify-content: space-between; gap: 20px; margin-bottom: 14px; }
.eyebrow { margin: 0 0 4px; color: var(--cyan); font-size: .78rem; font-weight: 800; letter-spacing: .16em; text-transform: uppercase; }
h1, h2, p { margin-top: 0; }
h1 { margin-bottom: 0; font-size: clamp(2rem, 7vw, 4rem); line-height: .95; text-transform: uppercase; }

.scoreboard {
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 10px 18px;
  border: 1px solid rgb(55 246 255 / 45%);
  border-radius: 14px;
  background: rgb(7 20 33 / 85%);
}
.scoreboard div { min-width: 54px; text-align: center; }
.scoreboard span { display: block; font-size: 2rem; font-weight: 900; line-height: 1; }
.scoreboard small { color: var(--muted); text-transform: uppercase; }

.game-stage {
  position: relative;
  overflow: hidden;
  border: 2px solid var(--cyan);
  border-radius: 18px;
  background: var(--court);
  box-shadow: 0 0 22px rgb(55 246 255 / 30%);
}
canvas { width: 100%; height: auto; display: block; background: var(--court); }
.overlay { position: absolute; inset: 0; display: grid; place-items: center; padding: 20px; background: rgb(2 7 17 / 72%); text-align: center; }
.overlay div { padding: 24px 34px; border: 1px solid rgb(255 79 216 / 55%); border-radius: 16px; background: rgb(7 20 33 / 92%); }
.overlay h2 { margin-bottom: 8px; color: var(--pink); font-size: 2rem; }
.overlay p { margin-bottom: 0; color: var(--muted); }
[hidden] { display: none !important; }

.controls { display: flex; flex-wrap: wrap; justify-content: center; gap: 10px; margin-top: 16px; }
button { min-width: 130px; padding: 11px 16px; border: 1px solid var(--cyan); border-radius: 10px; background: #0b2233; color: var(--text); font-weight: 800; cursor: pointer; }
button:hover:not(:disabled) { background: #12364f; }
button:disabled { cursor: not-allowed; opacity: .45; }
.instructions { margin: 12px 0 0; color: var(--muted); text-align: center; }

@media (max-width: 640px) {
  .game-header { align-items: center; }
  .scoreboard { gap: 8px; padding: 8px 10px; }
  .scoreboard div { min-width: 42px; }
  .scoreboard span { font-size: 1.55rem; }
}

Before the final check, which controls do you expect to change the current state without starting another animation loop?

  • Double-click index.html in ChromeOS Files.
  • Select Start game.
  • Select Pause.
  • Select Resume.
  • Press Space.
  • Press Space again.
  • Select Restart.
  • Resize the Chrome window.

You'll see the Ready overlay disappear when the match starts. The Pause control changes to Resume while the paused overlay is visible.

Space toggles the same paused state. Restart resets both scores.

Restart also begins a new serve. The complete court remains visible as the browser changes size.

That's the full arcade experience working. Your three local files now deliver a responsive player-versus-computer match from start to finish.

Secret mission

Add Selectable AI Difficulty

Give Neon Pong three computer opponents without duplicating the movement logic. The 1, 2, and 3 keys will select Easy, Normal, or Hard before each round.

Clean Up Your Resources

Clean Up Your Resources

Your Neon Pong game lives entirely in three local files, so it creates no billable resources or ongoing costs. Choose whether to keep the game, close it for now, or delete it entirely.

Resources you used:

  • The local index.html file stored in Downloads.
  • The local style.css file stored in Downloads.
  • The local script.js file stored in Downloads.

Keep everything running

No cleanup action is needed. Choose this option if you want to keep playing or improving Neon Pong.

  • Find the three game files in Downloads using ChromeOS Files.
  • Select index.html, style.css, and script.js.
  • Move the selected files together into My Drive for a more permanent copy.
  • Confirm that all three files appear together in My Drive.

Pause - I'll come back to this later

This option ends your current browser session while keeping every game file. You can reopen the game whenever you are ready.

  • Close the Google Chrome tab displaying index.html.
  • Close the Chrome tab running Visual Studio Code for the Web at https://vscode.dev.
  • Reopen the game later by double-clicking index.html in ChromeOS Files.

Delete - I don't want to use this again

This option removes the complete Neon Pong project from your Chromebook. The game cannot run after its three linked files are deleted.

  • Open ChromeOS Files.
  • Select Downloads in the sidebar.
  • Select index.html, style.css, and script.js.
  • Delete the selected files from Downloads using the file manager's deletion control.
  • Confirm that the three files no longer appear in Downloads.

Nice Work!

Nice Work!

You did it! Your complete Neon Pong game now runs from three local files on your Chromebook. You can challenge the computer through a complete first-to-7 match.

You've learned how to:

  • Build a complete player-versus-computer match with plain HTML, CSS, and JavaScript. The game tracks live scores. It resets with timed serves. It declares a winner at 7 points.
  • Use a time-based animation loop to keep movement consistent. Add collision detection for the walls plus both paddles. Calculate return angles from each paddle hit.
  • Create polished arcade controls for starting, pausing, restarting, and replaying. Keep the 900 by 500 Canvas court visible as the browser changes size. Support fallback text plus live score updates.
  • Secret Mission: Add Easy, Normal, and Hard AI difficulty shortcuts on the 1, 2, and 3 keys. Keep the selected speed active when a new game starts.

Ready to quiz yourself?