← Blog
PhysicsTypeScriptGame DevelopmentMath

Collision Detection Fundamentals — Circles, Rectangles, and Impulse-Based Response

9 min read

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.

View topic page

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.