← blog

 /  13 min read

Impulse: a 2D physics engine from scratch in TypeScript

2D physics with no library to blame.

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 and has zero runtime dependencies.

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. The first 12 stages built the engine and ended with a list of things that did not work: a ball that bounced 3.5% too high, a 500-body pile that never fell asleep, and fast bodies that only static walls could stop. Five more stages went through that list, and replaced the solver on the way.

0runtime dependencies
17stages, each finished before the next began
211 + 14unit tests and browser tests
2.5 msper step with 1,000 bodies awake
42×broadphase speedup over brute force at 4,000 bodies
0.9985rebound height of a bouncing ball, was about 1.035

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.

Seventeen stages

I built it in stages and did not start the next one until the current one had green tests and a working demo. Stages 13 to 17 are the second round:

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
13restitution reads the speed before gravitya bouncing ball returns to within 2% of its drop height
14raycast, box overlap query, category, mask and group filtersray hits and normals match the analytic answer; filters hold for contacts and for continuous collision
15a soft-step solver: substeps, soft contacts, a relax passevery earlier test still passes, towers of 20 boxes still stand and sleep
16damping, rolling resistance, and a fix to how spinning circles toucha rolling ball slows at the textbook rate; a 500-body pile sleeps
17contacts up to 2 cm before bodies touch, for every shape paireleven of the new tests fail with the margin set to zero; a bullet stops instead of staying pinned

What it looks like

A pyramid of 55 boxes drawn with dim amber outlines on a dark grid.
A pyramid of 55 boxes, six seconds in. The dim outlines mean 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 white contact points and normals.
A mixed pile of circles, boxes, triangles and hexagons. The white dots and lines are contact points and normals, drawn straight from the solver's data.
A ten-box tower in dim amber 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 amber 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 light grey joint markers.
The joints scene: a motor-driven paddle, a hinge that stops at its angle limits, and a spring. Light grey marks the joints.
A pile of circles, boxes and polygons with amber outlines resting in a walled container on a dark grid.
The demo, restyled to match this site: dark metal faces with amber edges, like the solids on the home page. The faint lines from the center to each corner are only decoration.

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. Stage 15 later replaced this solver altogether, as explained below.

The ball that gained 3.5%

The old step added gravity to every velocity, and then solved the contacts. Restitution reads the approach speed when the contact is set up, so the ball hitting the ground was already 9.81 × 1/60 = 0.16 m/s faster than it had been on the last step, and it bounced with that extra speed. Over a drop of 4.5 m that is a rebound 3.5% too high.

The fix is an order change, not new math: set up the contacts first, so restitution sees the speed before gravity, and add gravity after. The ball now comes back to 0.9985 of its drop height, and I tightened the test bounds from 0.95 to 1.06 down to 0.98 to 1.02. It was one of the smallest changes of the whole round and it needed the most thinking, because the solver had no place to put "after prepare, before solve", so I split it in two.

Asking the world questions

Until now the only way to find a body was a point test for the mouse. Stage 14 added world.raycast(x0, y0, x1, y1, mask), which returns the closest body, the hit point and the surface normal, and world.boundsOverlapping(box). I did not write a new ray-shape routine. The continuous collision code already sweeps a point against every shape (that is what stops a bullet at a wall), so a ray is that sweep with a radius of zero, plus the normal of the plane it entered through. The test compares circles against the analytic distance, checks a box turned 45 degrees, and a hexagon whose flat side sits at cos 30° from its center.

The same stage added collision filtering with the Box2D rules: a body has a category, a mask and a group. Two bodies meet only if each mask contains the other's category, and a shared non-zero group overrides that, positive always colliding and negative never. The check sits in one place for contacts and once more in the continuous collision sweep, and a test covers the second one, because a filtered wall that still stops fast bullets would be an odd bug to meet later.

Swapping the solver

The old solver ran 10 iterations of sequential impulses over the contacts, with a 2-point block solver for stability, and pushed overlapping bodies apart after the solve. Stage 15 replaced all of that with the idea from Box2D v3 (Erin Catto's soft step): split each step into 4 substeps, and in each substep add gravity, warm start, solve with a soft contact constraint that treats overlap like a stiff, heavily damped spring, move the bodies, then relax: solve again with the bias switched off, so the push-out speed does not turn into energy. Restitution is applied once at the end, using the speed measured before the step. That deleted the block solver and the position projection completely.

// one substep, four per step for (const joint of joints) joint.prepare(1 / substep); for (const body of bodies) if (body.isSimulated) body.integrateVelocity(gravity, substep); // warm start, then solve with bias this.iterate(joints, true); for (const body of bodies) if (body.isSimulated) body.advance(substep); // relax: same constraints, no bias this.iterate(joints, false);

The surprise was how little of the test suite noticed. On the first run of the new solver 178 of 184 tests passed. Four of the six failures were about things that had changed on purpose: free fall now sums over 4 substeps per step, resting impulses are stored per substep (the test now expects m g dt / 4), a test called "block solver" now says "two-point contacts", and a two-point elastic bounce came back at 1.76 m/s instead of 2 after one restitution sweep, so I run six. The other two were real: bullets flew through the wall. The continuous collision sweep started from the body's position minus its step motion, which is off by a rounding error, and a bullet that had been stopped exactly on the wall's surface was suddenly counted as starting inside it. Storing the exact start position fixed both. The towers, the joints, the pendulum periods and the sleeping tests passed untouched.

The one number I had to choose is the contact stiffness. I ran the same towers and pyramids at several values and let the test suite vote:

stiffnesstower top height, ideal 19.5tower asleeppyramid asleeptest suite
30 Hz19.3691.33 s0.85 sall pass
40 Hz19.4261.03 s0.82 sall pass, chosen
45 Hznot measurednot measurednot measured2 sleep tests fail
60 Hz19.4672.22 s1.45 s5 tests fail
90 Hztower flies apartnot asleep at 10 snot asleep at 10 snot run

Stiffer contacts sag less but jitter, and jitter keeps bodies awake longer. 40 Hz is the point where the old tests still hold, not a value from a paper. The 30 Hz and 60 Hz rows were measured with the solver as it stood at that moment; they use boxes only, so the circle fix below cannot have changed them.

The box that would not sleep

After the first round, a pile of 500 mixed bodies never fell asleep in 8 seconds. I assumed the solver was too loose. With the new solver it still did not sleep, so I looked at which bodies were actually moving. After 30 seconds every body but one was standing still (0.000 m/s) and that one moved at 0.020 m/s with a spin of 0.063 rad/s. Sleeping is per island, and a pile is one island, so one body kept all 500 awake.

That body was a box with a single contact point, rocking back and forth every few seconds, the way a box balanced on a ball would. A real one would lose energy to friction and to the ball sinking into the ground. So I added the missing physics: rolling resistance, a torque limited by the normal force that resists relative spin, and linear and angular damping. The first version made things worse: 495 bodies were now moving, at up to 0.02 m/s. The rolling impulse was starting from zero every step, so the constraint acted like noise. Once it was stored in the contact and warm started like the other impulses, the pile fell asleep at 4.4 to 7.5 s.

The test for it is a ball rolling without slipping. Rolling resistance r slows it at (2/3) r g / ball radius, and my first run of the test missed by a factor of 1.7. The ball hovered about a millimetre inside the ground with a downward speed of 8 cm/s, and its normal impulse read exactly zero at the end of every step I printed. The solver measures how far a contact has opened by moving the contact anchor with the body, including its rotation. For a box that is right. For a ball, the contact point stays under the center as the ball spins, but the anchor rotated away from the ground, so a spinning ball looked like it was leaving the surface by about 1 mm per step. The solver then thought there was a gap and let it fall into the ground again.

Circles now keep their anchor fixed for the separation test. The rolling test passes at the theoretical rate, and here is the part I did not expect: the 500-body pile now falls asleep at 7.5 s even with no rolling resistance at all. The rocking box was mostly this bug, not missing physics. Rolling resistance still helps (5.2 s at 0.03 m), and I kept it because the rolling test would have caught the anchor problem earlier if it had existed when the solver was written. I checked that by putting the bug back: the rolling test and the pile test both fail.

A pile of circles, boxes and polygons with amber outlines resting in a walled container on a dark grid.
The demo, restyled to match this site: dark metal faces with amber edges, like the solids on the home page. The faint lines from the center to each corner are only decoration.

Contacts before they touch

The engine only knew about bodies that overlapped. Continuous collision stops a fast body exactly at a wall's surface, where nothing overlaps, so the solver saw no contact on the next step, or did see one depending on a rounding error in the stop position. That is the same thing that made the bullets fly through walls after the solver swap. Stage 17 removes the cause: every shape pair now makes contacts up to 2 cm before touching, with a negative depth for the gap, and the broadphase grows every body's box by 1 cm per side so those pairs are found. The solver already had the other half: a contact with a gap lets the bodies close it during the substep but not cross it.

Nine shape pairs (circle, box and polygon in every order that the code treats differently) each give a contact with depth −1.5 cm at a 1.5 cm gap and none at 3 cm. A ball closing on a wall at 20 m/s from 1.5 cm away stops at the surface, where unchecked it would travel 0.33 m in one step. A ball sliding past the wall keeps its speed to nine digits. A 250 m/s bullet ends up stopped, not pinned. With the margin set to zero, eleven of these tests fail.

One character mattered more than I expected. The solver treats a positive gap as speculative and anything else as a soft contact. I first wrote "gap of zero or more" as speculative, which felt right for two bodies that exactly touch, and the 500-body pile stopped falling asleep: after 20 seconds a circle and a box were still moving at 0.03 m/s and 0.1 rad/s. With the strict "greater than zero" (what Box2D uses) the same pile fell asleep at 6.7 s. I do not have a mechanism for that. What I can say is that in the version that failed, rolling resistance of 0.01, 0.03 or 0.06 made the pile fall asleep at 6.0, 6.2 and 5.9 s, and in the final version 0.03 gives 4.7 s. So my claim in the last section was too strong: the rocking box was partly the anchor bug, but whether a big pile ever falls asleep without rolling resistance depends on details that I cannot control one by one. Rolling resistance is the part that made it robust.

The price is more contacts. Boxes that sit 2 cm apart, like the columns of my pyramid, now touch speculatively: the peak went from 401 to 546 contacts, and the awake pyramid from 0.92 ms (stage 16) to 1.18 ms.

Performance

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 did not work everywhere in the first round: the 500-body pile never fell asleep in the 8 seconds I measured, so its two rows are the same.

The same scenes after stage 17, average time per step:

scenebefore (ms)after (ms)asleep at the end
pyramid, 210 boxes0.861.18no (kept awake on purpose)
pyramid, 210 boxes, sleeping on0.290.20all 210
pile, 500 bodies1.471.44no (kept awake on purpose)
pile, 500 bodies, sleeping on1.391.03before: 0 of 500, after: all 500
pile, 500 bodies, sleeping on, rolling resistancenew0.67all 500, over 10 s
pile, 1000 bodies2.592.47no (kept awake on purpose)

4 substeps with a relax pass cost about the same as 10 iterations. The awake pyramid got 37% slower (0.86 to 1.18 ms) because of the extra speculative contacts, and every other row got faster. Two runs of the same scene differ by up to about 12%, so I would not read anything into the small differences.

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.

Verification

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.

Limits

A 20-box tower still compresses by about 7 cm at 40 Hz. Soft contacts trade stiffness for stability, and I have not found a better place on that trade.

The 2 cm margin is a constant. A body that starts a step farther than that from a surface and moves farther than that in one step (over 1.2 m/s at 60 Hz) is not seen by contacts, so thin static walls still depend on continuous collision. Two fast dynamic bodies can still pass through each other.

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 code, tests and benchmark are in the repository, and the demo lets you drop shapes in and grab them with the mouse. Every number in this post comes from a run of the test suite, the benchmark page in headless Chrome, or a measurement script kept out of the repository.