← Blog
TypeScriptPhysicsCanvas APIPerformance

Building a Custom Physics Engine for the Browser from Scratch

5 min read

Go deeper on this topic

Canvas vs DOM & Interactive UI

Canvas vs DOM tradeoffs, CSS interactions, custom physics, and portfolio design

Article 5 of 5 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 →

Why I Built It from Scratch

I wanted physics-based interactions on my portfolio site. I first considered Matter.js, but its bundle size is around 300KB — too heavy. The only features I needed were gravity, collision detection, and bouncing.

I decided to implement just the required features in 180 lines.

Design Overview

A plain-object-based physics engine consisting of a Body type and 8 functions. No classes — React state compatibility was the top priority.

// src/utils/physics.ts structure
type Body = {
  x: number
  y: number
  vx: number
  vy: number
  radius: number
  mass: number
  restitution: number
}

function applyGravity(body: Body, dt: number) {
  body.vy += 980 * dt // 9.8m/s² scaled to pixels
}

function detectCollision(a: Body, b: Body): boolean {
  const dx = a.x - b.x
  const dy = a.y - b.y
  const dist = Math.sqrt(dx * dx + dy * dy)
  return dist < a.radius + b.radius
}

Performance Optimizations

Grid-based spatial partitioning, fixed timestep, and Canvas/DOM selection keep 200 particles running at 60fps.

1. Spatial Partitioning (Grid-based)

Checking all collision pairs is O(n²), so we divide the screen into a grid and only check neighbors:

function buildGrid(bodies: Body[], cellSize: number) {
  const grid = new Map<string, Body[]>()
  for (const body of bodies) {
    const key = `\${Math.floor(body.x / cellSize)},\${Math.floor(body.y / cellSize)}`
    let bucket = grid.get(key)
    if (!bucket) {
      bucket = []
      grid.set(key, bucket)
    }
    bucket.push(body)
  }
  return grid
}

2. requestAnimationFrame + Fixed Timestep

To keep physics stable regardless of variable frame rate, we integrate with a fixed timestep:

const FIXED_DT = 1 / 60
let accumulator = 0

function gameLoop(timestamp: number) {
  const dt = (timestamp - lastTime) / 1000
  accumulator += dt

  while (accumulator >= FIXED_DT) {
    updatePhysics(FIXED_DT)
    accumulator -= FIXED_DT
  }

  render()
  requestAnimationFrame(gameLoop)
}

3. Canvas vs DOM Selection

Element Rendering Method Reason
Particles (100+) Canvas Avoids DOM manipulation overhead
Badges (~20) DOM + CSS Transform Accessibility, text selection
Bubble sheet Canvas Touch event responsiveness

Results

  • Bundle size: Matter.js 300KB → Custom implementation 4KB (gzipped)
  • 60fps maintained: Stable even with 200 particles
  • Lighthouse Performance: 98 points

For a small scope, not using a library is a perfectly viable option.

Tools Used

I've placed the books I referenced for this implementation in the "tool shelf" section at the end of the article. For learning the fundamentals of physics simulation, I recommend starting with these two.

FAQ

Q. Is it realistic to build a custom physics engine for a portfolio site?
If the required features are limited to gravity, collision detection, and bouncing, it is realistic. It can be implemented in 180 lines, reducing Matter.js's 300KB to 4KB.
Q. How do you maintain 60fps with physics calculations?
Use grid-based spatial partitioning to reduce O(n²), stabilize physics with a fixed timestep, and choose between Canvas/DOM based on entity count to maintain 60fps even with 200 particles.
Q. What are the criteria for choosing between Canvas API and DOM?
Use Canvas for 100+ particles, DOM + CSS Transform for around 20 badges. Use DOM when accessibility and text selection are needed.