$Kaan Dinç

building a 2d physics engine from scratch

Impulse is a 2D rigid body physics engine I wrote from scratch in TypeScript, with no physics libraries: no Matter.js, no Planck, no Rapier. it simulates circles, boxes and convex polygons that collide, stack, fall asleep, get pinned together with joints and get grabbed with the mouse. it runs in the browser, has zero runtime dependencies, and the whole demo is about 36 kB of JavaScript.

my map was Erin Catto's Box2D Lite: sequential impulses, warm starting, accumulated impulse clamping. I used it for the ideas and wrote every piece to fit this codebase, then tried to prove each piece against something other than itself. most of this post is about that second part.

0runtime dependencies
12stages, each finished before the next began
171 + 14unit tests and browser tests
2,041lines of engine, backed by 2,171 lines of tests
35.6 kBdemo JavaScript, 12.7 kB gzipped
2.6 msper step with 1,000 bodies awake

##the rules I set first

four rules before any physics: a fixed 1/60 s timestep, a deterministic engine (no Math.random), no allocations in the hot paths, and an engine that never touches the DOM or the canvas. the first one is the one that bites, so it lives in one small class.

// FixedStepper: the only place frame time is allowed to exist advance(frameSeconds: number): number { this.accumulator += Math.min(frameSeconds, MAX_FRAME_SECONDS); let steps = 0; while (this.accumulator >= FIXED_DT) { this.world.step(FIXED_DT); this.accumulator -= FIXED_DT; steps++; } return steps; }

the browser gives you a different frame time on every frame, and the engine never sees it. the accumulator turns frame time into whole fixed steps, and a long frame (a background tab, a breakpoint) is capped at 0.25 s so catching up cannot spiral. the test feeds the same simulation frames at 30, 60 and 144 fps and as an uneven sequence, and requires each result to match plain manual stepping of the same number of steps, bit for bit.

##twelve stages

I built it in stages and did not start the next one until the current one had green tests and a working demo:

stagewhat it addedhow it was proven
1circles and boxes, semi-implicit Euler, fixed timestep, canvas renderer, drag-and-drop spawningfree fall matches the analytic result; frame rate never changes the outcome
2SAT collision detection and contact manifoldsknown overlaps give the exact normal, depth and points; swapping bodies flips the normal
3impulse solver: friction, restitution, clamped accumulated impulsesboxes come to rest; elastic collisions conserve energy to 1e-9
4warm starting, persistent contacts, sleepinga 10-box tower stands 10 s; reruns are bit-identical
5spatial hash broadphasesame pairs as brute force, five cell sizes
6pin and rod jointspendulum period within 2% of the physics formula
7benchmark page, GitHub Pages deploythe built site uses only base-aware URLs
8convex polygonsagrees with the box code on 4,000 random pairs
9block solver, sequential position correction, continuous collision20-box towers stand and sleep; bullets stop at thin walls
10joint limits, motors, springs, ropes, mouse jointspring frequency and deflection match theory
11interactive demo: scenes, grabbing, pause, step, resetevery scene simulates cleanly
12pre-commit hook, CI, browser tests14 tests drive the built site in Chrome

##what it looks like

A pyramid of 55 boxes drawn in dark blue on a dark background.
a pyramid of 55 boxes, six seconds in. the darker blue means the bodies are asleep: the solver has stopped spending time on them.
A pile of circles, boxes, triangles and hexagons in a walled container, with red contact points and normals.
a mixed pile of circles, boxes, triangles and hexagons. the red dots and lines are contact points and normals, drawn straight from the solver's data.
A ten-box tower in dark blue with a wrecking ball on a rope swinging toward it.
a 10-box tower asleep, a wrecking ball on a rope swinging in.
The same tower in bright blue after the ball hit it, with two boxes knocked off the top.
under half a second later: the ball hit, the tower woke up, and it lost its top two boxes.
A motor-driven paddle, a hinged arm and a spring with a ball, drawn with green joint markers.
the joints scene: a motor-driven paddle, a hinge that stops at its angle limits, and a spring. green marks the joints.

##the numbers

stacking

the most useful thing I did was not obvious at first. tall stacks jittered: a 10-box tower stood, but kept moving at roughly 9 cm/s and did not fall asleep at 10 solver iterations. more iterations helped slowly and not even reliably: 60 iterations was worse than 40. as far as I can tell, two things were wrong. every box rests on two contact points, and solving them one after the other lets the pair rock. and my position correction pushed every contact apart at the same moment, so a stack's shared compression cancelled itself out and never went away.

the fix was a 2-point block solver, which solves both points of a contact together as a tiny problem with four cases (both pushing, either one alone, neither), plus a position correction that runs contact by contact so each one sees the shifts of the last.

Time until a tower falls asleep, before and after the block solver10 boxes, aligned: before 7.4 s, after 0.9 s. 10 boxes, staggered 5 cm: before 5.6 s, after 0.9 s. 10 boxes, dropped from 10 cm gaps: before 6.7 s, after 1.4 s. 10 boxes, staggered 10 cm, 5 cm gaps: before 3.5 s, after 1.5 s. 15 boxes, aligned: before never, after 1.4 s. 20 boxes, staggered 3 cm: before not measured, after 2.4 s.before: sequential solver, 30 iterationsafter: block solver, 10 iterations0 s2 s4 s6 s8 s10 boxes, aligned7.4 s0.9 s10 boxes, staggered 5 cm5.6 s0.9 s10 boxes, dropped from 10 cm gaps6.7 s1.4 s10 boxes, staggered 10 cm, 5 cm gaps3.5 s1.5 s15 boxes, alignednever (20 s test)1.4 s20 boxes, staggered 3 cmbefore: not measured2.4 s

time until every body in the tower is asleep. the tests stop at 20 s.

the block solver is also much less sensitive to the iteration count. time until sleep, by iterations:

iterations10-box tower20-box tower
61.1 scollapsed
80.9 s3.0 s
100.9 s2.4 s
150.7 s1.7 s
200.7 s1.4 s
300.6 s1.0 s

at 6 iterations the 20-box tower collapses. from 8 up it stands and sleeps, so 10 (the Box2D Lite default) has some margin.

step time

Average step time per scene against the 16.7 ms frame budgetPyramid, 210 boxes: 0.86 ms. Pyramid, 210 boxes, sleeping on: 0.29 ms. Pile, 500 bodies: 1.47 ms. Pile, 500 bodies, sleeping on: 1.39 ms. Pile, 1000 bodies: 2.59 ms. Budget 16.7 ms.0 ms4 ms8 ms12 ms16 ms16.7 ms = one frame at 60 fpsPyramid, 210 boxes0.86 msPyramid, 210 boxes, sleeping on0.29 msPile, 500 bodies1.47 msPile, 500 bodies, sleeping on1.39 msPile, 1000 bodies2.59 ms
scenebodiesavg msp95 msmax mspeak contactsawake at end
Pyramid, 210 boxes2100.861.101.50403210
Pyramid, 210 boxes, sleeping on2100.290.901.204030
Pile, 500 bodies5001.471.802.50986500
Pile, 500 bodies, sleeping on5001.391.702.00986500
Pile, 1000 bodies10002.593.904.7021161000

these are full World.step calls: contacts, solver, integration and sleeping. 1,000 awake bodies cost 2.6 ms of a 16.7 ms frame. sleeping is worth a lot when it works: the pyramid drops from 0.86 ms to 0.29 ms once it is asleep. it does not work everywhere. the 500-body pile never falls asleep in the 8 seconds I measured, so its two rows are the same.

broadphase

Broadphase time against body count, spatial hash versus brute force, log scale250 bodies: hash 0.03 ms, brute force 0.12 ms. 500 bodies: hash 0.05 ms, brute force 0.38 ms. 1000 bodies: hash 0.1 ms, brute force 1.48 ms. 2000 bodies: hash 0.27 ms, brute force 6.05 ms. 4000 bodies: hash 0.6 ms, brute force 25.7 ms.0.01 ms0.1 ms1 ms10 ms100 ms2505001,0002,0004,000bodiesbrute force, every pairspatial hash25.7 ms0.6 ms
bodiesoverlapping pairsspatial hashbrute forcespeedup
2501230.03 ms0.12 ms4.4×
5002510.05 ms0.38 ms7.7×
10004880.10 ms1.48 ms14.8×
20009800.27 ms6.05 ms22.4×
400020640.60 ms25.70 ms42.5×

the broadphase is a spatial hash. for 4,000 bodies it takes 0.6 ms, and testing every pair takes 25.7 ms, about 42 times slower. the gap grows with body count because one is roughly linear and the other is quadratic. both find exactly the same pairs, and the benchmark page checks that on every run.

how to read these numbers: production build, headless Chrome 153 driven by Playwright, Windows 11, AMD Ryzen 7 5800X. one machine, timer resolution 0.1 ms, and two runs differed by up to about 12% (the pyramid), usually by less.

the "before" tower times come from an earlier solver setup (30 iterations, no block solver), so that chart compares the whole change, not one part of it. the same goes for the step times: they dropped by roughly four times after the change, but I changed the iteration count and the browser mode at once, so I can't credit either one alone. it went from 3.3 ms to 0.86 ms for the pyramid, and from 10.4 ms to 2.6 ms for 1,000 bodies.

##how I checked it's right

a physics engine can look right and be wrong, so most tests compare against something independent of the code they test:

claimwhat it is checked against
free fallthe analytic formula, the exact semi-implicit Euler sum, and the first-order error bound between them
elastic collisionsconservation of momentum and energy, to 1e-9
pendulumthe period of a physical pendulum, within 2%
springthe static deflection g/ω² and the requested frequency
polygon collisionthe older box-box code on 4,000 random pairs, and a second distance computation on 3,000 random circle-polygon pairs
broadphasea brute-force pair loop using independently computed bounds, at five cell sizes
tunnelingthe same shot with continuous collision off, which must tunnel, so the setup itself is proven
determinismtwo runs compared bit for bit, sleep flags included

then I sabotaged the code on purpose and checked that the tests noticed. a test that has never failed hasn't proven anything.

partwhat I broketests that failed
polygon clippingpicked the wrong incident edge10 of 24
circle vs polygonremoved the corner region3 of 24
block solveraccepted negative (pulling) impulses3 of 26
block solvernever paired the two contact points5 of 26
broadphasegrid cells only one column wide11 of 12
joint limitdropped the lower limit1 of 27
ropelet it push like a rod2 of 27
springdropped the spring bias2 of 27
motorignored the torque cap2 of 27
joints and sleepstopped waking joint partners1 of 11
deploybase path "/" instead of "/impulse/"1 of 2

the sabotage and the ordinary failures turned up problems on both sides. several times a test failed because my test was wrong, not the engine: a vector helper mutated its input and broke my "is the point inside" oracle, and a test expected a bullet to stop against a small circle that it only grazed, when a glancing hit should deflect it. and one test found a real bug: removing a joint left a sleeping body asleep in mid-air, so removing a joint now wakes its bodies.

##what it still can't do

a pile of 500 mixed bodies never falls asleep in the time I measured, so sleeping helps stacks and pyramids but not big chaotic piles.

continuous collision only stops bodies at static geometry. two fast dynamic bodies can still pass through each other.

a ball bouncing under gravity comes back about 3.5% too high at 1/60 s, because gravity is added before the impact is solved. I believe Box2D does the same. collisions without gravity are exact.

determinism was only checked on one machine's Node and Chrome. other browsers or CPUs may differ in Math.sin and Math.cos. and "no allocations in the hot paths" is a design goal that no test enforces.

the source, the tests and the benchmark are in the repo, and the demo lets you drop shapes in and grab them with the mouse.