Collisions
No collisions — no game
A bullet hit an enemy, the hero landed on a platform, the ball bounced off the paddle, the snake ate an apple — all of this is collision detection: whether two shapes intersect right now. The canvas doesn't know this (it only has pixels), so collisions are calculated from the numbers in the game state.
In most 2D games, objects are simplified to two shapes for collisions: a rectangle and a circle. Even if a complex character is drawn on screen, its "hitbox" is a simple rectangle.
Rectangle with rectangle (AABB)
Rectangles whose sides are parallel to the screen edges are called AABB (axis-aligned bounding box). The check works in reverse: rectangles do not overlap if one is entirely to the left, right, above or below the other. In all other cases they overlap.
function rectsOverlap(a, b) {
return (
a.x < b.x + b.w && // a's left edge is left of b's right edge
a.x + a.w > b.x && // a's right edge is right of b's left edge
a.y < b.y + b.h && // a's top is above b's bottom
a.y + a.h > b.y // a's bottom is below b's top
);
}
The strict < and > mean that touching at the edges isn't a collision yet. If a game needs it differently,
they're changed to <= and >=.
Circle with circle
Circles overlap when the distance between the centers is less than the sum of the radii:
function circlesOverlap(a, b) {
const dist = Math.hypot(a.x - b.x, a.y - b.y); // √(dx² + dy²)
return dist < a.r + b.r;
}
Math.hypot(dx, dy) is the length of the hypotenuse, that is, the distance by the Pythagorean theorem. When there are
very many objects, the square root isn't computed and squares are compared instead: dx * dx + dy * dy < (a.r + b.r) ** 2.
Circle with rectangle
A ball and a brick, a coin and a platform. The precise way is to find the point of the rectangle closest to the center of the circle and check the distance to it:
function circleRect(c, r) {
const nearX = Math.max(r.x, Math.min(c.x, r.x + r.w));
const nearY = Math.max(r.y, Math.min(c.y, r.y + r.h));
return Math.hypot(c.x - nearX, c.y - nearY) < c.r;
}
Simple arcade games often do it even more simply: they treat the circle as a square with side 2r and use
rectsOverlap. The error is only noticeable at the corners.
Bouncing off walls
The ball must not fly out of the field. We check each wall: if the ball went into it — we put it back on the boundary and turn the velocity around:
if (ball.x - ball.r < 0) { // left wall
ball.x = ball.r;
ball.vx = Math.abs(ball.vx); // now it definitely flies right
}
if (ball.x + ball.r > canvas.width) { // right wall
ball.x = canvas.width - ball.r;
ball.vx = -Math.abs(ball.vx); // now it definitely flies left
}
Why not ball.vx = -ball.vx? In one frame the ball can get deep into a wall. If you only
flip the velocity, on the next frame it may not be out yet — and the velocity flips again, the ball
"sticks" and jitters. Putting it back on the boundary plus Math.abs make the bounce reliable.
What to do after a collision
A collision is only a "yes / no". Then the game decides:
for (const enemy of enemies) {
if (rectsOverlap(bullet, enemy)) {
enemy.alive = false;
bullet.alive = false;
score += 10;
}
}
enemies = enemies.filter((e) => e.alive);
Removing from an array right while looping over it is dangerous (the indices shift), so first objects are marked
alive = false, and then removed with filter.
Objects that are too fast
If a bullet flies 50 pixels per frame and a wall is 10 thick, in one frame the bullet is still in front of the wall, and in the next it's already behind it, and no intersection happens. This is called "tunneling". Simple remedies: limit the speed, make objects thicker, or move fast objects in several small steps per frame.
Summary
- Hitboxes are rectangles and circles; we check numbers from the state, not pixels.
- AABB: four edge comparisons; circles:
Math.hypot(dx, dy) < r1 + r2. - Bounce: put back on the boundary and set the direction with
Math.abs. - After a collision, mark the objects and remove them with
filter.