Every first brick breaker has the same two bugs. Sooner or later the ball goes straight through a brick, or through the paddle, as if it wasn't there. And when it does hit a brick near a corner, it bounces off in a direction that feels wrong.
Both come from how the ball moves. On screen it looks continuous, but in code it jumps from one position to the next, once per frame. Collisions are only checked at those positions, never in between.
I ran into both while building Brick Burst, a small Breakout-style game in a single HTML file with no library. Here are the two fixes, with the code.
Bug 1: the ball tunnels through thin objects
The usual first loop moves the ball by velocity × frameTime once per requestAnimationFrame:
function frame(now) {
const dt = (now - last) / 1000;
last = now;
ball.x += ball.vx * dt;
ball.y += ball.vy * dt;
checkCollisions();
draw();
requestAnimationFrame(frame);
}
At 60 frames a second and 600 pixels a second, the ball moves 10 pixels per frame. If a brick is 20 pixels tall, that's fine. But frames aren't always 1/60 of a second. A slow frame, a background tab or a garbage-collection pause can make one dt five times longer, and then the ball moves 50 pixels in one jump. It can be above the paddle in one frame and below it in the next, without ever overlapping it.
Fix: fixed physics steps, sized to the ball
Separate the physics from the frame rate. Keep an accumulator of elapsed time and run the physics in small fixed steps until it's used up:
const STEP = 1 / 120; // 120 physics steps a second
let acc = 0, last = performance.now();
function frame(now) {
acc += Math.min((now - last) / 1000, 0.25); // cap long pauses
last = now;
while (acc >= STEP) {
update(STEP);
acc -= STEP;
}
draw();
requestAnimationFrame(frame);
}
Now the ball always moves by the same small amount per step, however uneven the frames are. The cap on acc stops a tab that was hidden for a minute from running thousands of steps when it comes back.
The step size isn't arbitrary. Here's the rule that makes simple overlap tests safe:
If the ball can't move further than its own radius in one step, it can't skip over anything thicker than its radius.
In Brick Burst the top speed is 620 units a second. At 120 steps a second that's 5.2 units per step, less than the ball's 7-unit radius. Bricks are 20 units tall and the paddle is 12, so the ball always overlaps them for at least one step on the way through. If you raise the top speed a lot, raise the step rate with it. Past a certain speed you need a swept test, a ray cast along the ball's path, but most casual games never get there.
Bug 2: the ball bounces off the wrong side of a brick
The tempting brick test checks the ball's bounding box against the brick and flips vy on a hit:
if (overlaps(ball, brick)) {
ball.vy = -ball.vy;
brick.hp--;
}
That works when the ball hits a brick from below. When it clips the side of a brick, it should bounce sideways, but this code sends it back down, which looks broken. There are two smaller problems too: a box test counts corner hits that a round ball would miss, and flipping the sign can leave the ball stuck inside a brick, flipping back and forth every step.
Fix: circle against rectangle, then pick the shallow side
First, a proper circle-versus-rectangle test. Clamp the ball's centre to the brick's edges to find the nearest point on the brick, then compare the distance to that point with the radius. Comparing squared distances saves a square root:
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
function circleHitsRect(ball, b) {
const nx = clamp(ball.x, b.x, b.x + b.w);
const ny = clamp(ball.y, b.y, b.y + b.h);
const dx = ball.x - nx, dy = ball.y - ny;
return dx * dx + dy * dy <= ball.r * ball.r;
}
Second, decide which side was hit by how deep the ball went in on each axis. If it's only a little inside horizontally but deep vertically, it came in from the left or right. Otherwise it came from the top or bottom:
function bounceOffBrick(ball, b) {
const cx = b.x + b.w / 2, cy = b.y + b.h / 2;
const overlapX = ball.r + b.w / 2 - Math.abs(ball.x - cx);
const overlapY = ball.r + b.h / 2 - Math.abs(ball.y - cy);
if (overlapX < overlapY) {
ball.vx = ball.x < cx ? -Math.abs(ball.vx) : Math.abs(ball.vx);
} else {
ball.vy = ball.y < cy ? -Math.abs(ball.vy) : Math.abs(ball.vy);
}
}
Notice that this sets the sign instead of flipping it. "Move away from the brick's centre" stays true even if the ball is still overlapping on the next step, so it can't get stuck. The same trick works for walls:
if (ball.x < ball.r) { ball.x = ball.r; ball.vx = Math.abs(ball.vx); }
if (ball.x > W - ball.r) { ball.x = W - ball.r; ball.vx = -Math.abs(ball.vx); }
One more detail: stop after the first brick you hit in a step. A ball that touches two bricks at once would otherwise bounce twice, cancel itself out and carry on through both.
for (const b of bricks) {
if (!b.hp || !circleHitsRect(ball, b)) continue;
bounceOffBrick(ball, b);
b.hp--;
break;
}
Bonus: aim from the paddle instead of mirroring
A paddle that just flips vy gives the player no control. Throw the ball at an angle that depends on where it lands: straight up from the centre, up to 60 degrees from the ends:
const MAX_ANGLE = 60 * Math.PI / 180;
const offset = (ball.x - paddleX) / (PADDLE_W / 2 + ball.r); // -1 … 1
if (ball.vy > 0 && Math.abs(offset) <= 1 && touchingPaddle(ball)) {
const angle = offset * MAX_ANGLE;
ball.vx = Math.sin(angle) * ball.speed;
ball.vy = -Math.cos(angle) * ball.speed;
}
Capping the angle at 60 degrees means the ball always climbs at least half its speed upwards, so it can never end up skimming sideways forever.
The full game
With fixed steps, a circle test and "set, don't flip" bounces, a brick breaker needs no physics engine at all. The complete game is about 250 lines in one file: responsive canvas scaling, touch and mouse controls, levels, lives and two-hit bricks. You can play it and copy the MIT-licensed code from our Breakout HTML5 code tutorial, which walks through the rest of the file.
If you want more of these, the same collection of free HTML game code has Pong, Snake, Tetris, 2048 and about twenty other classics, each as a single file you can open in a browser.
What was the first collision bug that got you? I'd like to hear it in the comments.
Top comments (0)