Five of the games on this site run on the same 276-line file. It does Verlet integration, distance constraints and circle collision, and that is enough for ropes, cloth, ragdolls, soft bodies and trusses — which between them cover every physics game here. It has no dependencies and I wrote all of it.
I also got it wrong five times, and every one of those bugs shipped a game that ran perfectly, threw nothing, and was simply not true. That is the thing about physics code: a wrong answer is still a number, the frame still renders, and the only symptom is that the game feels bad in a way you cannot name.
What follows is each of the five, with the wrong version actually run rather than described. The measurement harness is tools/measure-engine.js; it loads the same verlet.js the site ships and runs both code paths side by side.
First, the one idea the whole file rests on
Verlet integration does not store velocity. It stores where a point is and where it was, and velocity is the gap between them:
function integrate(p, gx, gy, drag) {
var vx = (p.x - p.px) * drag;
var vy = (p.y - p.py) * drag;
p.px = p.x; p.py = p.y;
p.x += vx + gx;
p.y += vy + gy;
}
Two consequences follow, and every bug below is one of them misunderstood.
A constraint is solved by moving points. To hold two points a fixed distance apart you move them together or apart, and correcting the position corrects the velocity for free. There is no impulse solver to write, which is why the file is short.
Moving a point is applying a velocity. If you move a point without moving its previous position, you have not teleported it — you have kicked it. This is the useful half of the trick and it is also the trap, because ordinary-looking assignment statements are secretly forces.
Bug 1 — I broke the beam at the wrong moment
Span is a bridge builder. Its entire claim is the first thing anyone learns about structures: a square folds and a triangle does not. You place beams, a load rolls across, and an overstretched beam snaps.
Snapping is one line. A constraint knows its rest length and its current length, so strain is the difference over the rest length, and past some threshold the beam breaks. I put that test inside the solver, next to the code that already computed the length:
function solveOne(c) {
var d = distance(c.a, c.b);
c.strain = Math.abs(d - c.len) / c.len;
if (c.strain > c.breakAt) { c.broken = true; return; } // wrong
...pull the two points back toward the rest length...
}
The result was a game that taught the opposite of the truth. A carefully triangulated truss shed diagonals on the first frame and collapsed. A lazy flat deck, with a quarter of the members, survived longer. I spent an evening retuning the break threshold before accepting that no threshold could fix it, because the ordering was wrong rather than the number.
Here is why. Constraints are solved iteratively — the engine runs the whole list 14 times per frame, each pass nudging things closer to a consistent shape. The first pass sees the raw output of integration: every point has just fallen under gravity and nothing has been pulled back yet. That displacement is far larger than the load any member actually ends up carrying, and it is largest exactly where the structure is stiffest, because a stiff structure resists more.
So the test was reading a transient, and reading it in the one place where being well-built made your numbers look worst.
The fix is to relax first and judge afterwards. Breaking became its own pass, run once, after the solver has finished:
V.solve(beams, 14); // relax the whole structure
V.fracture(beams); // now ask what is genuinely overstrained
Show the numbers
| Bridge and fracture timing | Members snapped |
|---|---|
| Flat deck · judged mid-solve | 4 |
| Truss · judged mid-solve | 11 |
| Flat deck · judged after relaxing | 3 |
| Truss · judged after relaxing | 0 |
Both bridges get the same beam budget and the same load. Judged mid-solve, the truss loses 11 of its 21 members and falls; the flat deck loses 4 of 5 — 3 of them before the load had even started moving — and also falls. Every bridge fails, and the better one fails harder.
Judged after relaxation, the flat deck breaks 3 members and drops the load, and the truss breaks 0 and carries it across. Its peak strain is 0.87% against the flat deck's 1.37%, which is the difference the game is about. The break threshold sits at 1.4000000000000001%, between the two — set from that measurement rather than from taste.
The general form: an iterative solver's intermediate states are not physics. They are the arithmetic on the way to physics. Any decision you make from inside the loop — breaking, scoring, playing a sound, spawning a particle — is a decision made on a number that does not mean anything yet.
Bug 2 — a driven pinned point is an impulse, and it is quadratic
Upright is a cart-pole: a cart on a rail, a pole balanced on it, and two arrow keys. The cart is pinned, because it is driven rather than pushed, so it moves by plain assignment:
cart.x += cartV;
cart.px = cart.x; // the cart itself carries no velocity
My first tuning was unplayable. The pole fell in about a second, and a proportional-derivative controller — the textbook answer to a cart-pole, and a thing that has no reaction time — could not hold it either. I assumed gravity was too strong and turned it down, twice, and the game stayed impossible while becoming visibly sluggish.
Gravity was not the problem. The cart was.
The cart has no velocity of its own; I zero it every frame. But the rod still has to keep its length, and with the cart pinned the tip absorbs the entire correction. The tip gets moved and its previous position does not — which, in this integrator, is the definition of a kick. And the size of that kick is pure geometry: move the base sideways by v and a rod of length L is stretched to √(L²+v²), so the solver hauls the tip back in by the difference.
kick = √(L² + v²) − L ≈ v² / 2L
Quadratic in the cart's speed. Doubling how fast the cart can move quadruples what it does to the pole. That is why turning gravity down never helped: gravity is linear and the thing fighting me was not.
Show the numbers
| Cart speed | Kick (px/frame) |
|---|---|
| 1 px per frame | 0.003 |
| 3 px per frame | 0.027 |
| 5.5 px per frame | 0.09 |
| 9 px per frame | 0.241 |
| 14 px per frame | 0.582 |
| 22 px per frame | 1.434 |
| 40 px per frame | 4.696 |
| Cart speed (px/f) | Measured, L=168 | √(L²+v²) − L | Measured, L=74 |
|---|---|---|---|
| 1 | 0.003 | 0.003 | 0.007 |
| 3 | 0.027 | 0.027 | 0.061 |
| 5.5 | 0.090 | 0.090 | 0.204 |
| 9 | 0.241 | 0.241 | 0.545 |
| 14 | 0.582 | 0.582 | 1.313 |
| 22 | 1.434 | 1.434 | 3.201 |
| 40 | 4.696 | 4.696 | 10.119 |
The measured column and the closed form agree to three decimal places at every speed, which is the sort of thing that makes a debugging session end abruptly. At the cart speed I had originally allowed — 22 pixels a frame — one frame of movement hands the pole 1.434 pixels per frame of sideways velocity. At the 5.5 the game shipped with, it is 0.09 — sixteen times smaller for four times less speed.
There is a second edge on this. Upright's difficulty ramp shortens the pole over time, from 168 pixels down to 74. But the kick goes as 1/L, so shortening the pole makes every cart movement hit harder — the last column of that table is the same speeds against the short pole, and it is consistently 2.2× worse. The difficulty ramp was multiplying the bug it was hiding.
The general form: in a position-based integrator, look for every place you assign to .x or .y and ask what the constraint solver will do about it. Those assignments are your force API whether you meant them to be or not.
Bug 3 — the engine flame ate the game
Physics must not depend on the monitor. The usual fix is to scale movement by elapsed time; the better fix, and the one this site uses, is a fixed step — accumulate real time, run the simulation in 16.67ms increments, and draw whatever is left over:
acc += dt;
while (acc >= 16.667) { step(); acc -= 16.667; }
draw();
That makes step run 60 times a second on every machine. It also means step and draw now run at different rates, and that is a new hazard that did not exist before.
Space Dodge drew a flickering flame behind the ship, in draw, with Math.random(). Cosmetic, obviously — it scores nothing and collides with nothing. But the asteroid field came from the same generator, and draw ran 60 times a second on my laptop and 144 on a better monitor. The number of random draws consumed between two gameplay decisions therefore depended on the display.
Two players pressing start at the same moment got different games, and no line of game logic was wrong.
| Refresh rate | Flame sharing the game's generator | Flame with its own generator |
|---|---|---|
| 60 Hz | Same game | Same game |
| 75 Hz | Different from spawn 5 | Same game |
| 120 Hz | Different from spawn 2 | Same game |
| 144 Hz | Different from spawn 2 | Same game |
| 240 Hz | Different from spawn 2 | Same game |
Sixty simulation steps from a single seed. With the flame sharing the generator, a 120Hz display diverges from a 60Hz one at the very first spawn; even 75Hz has gone its own way within four. With the flame on its own generator, every rate produces an identical game — which is the whole property a fixed step was adopted to obtain, and which the flame had quietly given back.
The fix was three lines: drive the flicker off the clock instead.
var fl = Math.sin(flicker * 0.7) * 0.5 + 0.5;
The general form: the moment simulation and rendering run at different rates, the random generator becomes shared mutable state between them. Either give the cosmetics their own stream, or do not let them have randomness at all.
Bug 4 — asking an element where it is
This one is not physics, and I include it because it cost me three attempts and each wrong version looked completely fine in the console.
2048 positions its tiles with a CSS transform and slides them on a 110ms transition. When a merge happens I want a +64 to leap off the tile that merged. So: where is that tile?
The first answer is offsetLeft and offsetTop, which sound exactly like the question. They are always zero for a transform-positioned element, because offsets describe layout and a transform is not layout. Every score label piled up in the top-left corner of the board. Across 13 sampled tiles on a 404px board, the mean error was 248.6 pixels.
The second answer is getBoundingClientRect(), which is genuinely correct — it is measured after layout and after transforms. It is also correct only at the instant you call it, and the instant I called it was the moment of the merge, which is the moment the tiles are part-way through their previous slide. The number appeared wherever the tile happened to be passing. Mean error 161.8 pixels, worst case 303 — on a board 404 pixels wide.
Both of those printed clean logs. Both "worked". The only way to see either was to take a screenshot and look at it.
The third answer is the one that was available the whole time: the grid knows. Row and column times cell pitch, plus half a cell.
function cellCentre(r, c) {
var m = cellPx();
return { x: m.g + c * (m.c + m.g) + m.c / 2,
y: m.g + r * (m.c + m.g) + m.c / 2 };
}
Exact, no measurement, no timing. The general form: if your model already knows the answer, do not ask the DOM. Reading a position back out of the browser is a round trip through two systems that are allowed to lag your state, and the lag is worst during exactly the animations that made you want the position.
Bug 5 — the classic, which I still wrote
Adding wave progression to Weight of Arrows took ten minutes and broke the game instantly with Cannot read properties of undefined. Clearing a wave removes the spent arrows:
for (var k = arrows.length - 1; k >= 0; k--)
if (arrows[k].stick) arrows.splice(k, 1);
Which is fine, except a wave clears the moment an arrow scores the point that completes it, and that happens inside the handler for a hit, which is called from the loop iterating arrows. I emptied the array I was standing in.
Everyone knows not to do this. It is still easy to do when the mutation is four call frames below the loop and neither function mentions the other. The fix was to decide inside the frame and act at the end of it:
if (waveScore >= quota) { pendingClear = true; return; }
// ...and later, after stepArrows() has finished:
if (pendingClear) { pendingClear = false; clearWave(); }
Nothing about the reward needed to happen before the frame ended. It never does.
What I would tell myself before starting
- Write the test that can fail. Span has a script that builds both bridges and asserts that the flat one falls and the truss crosses. That test would have caught bug 1 on the first day; I wrote it on the fourth.
- Run the wrong version on purpose. Every number in this article comes from actually executing the broken code path. Until you do that you have a theory about what went wrong, and theories about physics bugs are usually flattering.
- Derive the closed form when there is one. Bug 2 was three days of tuning parameters and about twenty minutes of trigonometry, in that order. The trigonometry would have worked in either order.
- Take the screenshot. Bugs 4 is invisible to logging by construction. The console said everything was fine because everything the console could see was fine.
The engine is assets/js/verlet.js and it is 276 lines including the comments explaining the above. The games built on it are Span, Upright, Weight of Arrows, Topple and Rope Swing — all free, all in the browser, none of them asking you to sign in.