The Limits of a 180-Line Custom Physics Engine — Why I Dropped Matter.js and What I Couldn't Cover
Go deeper on this topic
Canvas vs DOM & Interactive UI
Canvas vs DOM tradeoffs, CSS interactions, custom physics, and portfolio design
Article 4 of 5 in this series.
Next reads
Building a Custom Physics Engine for the Browser from Scratch
How I built a lightweight 180-line physics engine in TypeScript instead of using Matter.js for my portfolio site. Reduced bundle size from 300KB to 4KB while maintaining 60fps.
Canvas vs DOM — Decision Criteria from 203 Components with Real Benchmark Data
The decision criteria used to assign 203 interactive components between Canvas API and DOM, explained with real examples and measured performance data.
Top 10 Bugs from 203 Canvas/DOM Components — Patterns Revealed by Real Data
Bugs encountered during the development of 203 interactive components, tallied from git history. The most frequent patterns and their fixes presented in a Top 10 format.
Use the topic hub as a map, then continue to the next article in the series. New here? Start at the home hub →
Why Not Matter.js
sakimytocom has 211 interactive components. About 30 of them require physics simulation: GravityBadges, BubbleWrap, Breakout, Pinball, NewtonsCradle, and other components in the "things fall with gravity" or "things bounce off each other" category.
Initially, Matter.js was considered. It is the de facto standard for 2D game physics, with a full feature set including constraint solvers, friction, stacking, and polygon collision. But the numbers changed the plan.
| Matter.js | physics.ts | |
|---|---|---|
| Bundle size (minified) | ~300KB | ~4KB |
| After gzip | ~80KB | ~1.5KB |
| Initialization cost | Engine + World + Render | Function calls only |
| Tree-shaking | Not possible (class-based) | Possible (function exports) |
Loading a 300KB physics engine on a portfolio site's initial load was unacceptable. The first thing users see is the Hero section, and physics only kicks in at GravityBadges after scrolling. Making everyone pay 300KB for that is a design mistake.
The decision criterion was simple: "Cover the necessary 90% in 4KB, and write the remaining 10% individually." This turned out to be the right call, though the 10% proved more challenging than expected.
Overview of physics.ts
src/utils/physics.ts is 166 lines. It consists of 2 type definitions and 8 functions.
Body Type
export type Vec2 = {
x: number
y: number
}
export type Body = {
id: string
pos: Vec2
vel: Vec2
acc: Vec2
mass: number
radius: number
restitution: number
isStatic: boolean
}Vec2 is a 2D vector. Body holds all state of a physics object. The isStatic flag allows walls and paddles to be treated as the same type. restitution is the coefficient of restitution: 1.0 means perfectly elastic collision, 0.0 absorbs all energy.
createBody — Factory with Defaults
export function createBody(partial: Partial<Body> & { id: string }): Body {
return {
pos: { x: 0, y: 0 },
vel: { x: 0, y: 0 },
acc: { x: 0, y: 0 },
mass: 1,
radius: 20,
restitution: 0.7,
isStatic: false,
...partial,
}
}Only id is required; everything else is overridden via spread. Since it uses plain objects rather than classes, it works well with React state. JSON serialization is also natural.
Forces and Integration
export function applyGravity(body: Body, g = 9.8): void {
if (body.isStatic) return
body.acc.y += g
}
export function applyForce(body: Body, force: Vec2): void {
if (body.isStatic) return
body.acc.x += force.x / body.mass
body.acc.y += force.y / body.mass
}
export function integrate(body: Body, dt: number): void {
if (body.isStatic) return
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
}Integration uses the Symplectic Euler method. The key is the order: vel += acc * dt before pos += vel * dt, which gives better energy conservation than standard Euler (which updates position first). Verlet integration and RK4 offer better precision for physics simulations, but Symplectic Euler is sufficient for game use cases. Acceleration is reset to zero after integration — forces are reapplied every frame by design.
Collision Detection and Response
export function detectCollision(a: Body, b: Body): { normal: Vec2; depth: number } | null {
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,
}
}Collision detection is circle-circle only. Two circles collide when the distance between centers is less than the sum of radii. The dist === 0 guard prevents division by zero when circles completely overlap.
Broad-phase with AABB (axis-aligned bounding box) is not included. With O(n^2) brute-force comparison of all pairs, 30 bodies means 435 comparisons. With one Math.sqrt and two comparisons per pair, the operation is lightweight enough that even 100 bodies aren't a problem.
export function resolveCollision(
a: Body,
b: Body,
collision: { normal: Vec2; depth: number },
): void {
const { normal, depth } = collision
// Position correction (penetration resolution)
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
} else if (!b.isStatic) {
b.pos.x += normal.x * depth
b.pos.y += normal.y * depth
}
// Impulse calculation
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 (relVelDotNormal > 0) return // Already separating
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 impulseMag = (-(1 + restitution) * relVelDotNormal) / inverseMassSum
if (!a.isStatic) {
a.vel.x -= (impulseMag / a.mass) * normal.x
a.vel.y -= (impulseMag / a.mass) * normal.y
}
if (!b.isStatic) {
b.vel.x += (impulseMag / b.mass) * normal.x
b.vel.y += (impulseMag / b.mass) * normal.y
}
}Collision response is impulse-based. First, penetration is resolved by mass ratio, then an impulse considering the coefficient of restitution is applied to velocities. By treating the inverse mass of isStatic bodies as 0, collisions with walls work naturally.
step Function — All Processing for One Frame
export function step(bodies: Body[], dt: number, bounds: { width: number; height: number }): void {
for (const body of bodies) applyGravity(body)
for (const body of bodies) integrate(body, dt)
for (let i = 0; i < bodies.length; i++) {
for (let j = i + 1; j < bodies.length; j++) {
const collision = detectCollision(bodies[i], bodies[j])
if (collision) resolveCollision(bodies[i], bodies[j], collision)
}
}
for (const body of bodies) constrainToBounds(body, bounds.width, bounds.height)
}Gravity, integration, collision detection/response, boundary constraints — in this order, one frame of physics is complete. Components just call step(bodies, dt, { width, height }).
What Worked (The 90% That Was Covered)
The three features of physics.ts (gravity, circle collision, boundary reflection) alone power 25 out of 30 components. Here are representative examples.
GravityBadges
Skill badges physically fall within the viewport and can be thrown by dragging. A Body is created for each badge using createBody. During dragging, isStatic = true is set to manually update position. Upon release, isStatic = false is restored and velocity is computed from the drag movement. This is exactly the intended use case for physics.ts.
BubbleWrap
Particle scatter when popping bubble wrap. Each particle is a Body, launched radially with applyForce and falling naturally under gravity. Since particles are small, the circle approximation works perfectly.
Breakout / Pinball
Block-breaking and pinball. Ball reflection is precisely the combination of circle-circle collision and boundary reflection. Paddles and flippers participate in collision response as isStatic Bodies. Pinball flippers require rotation, but the angle calculation is implemented individually, with collision detected using a circle at the flipper tip.
NewtonsCradle
Newton's cradle. Five balls are lined up in a row — pull the end ball and release it, and the ball on the opposite side flies out. Conservation of momentum and energy are naturally reproduced by the impulse-based collision response. With restitution: 0.99, collisions are nearly perfectly elastic.
What these components share is the pattern "circles move, collide, and bounce." physics.ts is specifically optimized for this pattern.
Limitations (The 10% That Wasn't Covered)
Five capabilities — constraint solver, friction, stacking, CCD, and polygon collision — were not covered by physics.ts, and were handled with 20-50 lines of dedicated code each.
1. Constraint Solver — DoublePendulum
A double pendulum is a system of two pendulums connected by a rod. The rod length must remain constant at all times. This is a "constraint condition," which physics.ts does not support.
Matter.js would represent the rod as a Constraint object, with an iterative solver correcting positions. A different approach was taken here: deriving the angular equations of motion directly using Lagrangian mechanics and simulating in terms of angles.
// Angular acceleration derived from Lagrangian equations
const alpha1 = (-g * (2 * m1 + m2) * sin(theta1)
- m2 * g * sin(theta1 - 2 * theta2)
- 2 * sin(theta1 - theta2) * m2
* (omega2 * omega2 * l2 + omega1 * omega1 * l1 * cos(theta1 - theta2)))
/ (l1 * (2 * m1 + m2 - m2 * cos(2 * theta1 - 2 * theta2)))A 166-line general-purpose engine can't handle this, but 20 lines of dedicated code runs it accurately. A typical example where abandoning generality and optimizing for the specific use case yields better precision.
2. Friction — Cloth Simulation
Cloth simulation connects particles with springs. The "damper" part of the spring-damper model serves as a substitute for friction. The impulse response in physics.ts lacks the tangential friction component.
// Spring-damper model
const springForce = -k * (dist - restLength)
const dampingForce = -d * relativeVelocity
const totalForce = springForce + dampingForceAdding friction to physics.ts was possible, but since only Cloth and Rope needed it, individual implementation was chosen. Adding features to a general-purpose engine increases overall complexity. Paying that cost across all components for a rarely-used feature is best avoided.
3. Stacking — Sokoban
Stacking physics for piling boxes is surprisingly difficult. "Stacking stability" — where multiple bodies rest in contact — requires an iterative solver and sleep (rest detection).
For Sokoban (the warehouse puzzle), grid-based movement was used instead. Rather than physics simulation, boxes are moved using discrete tile-based movement rules. This actually improved gameplay. Clear puzzle rules matter more than physically realistic box behavior.
4. Continuous Collision Detection (CCD)
The "tunneling" problem where fast-moving objects pass through thin walls. With discrete position updates, an object can end up on the other side of a wall in a single frame.
Matter.js has a CCD option; physics.ts does not. Two workarounds: keep dt small (clamp to never exceed 16ms), or cap maximum speed.
const clampedDt = Math.min(dt, 0.016)
const maxSpeed = bounds.width * 0.5 // Don't move more than half the screen per frame
const speed = Math.sqrt(body.vel.x ** 2 + body.vel.y ** 2)
if (speed > maxSpeed) {
body.vel.x = (body.vel.x / speed) * maxSpeed
body.vel.y = (body.vel.y / speed) * maxSpeed
}Not rigorous CCD, but sufficient for portfolio interactions.
5. Polygon Collision (SAT)
Separating Axis Theorem (SAT) for polygon-polygon collision detection is not implemented. All objects are approximated as circles.
Tetris blocks might seem problematic during rotation, but collision is handled with grid-based detection. No need for the physics engine. Breakout blocks are also treated as isStatic circles, with only the visual appearance being rectangular. There's occasionally slight visible penetration, but it's unnoticeable at game speed.
Performance Comparison
Benchmarked on Chrome 131, M2 MacBook Air, with 100 circles free-falling and colliding.
| Metric | Matter.js | physics.ts |
|---|---|---|
| step() per frame | ~2.0ms | ~0.3ms |
| Initialization | ~5ms (Engine + World) | ~0.1ms (array creation) |
| Memory (100 bodies) | ~2MB | ~50KB |
| Bundle size (min) | ~300KB | ~4KB |
| Tree-shaking | Not possible | Possible (per function) |
The reason physics.ts is fast is simple: it has fewer features. No friction calculation, no sleep detection, no broad-phase, no constraint solver. The O(n^2) collision detection becomes a bottleneck beyond 1000 bodies, but portfolio use cases rarely exceed 100.
"Sufficient for arcade games. Insufficient for realistic simulation." This is the precise positioning of physics.ts.
Lessons Learned
"Cover the necessary 90% in 4KB, write the remaining 10% individually" was the optimal solution. Three concrete takeaways follow.
Count the features you actually need before pulling in a full-spec library
Looking at Matter.js's feature list makes you want everything. But in practice, only gravity, circle collision, and boundary reflection were needed. 25 out of 30 components are covered by these three. The remaining 5 needed constraints, friction, and SAT — each handled with 20-50 lines of individual implementation.
A general-purpose library at 300KB versus dedicated code at 4KB + 200 lines of individual implementation. The latter has a smaller bundle size and allows free tuning of each component's physics behavior.
If 90% is enough, building your own wins
It's not just about bundle size. With a custom implementation, you fully understand the behavior. Debugging is faster when bugs appear. There's more room for performance tuning. Adjustments like setting GravityBadges' restitution to 0.3 or reducing gravity for BubbleWrap particles are just a matter of changing a function argument.
The remaining 10% is higher quality with dedicated implementation
If DoublePendulum had been implemented with a constraint solver in physics.ts, there would have been struggles with iteration count tuning and drift correction. Solving directly with Lagrangian mechanics is physically more accurate and the code is shorter. Cloth's spring-damper also behaves more naturally than Matter.js constraints. Dedicated code optimized for its purpose beats general-purpose solutions.
The 166-line physics engine is not meant to "solve everything." It's meant to "handle common patterns instantly." That trade-off is what runs a 211-component portfolio at a 4KB physics cost.
FAQ
- Q. What are the benefits of using a custom physics engine instead of Matter.js?
- Bundle size is reduced from 300KB to 4KB, and tree-shaking becomes possible. Additionally, debugging is faster since the behavior is fully understood, and per-component physics tuning is easier.
- Q. What features can't a 166-line physics engine cover?
- Five things: constraint solver (e.g., double pendulum), friction, stacking stability, continuous collision detection (CCD), and polygon collision (SAT). Each was handled with 20-50 lines of dedicated code.
- Q. How many bodies can the custom physics engine handle stably?
- With O(n²) brute-force collision detection, it works fine up to about 100 bodies. It becomes a bottleneck beyond 1000 bodies, but portfolio use cases rarely exceed 100.