Collision Detection Fundamentals — Circles, Rectangles, and Impulse-Based Response
Go deeper on this topic
Browser Game Development
Game loop design, collision detection, pathfinding, and tooling for browser games
Article 5 of 7 in this series.
Next reads
Designing a Type-Safe Game Loop in TypeScript — State Management for Snake and Tetris
Game state management and frame rate control using TypeScript's type system, explored through Snake, Tetris, and Breakout implementations.
Pinball Physics Simulation — Implementing Flippers, Bumpers, and Gravity with Canvas API
Implementing pinball physics with Canvas API. Covers flipper rotational motion, bumper restitution, gravity, and ball behavior.
Browser Game Developer Setup 2026 — The Toolchain Behind 203 Components
A tour of the development environment for building 203 browser games and interactions. Claude Code-centric workflow, bun + Biome + Vite, and parallel development with Git Worktree.
Use the topic hub as a map, then continue to the next article in the series. New here? Start at the home hub →
Overview of physics.ts
The physics engine for sakimytocom lives in src/utils/physics.ts at roughly 170 lines. Written in TypeScript, it consists of six functions covering the Body type, force application, integration, collision detection, collision response, and boundary constraints.
type Body = {
id: string
pos: Vec2
vel: Vec2
acc: Vec2
mass: number
radius: number
restitution: number
isStatic: boolean
}The isStatic flag exists so that walls, paddles, and other immovable objects can be represented using the same Body type.
Circle-Circle Collision Detection
Checking whether two circles overlap is simply a matter of comparing the distance between their centers to the sum of their radii:
function detectCollision(a: Body, b: Body) {
const dx = b.pos.x - a.pos.x
const dy = b.pos.y - a.pos.y
const dist = Math.sqrt(dx * dx + dy * dy)
const minDist = a.radius + b.radius
if (dist >= minDist || dist === 0) return null
return {
normal: { x: dx / dist, y: dy / dist },
depth: minDist - dist,
}
}The dist === 0 guard prevents division by zero when circles completely overlap. This was added after experiencing a bug in Breakout where the ball would get embedded in the paddle.
Impulse-Based Collision Response
When a collision is detected, two operations are performed: position separation and velocity modification.
Position Separation
Penetration is resolved proportionally to mass:
const totalMass = a.mass + b.mass
if (!a.isStatic && !b.isStatic) {
const ratio = b.mass / totalMass
a.pos.x -= normal.x * depth * ratio
a.pos.y -= normal.y * depth * ratio
b.pos.x += normal.x * depth * (1 - ratio)
b.pos.y += normal.y * depth * (1 - ratio)
} else if (!a.isStatic) {
a.pos.x -= normal.x * depth
a.pos.y -= normal.y * depth
}Heavier objects move less. This matches physical intuition.
Velocity Modification (Impulse)
Post-collision velocities are computed from conservation of momentum and the coefficient of restitution:
const relVelX = b.vel.x - a.vel.x
const relVelY = b.vel.y - a.vel.y
const relVelDotNormal = relVelX * normal.x + relVelY * normal.y
// If already separating, do nothing
if (relVelDotNormal > 0) return
const restitution = Math.min(a.restitution, b.restitution)
const inverseMassSum = (a.isStatic ? 0 : 1 / a.mass) + (b.isStatic ? 0 : 1 / b.mass)
if (inverseMassSum === 0) return
const impulse = (-(1 + restitution) * relVelDotNormal) / inverseMassSum
if (!a.isStatic) {
a.vel.x -= (impulse / a.mass) * normal.x
a.vel.y -= (impulse / a.mass) * normal.y
}
if (!b.isStatic) {
b.vel.x += (impulse / b.mass) * normal.x
b.vel.y += (impulse / b.mass) * normal.y
}When restitution = 1, collisions are perfectly elastic (ElasticCollision); when restitution = 0, they are perfectly inelastic. The Pong ball is set to around 0.95.
Rectangle Collision in Breakout
Block-ball collisions require AABB (axis-aligned bounding box) vs. circle detection:
function circleRectCollision(
circle: { x: number; y: number; radius: number },
rect: { x: number; y: number; width: number; height: number },
) {
const closestX = Math.max(rect.x, Math.min(circle.x, rect.x + rect.width))
const closestY = Math.max(rect.y, Math.min(circle.y, rect.y + rect.height))
const dx = circle.x - closestX
const dy = circle.y - closestY
const dist = Math.sqrt(dx * dx + dy * dy)
if (dist >= circle.radius) return null
return {
normal: dist > 0
? { x: dx / dist, y: dy / dist }
: { x: 0, y: -1 },
depth: circle.radius - dist,
}
}This finds the closest point on the rectangle and then computes the distance to the circle. This approach works for rectangles of any aspect ratio.
Choice of Integration Method
physics.ts uses a simple symplectic Euler method:
function integrate(body: Body, dt: number) {
body.vel.x += body.acc.x * dt
body.vel.y += body.acc.y * dt
body.pos.x += body.vel.x * dt
body.pos.y += body.vel.y * dt
body.acc.x = 0
body.acc.y = 0
}Velocity Verlet offers better energy conservation, but for this site's use cases (short-lived interactions), the difference is visually indistinguishable. Simplicity of code was prioritized.
Integration with the usePhysics Hook
On the React component side, the physics engine is accessed through the usePhysics hook:
const { bodiesRef, addBody, applyForce } = usePhysics([
{ id: 'ball', pos: { x: 200, y: 100 }, radius: 10, restitution: 0.9 },
])The hook manages the requestAnimationFrame loop, and components simply read bodiesRef.current for rendering. This design separates the concerns of physics and rendering.
Summary: Tools Used for Collision Detection
The mathematical foundations of collision detection and response are most systematically covered in game physics textbooks. The books on the tool shelf are both classics in this field.