Building a Breakout Game in the Browser — Mastering Reflection Angles and Level Design with Canvas API
Go deeper on this topic
Browser Game Development
Game loop design, collision detection, pathfinding, and tooling for browser games
Article 2 of 7 in this series.
Next reads
Comparing Maze Generation Algorithms — Structural Differences Between Recursive Backtracking, Kruskal, and Prim
Comparing the characteristics of multiple maze generation algorithms including recursive backtracking, Kruskal's, and Prim's, and the structural differences in the mazes they produce.
Visualizing Pathfinding Algorithms — Watching A*, BFS, and DFS Solve a Maze
Techniques for visualizing the search process of A*, BFS, and DFS implemented in the MazeRunner component, with a comparison of each algorithm.
Collision Detection Fundamentals — Circles, Rectangles, and Impulse-Based Response
A deep dive into circle-circle and circle-rectangle collision detection algorithms, restitution-based bounce control, and the math behind impulse-based collision response, explained through the physics.ts implementation.
Use the topic hub as a map, then continue to the next article in the series. New here? Start at the home hub →
Why Breakout?
Breakout is a teaching tool that packs the fundamental elements of game programming into a compact package. Rectangle-circle collision detection, reflection vector calculation, game state management, level design — all of these fit into about 200 lines of code. Through the BreakoutMini component implementation, we'll cover the key techniques for building games with Canvas API.
Game Loop Skeleton
The Breakout game loop runs in three phases: "input, update, render." Physics simulation and drawing are packed into a callback called every frame via requestAnimationFrame:
const onFrame = (ctx: CanvasRenderingContext2D, w: number, h: number, time: number) => {
// 1. Calculate delta time
const delta = time - lastTime
lastTime = time
const dt = Math.min(delta, 32) / 16.67
// 2. Update ball position
ball.x += ball.vx * speedMult * dt
ball.y += ball.vy * speedMult * dt
// 3. Collision detection (walls, paddle, bricks)
// ...
// 4. Render
renderBricks(ctx, bricks)
renderPaddle(ctx, paddleX, paddleY)
renderBall(ctx, ball)
}The Math.min(delta, 32) clamp is important. When a tab returns from the background, delta can spike to hundreds of milliseconds, causing the ball to pass through walls. Capping at 32ms limits the skip to about 2 frames.
Paddle Reflection Angle Design
What defines Breakout's feel is the reflection angle when the ball hits the paddle. Simply doing vy = -vy makes the ball bounce in the same trajectory repeatedly, which is boring.
In BreakoutMini, the reflection angle changes based on where on the paddle the ball hits:
// Normalize hit position on paddle to -1 to 1
const hitPos = (ball.x - paddleX) / (PADDLE_WIDTH / 2)
// Determine reflection angle based on hit position (-90° ± 60°)
const angle = -Math.PI / 2 + hitPos * (Math.PI / 3)
const speed = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy)
ball.vx = Math.cos(angle) * speed
ball.vy = Math.sin(angle) * speedWhy does this approach work? When hitPos is 0 (paddle center), the ball returns straight up. At ±1 (edges), it flies at 60° left or right. The player can aim by controlling where the ball hits the paddle, creating a "sense of control."
This angle range (±60°) is an intentional constraint. Extending to ±90° would allow near-horizontal trajectories where the ball bounces back and forth endlessly, stalling the game. Narrowing to ±30° would eliminate player agency.
Brick Collision Detection
Collision detection between bricks (rectangles) and the ball (circle) is implemented using a closest point algorithm:
for (const brick of bricks) {
if (!brick.alive) continue
// Find closest point on rectangle to ball
const closestX = Math.max(brick.x, Math.min(ball.x, brick.x + brick.w))
const closestY = Math.max(brick.y, Math.min(ball.y, brick.y + brick.h))
// Check distance between closest point and ball center
const dx = ball.x - closestX
const dy = ball.y - closestY
if (dx * dx + dy * dy <= BALL_RADIUS * BALL_RADIUS) {
brick.alive = false
// Reflection handling...
}
}The Math.max and Math.min combination calculates the closest point from the ball center to the rectangle. If this distance is less than or equal to the ball radius, it's a collision. Comparing squared values instead of using Math.sqrt is a classic technique to avoid the cost of square root calculation.
Reflection Direction — Minimum Overlap Method
After detecting a collision, we need to decide whether to bounce the ball in the X or Y direction. BreakoutMini uses the minimum overlap method:
const overlapLeft = ball.x + BALL_RADIUS - brick.x
const overlapRight = brick.x + brick.w - (ball.x - BALL_RADIUS)
const overlapTop = ball.y + BALL_RADIUS - brick.y
const overlapBottom = brick.y + brick.h - (ball.y - BALL_RADIUS)
const minOverlapX = Math.min(overlapLeft, overlapRight)
const minOverlapY = Math.min(overlapTop, overlapBottom)
if (minOverlapX < minOverlapY) {
ball.vx = -ball.vx // Hit from the side
} else {
ball.vy = -ball.vy // Hit from top or bottom
}Calculate the penetration depth on all four sides and bounce in the direction with the shallowest penetration. This approach is equivalent to estimating "the last face the ball passed through" and produces intuitively correct reflections.
More sophisticated approaches like the Separating Axis Theorem (SAT) exist, but for axis-aligned rectangles and circles, minimum overlap is sufficient. Both computation and branching are minimal.
Level Design Considerations
BreakoutMini's brick layout is 3 rows by 8 columns. Simple, but several design decisions are embedded:
const ROWS = 3
const COLS = 8
const BRICK_GAP = 2
const BRICK_TOP_OFFSET = 40
const ROW_COLORS = ['#ef4444', '#f59e0b', '#10b981']3 rows is the optimal choice. There are three reasons. First, given the component height of 400px, space needs to be reserved for bricks, paddle, and ball movement. Second, color-coding by row creates a visual hierarchy of "red at top = far away = harder." Third, 24 bricks (3×8) results in clear times of 1-2 minutes, maintaining good pacing for a mini-game.
Dynamically calculating brick width is also important:
const brickW = (canvasW - BRICK_GAP * (COLS + 1)) / COLSSubtract gaps from the Canvas width and divide by column count. This ensures bricks line up evenly to the edges regardless of screen width. Responsive design handled on the JavaScript side rather than CSS — a pattern specific to Canvas.
Dynamic Speed Adjustment
To prevent Breakout from becoming monotonous, ball speed increases based on remaining brick count:
const aliveCount = bricks.filter(b => b.alive).length
const speedMult = 1 + (1 - aliveCount / totalBricks) * 0.51.0x when all bricks remain, 1.25x when half are destroyed, 1.5x with one left. Difficulty naturally increases as the player improves. The 1.5x cap prevents the ball from reaching speeds where it might pass through walls.
Wall Collision Implementation
There's a reason we write ball.vx = Math.abs(ball.vx) instead of ball.vx = -ball.vx for wall collisions:
if (ball.x - BALL_RADIUS <= 0) {
ball.x = BALL_RADIUS
ball.vx = Math.abs(ball.vx)
}
if (ball.x + BALL_RADIUS >= w) {
ball.x = w - BALL_RADIUS
ball.vx = -Math.abs(ball.vx)
}With -ball.vx, if the wall check runs multiple times in a single frame, the sign double-flips and the ball embeds in the wall. Using Math.abs forces the correct direction regardless of how many times the check runs. Subtle, but this kind of bug is annoying to debug.
Game State Management
BreakoutMini has five states: idle, playing, active, win, gameOver. Here's the state transition map:
idle → playing (tap to start game)
playing → active (tap to launch ball)
active → playing (ball falls, lives remaining)
active → gameOver (ball falls, no lives)
active → win (all bricks destroyed)
win / gameOver → playing (tap to retry)The separation of playing and active is key. playing is when the ball is stuck to the paddle, giving the player time to aim. This "pause before launch" significantly affects the feel of the controls.
Performance Considerations
In BreakoutMini, all game state is managed with useRef. Using useState would trigger a re-render on every ball position update, making it difficult to maintain 60fps. Separating React's rendering cycle from Canvas's drawing cycle is the fundamental design principle for building games with React.
useState is used only for overlay UI (displaying "Game Over" and "You Win!"), while physics state is contained in useRef.
Summary: Tools for Building a Canvas Breakout Game
Implementing Breakout is ideal as an introduction to game programming and physics simulation. The books on the tool shelf cover the mathematical background of reflection vector calculation and more complex collision responses (rotation, friction) in a systematic way.