Skip to content
JavaScript projects 6 min read

Project: Breakout Game on Canvas

Breakout is the capstone canvas project: a bouncing ball, a player-controlled paddle, and a wall of bricks to demolish. Building it ties together everything that makes browser games tick — a requestAnimationFrame game loop, frame-by-frame rendering on <canvas>, axis-aligned collision detection, scoring, and keyboard input. By the end you’ll have a small but complete game engine you can extend with levels, sound, and power-ups.

Setting up the canvas

Every canvas game starts with a drawing surface and a 2D context. The context is the object you call every drawing method on — fillRect, arc, fillText, and so on. We grab it once and reuse it each frame.

<canvas id="game" width="480" height="320"></canvas>
const canvas = document.getElementById("game");
const ctx = canvas.getContext("2d");

console.log(canvas.width, canvas.height);

Output:

480 320

Set canvas dimensions via the width/height attributes, not CSS. CSS only stretches the existing bitmap, which makes everything blurry and throws off your collision math.

Modeling game state

Keep mutable state in plain objects so each entity owns its position and velocity. The ball needs a position (x, y), a velocity (dx, dy), and a radius. The paddle tracks its horizontal position and size. Bricks are generated from a grid description.

const ball = { x: 240, y: 280, dx: 3, dy: -3, radius: 8 };
const paddle = { width: 75, height: 10, x: 202 };

const brick = { rows: 4, cols: 7, w: 56, h: 18, pad: 8, offTop: 30, offLeft: 18 };
const bricks = [];

for (let c = 0; c < brick.cols; c++) {
  bricks[c] = [];
  for (let r = 0; r < brick.rows; r++) {
    bricks[c][r] = { x: 0, y: 0, alive: true };
  }
}

let score = 0;

We store each brick’s alive flag so collision detection can skip destroyed ones, and we compute each brick’s x/y lazily while drawing.

The game loop

A game loop runs continuously: clear the screen, update positions, detect collisions, redraw, repeat. requestAnimationFrame is the right tool — it syncs to the display’s refresh rate (typically 60fps), pauses in background tabs to save battery, and passes a high-resolution timestamp you can use for frame-rate-independent movement.

function loop() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  draw();
  update();
  requestAnimationFrame(loop);
}

requestAnimationFrame(loop);

clearRect wipes the previous frame; without it the ball would smear into a solid line. The recursive requestAnimationFrame(loop) call queues the next frame.

Collision detection

Breakout has three kinds of collisions, all solved with simple arithmetic.

CollisionTestResponse
Left/right wallsx ± radius past edgeflip dx
Top wally - radius past edgeflip dy
Paddleball within paddle’s x-range at bottomflip dy
Brickball center inside brick rectangleflip dy, mark dead, add score

Bricks use an axis-aligned bounding-box test: the ball overlaps a brick when its center falls within the brick’s left/right and top/bottom bounds. Flipping a velocity component reverses direction while preserving speed.

function hitBricks() {
  for (let c = 0; c < brick.cols; c++) {
    for (let r = 0; r < brick.rows; r++) {
      const b = bricks[c][r];
      if (!b.alive) continue;
      if (
        ball.x > b.x && ball.x < b.x + brick.w &&
        ball.y > b.y && ball.y < b.y + brick.h
      ) {
        ball.dy = -ball.dy;
        b.alive = false;
        score += 10;
      }
    }
  }
}

Keyboard input

Track input with keydown/keyup listeners that set boolean flags. Reading flags inside the loop (rather than moving the paddle directly in the event handler) gives smooth, continuous motion and avoids the OS key-repeat delay.

let leftPressed = false;
let rightPressed = false;

document.addEventListener("keydown", (e) => {
  if (e.key === "ArrowLeft") leftPressed = true;
  if (e.key === "ArrowRight") rightPressed = true;
});

document.addEventListener("keyup", (e) => {
  if (e.key === "ArrowLeft") leftPressed = false;
  if (e.key === "ArrowRight") rightPressed = false;
});

The complete game

Here’s the whole thing in one self-contained file — paddle, ball, bricks, scoring, win/lose states, and keyboard control. Open it in CodePen and play with the arrow keys.

<canvas id="game" width="480" height="320" style="background:#0b1021;display:block;margin:0 auto;border-radius:8px"></canvas>
<script>
const canvas = document.getElementById("game");
const ctx = canvas.getContext("2d");

const ball = { x: 240, y: 280, dx: 3, dy: -3, radius: 8 };
const paddle = { width: 75, height: 10, x: 202 };
const brick = { rows: 4, cols: 7, w: 56, h: 18, pad: 8, offTop: 30, offLeft: 18 };
const bricks = [];
for (let c = 0; c < brick.cols; c++) {
  bricks[c] = [];
  for (let r = 0; r < brick.rows; r++) bricks[c][r] = { x: 0, y: 0, alive: true };
}

let score = 0, over = false, won = false;
let left = false, right = false;

document.addEventListener("keydown", (e) => {
  if (e.key === "ArrowLeft") left = true;
  if (e.key === "ArrowRight") right = true;
});
document.addEventListener("keyup", (e) => {
  if (e.key === "ArrowLeft") left = false;
  if (e.key === "ArrowRight") right = false;
});

function drawBricks() {
  for (let c = 0; c < brick.cols; c++) {
    for (let r = 0; r < brick.rows; r++) {
      const b = bricks[c][r];
      if (!b.alive) continue;
      b.x = brick.offLeft + c * (brick.w + brick.pad);
      b.y = brick.offTop + r * (brick.h + brick.pad);
      ctx.fillStyle = `hsl(${r * 40 + 180}, 70%, 55%)`;
      ctx.fillRect(b.x, b.y, brick.w, brick.h);
    }
  }
}

function hitBricks() {
  let remaining = 0;
  for (let c = 0; c < brick.cols; c++) {
    for (let r = 0; r < brick.rows; r++) {
      const b = bricks[c][r];
      if (!b.alive) continue;
      remaining++;
      if (ball.x > b.x && ball.x < b.x + brick.w && ball.y > b.y && ball.y < b.y + brick.h) {
        ball.dy = -ball.dy;
        b.alive = false;
        score += 10;
        remaining--;
      }
    }
  }
  if (remaining === 0) won = over = true;
}

function update() {
  if (over) return;
  if (left && paddle.x > 0) paddle.x -= 6;
  if (right && paddle.x < canvas.width - paddle.width) paddle.x += 6;

  if (ball.x + ball.dx > canvas.width - ball.radius || ball.x + ball.dx < ball.radius) ball.dx = -ball.dx;
  if (ball.y + ball.dy < ball.radius) {
    ball.dy = -ball.dy;
  } else if (ball.y + ball.dy > canvas.height - ball.radius - paddle.height) {
    if (ball.x > paddle.x && ball.x < paddle.x + paddle.width) ball.dy = -ball.dy;
    else if (ball.y + ball.dy > canvas.height - ball.radius) over = true;
  }

  ball.x += ball.dx;
  ball.y += ball.dy;
  hitBricks();
}

function draw() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  drawBricks();

  ctx.beginPath();
  ctx.arc(ball.x, ball.y, ball.radius, 0, Math.PI * 2);
  ctx.fillStyle = "#f9d342";
  ctx.fill();

  ctx.fillStyle = "#5eead4";
  ctx.fillRect(paddle.x, canvas.height - paddle.height, paddle.width, paddle.height);

  ctx.fillStyle = "#fff";
  ctx.font = "14px sans-serif";
  ctx.fillText("Score: " + score, 8, 20);

  if (over) {
    ctx.font = "24px sans-serif";
    ctx.fillStyle = won ? "#5eead4" : "#f87171";
    ctx.textAlign = "center";
    ctx.fillText(won ? "You win!" : "Game over", canvas.width / 2, canvas.height / 2);
    ctx.textAlign = "left";
  }
}

function loop() {
  draw();
  update();
  requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
</script>

Notice how update checks the paddle hit before the game-over test: if the ball reaches paddle height and is within the paddle’s x-range, it bounces; otherwise it falls past and ends the game.

A frame-rate-independent variant

The version above moves by a fixed number of pixels per frame, so it runs faster on a 120Hz display. To decouple movement from refresh rate, scale velocity by the elapsed time (delta) between frames.

let last = 0;
function loop(now) {
  const dt = (now - last) / 16.67; // 1.0 at 60fps
  last = now;
  ball.x += ball.dx * dt;
  ball.y += ball.dy * dt;
  requestAnimationFrame(loop);
}
requestAnimationFrame(loop);

Best practices

  • Set canvas size with the width/height attributes; use CSS only for layout, never to resize the bitmap.
  • Drive movement from boolean key flags read inside the loop, not from the event handlers, for smooth continuous input.
  • Always clearRect at the top of each frame to prevent trails and ghosting.
  • Multiply velocities by a delta-time factor so the game runs identically on 60Hz and 120Hz displays.
  • Keep entity state in plain objects and separate update (logic) from draw (rendering) for code you can actually maintain.
  • Cancel the loop with cancelAnimationFrame (or guard with an over flag) when the game ends so you don’t burn CPU.
Last updated June 1, 2026
Was this helpful?