Lesson 04
Collision detection: make hitboxes fair and easy to debug.
Players forgive difficult games faster than unfair games. Collision code decides what counts as a hit, a wall, a safe gap, an exit or a pickup. Keep it readable.
1. Screen bounds are collisions too
The simplest collision is the player leaving the playable area. Flying Bird uses the bird radius against the top and bottom of the canvas.
if (bird.y + bird.r > H || bird.y - bird.r < 0) {
gameRunning = false;
draw();
return;
}
This is clearer than testing only `bird.y`, because the player sees the bird as a circle. The collision should respect the visible body, not the invisible center point.
2. Gap collisions in obstacle games
Pipe games do not need polygon math. The pipe is a rectangle with a safe vertical gap. First test horizontal overlap, then test whether the bird is outside the gap.
if (bird.x + bird.r > pipe.x && bird.x - bird.r < pipe.x + pipeW) {
const outsideGap = bird.y - bird.r < pipe.top || bird.y + bird.r > pipe.bottom;
if (outsideGap) {
gameRunning = false;
}
}
Breaking the check into `outsideGap` makes it easier to log and easier to tune. If players complain about unfair hits, you can shrink or expand the effective radius instead of rewriting the system.
3. Rectangle helpers for action games
Top-down action games use rectangular hitboxes for players, enemies, projectiles and pickups. A reusable helper keeps every collision readable.
function rectHit(a, b) {
return Math.abs(a.x - b.x) < (a.w + b.w) / 2 &&
Math.abs(a.y - b.y) < (a.h + b.h) / 2;
}
Source pattern: Level 33 action core.
This helper assumes objects are represented by their center point plus width and height. That is a nice convention for canvas games because drawing, movement and collision can use the same center coordinate.
4. Grid blocking for dungeons and maps
Dungeon games often use a tile map. The player moves in pixels, but walls live on a grid. A blocking function converts pixel position to tile position and checks whether the tile is walkable.
function tileAtPixel(x, y, tileSize) {
return {
col: Math.floor(x / tileSize),
row: Math.floor(y / tileSize)
};
}
function blockedAt(x, y, map, tileSize) {
const tile = tileAtPixel(x, y, tileSize);
const row = map[tile.row];
if (!row) return true;
return row[tile.col] === "#";
}
The important detail is returning `true` outside the map. That prevents the player or enemies from walking into undefined space and causing errors.
5. Debug visual hitboxes
When collision feels wrong, draw the hitboxes for a moment. You do not need a fancy debug tool. Add a temporary stroke around your rectangles.
function drawHitbox(entity) {
ctx.save();
ctx.strokeStyle = "rgba(255, 217, 61, 0.8)";
ctx.strokeRect(
entity.x - entity.w / 2,
entity.y - entity.h / 2,
entity.w,
entity.h
);
ctx.restore();
}
If the hitbox is much larger than the visible sprite, the player will feel cheated. If it is too small, enemies will look harmless. The right hitbox usually feels slightly generous to the player.
6. Collision response is separate from collision detection
Detecting a hit answers one question: did these objects overlap? Responding to a hit answers another: what happens now? Keeping those two steps separate makes code much easier to tune.
if (rectHit(player, coin)) {
collectCoin(coin);
}
if (rectHit(player, enemy) && player.invulnerableTime <= 0) {
damagePlayer(enemy.damage);
pushPlayerAwayFrom(enemy);
}
The coin and enemy use the same collision test, but the response is completely different. That is why helpers should return true or false instead of directly changing score, health or level state.
This also helps when adding difficulty levels. You can keep the hitbox exactly the same, but change the response: easy mode might reduce damage and increase invulnerability time, while hard mode might increase knockback or enemy speed. The collision helper does not need to know about difficulty.
7. Make exits require intentional contact
In dungeon and platform games, an exit should usually require the player to enter the exit area, not merely complete an objective anywhere on the map. A readable exit check looks like this:
if (objectiveComplete(state) && rectHit(player, exitDoor)) {
startNextLevel();
}
This gives the player a moment of agency after finishing the objective. It also prevents a confusing bug where a level changes instantly while the player is still fighting somewhere else.
Common mistakes
- Using visual coordinates in one function and center coordinates in another.
- Letting enemies damage the player every frame without a cooldown.
- Checking collision before movement in one object and after movement in another.
- Forgetting to guard map bounds before reading `map[row][col]`.
- Building perfect collision for art that is too small to read at game speed.