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/heightattributes, 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.
| Collision | Test | Response |
|---|---|---|
| Left/right walls | x ± radius past edge | flip dx |
| Top wall | y - radius past edge | flip dy |
| Paddle | ball within paddle’s x-range at bottom | flip dy |
| Brick | ball center inside brick rectangle | flip 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/heightattributes; 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
clearRectat 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) fromdraw(rendering) for code you can actually maintain. - Cancel the loop with
cancelAnimationFrame(or guard with anoverflag) when the game ends so you don’t burn CPU.