Field notes
Inside the Simulator
We built a 3D physics sandbox that runs in a browser tab, weighs about a hundred kilobytes, has no dependencies, and draws everything in isometric projection on a phosphor screen. It is free to use. This is how it actually works, including the parts we got wrong the first time.
One file, no libraries
PHYS//LAB is a single HTML file. No build step, no npm, no framework, no WebGL. Everything is plain JavaScript and a 2D canvas. You can save the page and it will still run in ten years, offline, on a laptop with no network.
That constraint was deliberate, but it was also a decision made against the obvious alternative. Matter.js, Planck and Rapier are excellent open-source physics engines, and we did consider them. All three are built around rigid bodies falling downward. None of them does mutual gravitation between every pair of bodies, which is the one thing this project is actually about. We would have written the interesting forces by hand anyway and inherited a dependency for the rest.
The integrator
Every frame, each body needs a new position. The naive way is Euler integration: take the current velocity, multiply by the timestep, add. It is one line and it is wrong in a specific, visible way. Orbits made with Euler spiral outward, because the method systematically overestimates the distance travelled on a curve. Leave it running for a minute and your solar system dismantles itself.
We use velocity Verlet instead. Position advances using both velocity and acceleration, then the velocity update is split into two half-kicks around a fresh force evaluation:
x += v*dt + 0.5*a*dt*dt
v += 0.5*a*dt // half kick, old acceleration
computeForces()
v += 0.5*a*dt // half kick, new acceleration
The result conserves energy far better over long runs. A 25-body system in the ORBIT scene keeps every single body for 30 seconds, with the mean orbital radius oscillating between 287 and 381 units around a starting value of 310. Nothing spirals away, nothing collapses into the star.
Gravity, softly
Mutual gravitation is an all-pairs problem: every body pulls on every other body, so the cost grows with the square of the population. For the few hundred bodies this tool deals with, that is fine.
The subtlety is what happens when two bodies get very close. The inverse-square law divides by distance squared, so as distance approaches zero the force approaches infinity, and a single frame can fling a body to the far edge of the universe. The standard fix is softening: add a small constant to the squared distance before taking the root. Physically it says "treat bodies as slightly fuzzy rather than as points". Practically it means the simulation never explodes.
The spring bug
This is the mistake worth writing down.
The soft-body scenes are lattices of spheres connected by springs. Hooke's law says force equals stiffness times extension. We implemented exactly that, and the jelly cube collapsed under its own weight like wet cloth. Stiffening the springs did not help much.
The reason: our bodies get their mass from their volume, and a sphere of radius 33 weighs about 3000 units. To hold that up, a spring with an absolute stiffness constant needs an extension of hundreds of units. The stiffness number meant something completely different depending on how big the spheres happened to be. The same slider produced a rigid structure at one scale and a puddle at another.
The fix is to scale the spring force by the reduced mass of the pair:
const mred = 1/(a.invMass + b.invMass);
const f = (stiffness*extension + damping*relVel) * mred;
Now the natural frequency of a spring depends only on the stiffness setting, not on the mass of what it connects. A grain of sand and a planet respond identically to the same number. After the change, poking the jelly cube spikes its strain to 2.6% and it settles back to its resting 0.66% within about two seconds, which is exactly the behaviour we wanted from the start.
The general lesson: a physics parameter that a human moves with a slider should be scale-invariant. If the same value means different things at different sizes, the slider is broken even when the equation is right.
Collisions
Checking every pair of bodies for contact is wasteful, so bodies are binned into a 3D grid of cells sized to the largest radius. Only bodies in neighbouring cells can possibly touch.
The obvious implementation checks all 27 surrounding cells and then has to remove duplicate pairs, because cell A finds B and cell B finds A. Keeping a set of seen pairs costs memory allocation on every step. The trick is to only walk half the neighbourhood: 13 of the 27 offsets, chosen so that each unordered pair is visited exactly once. No dedupe structure, no allocation.
Contacts are resolved with impulses, plus positional correction with a small tolerance so that resting stacks settle quietly instead of jittering forever. In a 300-body pile the worst remaining overlap is 0.13 units against radii of about 14, which is under one percent.
Spin, and the angular momentum that went missing
We checked the simulation against the conservation laws, one at a time. Linear momentum came out exact. Energy came out exact. Angular momentum drifted by nearly nineteen percent, which is the kind of number that means something is actually wrong.
Isolating it took three runs. With gravity alone the drift was zero. With collisions but no friction it was two percent. With friction it jumped to seventeen. The culprit was friction, and the reason was that our spheres could not spin.
A tangential friction impulse acts at the surface, not at the centre. In reality it torques both bodies, and the rotation it creates carries exactly the angular momentum that the orbital motion loses. With no rotational degree of freedom, that angular momentum simply vanished from the universe.
So the bodies got rotation: an angular velocity vector, a moment of inertia of
2/5·m·r² for a solid sphere, and contact resolution that uses the
velocity at the contact point rather than at the centre. The friction impulse now spins
both surfaces the way meshing gears turn each other.
Measured on a single oblique impact afterwards: orbital angular momentum fell by 152,765 and spin angular momentum rose by 159,866. The spin picks up what the orbit puts down.
The same treatment went to wall contacts, and that produced the result we are most pleased with. A sphere launched sliding across the floor at 200 units per second spins up under friction until the contact point stops slipping, and then it rolls. The textbook says a sliding sphere settles into rolling at exactly five sevenths of its initial speed. Five sevenths of 200 is 142.857.
t=0.0 v=196.0 slip ratio 19.71 (sliding)
t=0.5 v=142.86 slip ratio 1.0000 (rolling)
t=5.5 v=142.86 slip ratio 1.0000 (still rolling)
Once it rolls, the contact point has no relative velocity, static friction does no work, and the speed holds at 142.86 across seven hundred units of travel with a loss of zero percent. That is not a tuned approximation. It is the analytic answer falling out of the solver.
What else we checked
Since the rotation work started with a failed conservation test, we ran the rest of them properly rather than assuming:
| Law | Measured | Exact |
|---|---|---|
| Kepler III, T²/a³ at three radii | 3.068e-6 (identical) | constant |
| Vis-viva, worst error over an ellipse | 0.0000% | 0 |
| Circular orbit radius variation | 0.029% | 0 |
| Free fall velocity after 2 s | 1039.89 | 1040 |
| Terminal velocity under linear drag | 260.000 | 260 |
| Spring period, reduced-mass scaling | 0.2096 s | 0.2094 s |
| Coulomb force, distance doubled | ratio 4.000 | 4 |
| Restitution, successive bounce heights | 0.360, 0.360, 0.360 | e² = 0.36 |
| Elastic impact, unequal masses | 83.16 / 223.16 | 83.15 / 223.16 |
One honest residual: positional correction, the small nudge that stops resting bodies from sinking into each other, perturbs angular momentum by roughly one to two percent in scenes with many simultaneous contacts. It is an artifact of position projection rather than of the impulse solver, and linear momentum and energy remain exact throughout.
The quantum section
A classical particle engine cannot solve the Schrödinger equation, and we are not going to pretend otherwise. What it can do honestly is sample. Every body in the quantum section is one draw from an exact analytic probability density, so the cloud you see is the distribution, built out of points.
The orbitals use the real hydrogen densities. Rejection sampling in a box, with the peak estimated first so the ceiling is valid. Checking the result against the known mean radii is a good test of whether the sampler is honest:
| Orbital | Sampled 〈r〉 | Theory |
|---|---|---|
| 1s | 1.53 | 1.5 |
| 2s | 6.11 | 6 |
| 2pz | 5.10 | 5 |
| 3dz² | 10.55 | 10.5 |
| 4fz³ | 17.95 | 18 |
The nodes survive too, which is the part that looks best in isometric. The 2s orbital has a spherical shell where the wavefunction crosses zero, and not a single sample landed in it. The 2p orbital vanishes on the equatorial plane, and 0.13% of samples fall near it against a uniform expectation many times higher. Those empty regions are the geometry of the mathematics, not a rendering trick.
The uncertainty packet enforces the relation rather than illustrating it. You choose
the position spread with a slider, and the momentum spread is whatever
ℏ/(2σx) demands. Squeeze the cloud twenty times tighter
and the velocities get twenty times wilder, while the product stays pinned:
sigma_x 17.7 sigma_p 3319 product 58750
sigma_x 70.8 sigma_p 830 product 58750
sigma_x 177.0 sigma_p 332 product 58750
sigma_x 354.0 sigma_p 166 product 58750
Press MEASURE and the packet localises further, which forces the momentum spread up and blows the cloud apart. That is the observer effect, done as arithmetic rather than as an animation someone drew.
The double slit samples landing positions from the true two-slit intensity, the interference term multiplied by the single-slit envelope. Fire a few hundred particles with trails on and the fringes assemble one dot at a time. Switch to ONE SLIT and they collapse into a single blob: five local maxima become one. The flight path drawn between slit and screen is the usual visual shorthand, since a real particle has no definite trajectory there, and the panel says so.
The scale ladder
One slider walks the whole thing from a flat plate of molecules seen from directly above, through cells, sand, everyday objects, planets and stars, out to a galaxy in isometric projection.
What makes it feel like one continuous move rather than seven separate demos is that the world itself changes shape. At the bottom of the ladder the simulation volume is squeezed to a thin slab, so it is effectively two-dimensional, and the camera looks straight down. As you climb, the slab opens into a full cube and the camera eases into isometric. Brownian agitation fades out, gravity fades in, mutual gravitation takes over at the top.
Strange attractors, and why they ran away
The most visually interesting mode steers every body along a strange attractor: Lorenz, Rössler, Aizawa, Thomas, Halvorsen. These are systems of three differential equations whose solutions never repeat and never escape, tracing those famous butterfly and shell shapes.
Bodies are not pushed by a force here. They are steered: the local value of the equation becomes a target velocity, and each body accelerates toward it. That works, but our first version had every attractor flying apart, with two of them losing all 288 bodies to infinity.
Three separate causes, found in order:
- The target velocity was not scaled to the size of the attractor. Lorenz lives in a box roughly 40 units wide; our world is 900. The mapping was off by an order of magnitude.
- A speed clamp meant to prevent blowups was cutting the field exactly where it was strongest, which distorts the trajectory shape rather than just slowing it down.
- Most importantly, we seeded bodies in a random cloud. An attractor's basin is not the whole of space. Points outside it do not converge, they diverge, and for Halvorsen the basin is small enough that most of a random cloud starts outside it.
The fix for the third one is the interesting part. Instead of guessing start positions, the tool integrates the raw differential equation first, discards the transient, and then samples points along the resulting orbit. Every body therefore begins on the attractor and chaos spreads them along it from there. Seeding takes about ten milliseconds.
Measured afterward, five of the seven fields sit comfortably inside their containment radius rather than being pressed against it, and Halvorsen settles at 537 units against a theoretical extent of 544.
The 92-millisecond line
On a large display the tool crawled. The instinct is to blame the physics: hundreds of bodies, an all-pairs force loop, collision detection. The instinct was wrong.
Measuring a canvas is harder than it looks, because canvas drawing commands are queued and the timing you get back does not include the actual rasterisation. Forcing a synchronisation point after each frame gives honest numbers. At 14.3 megapixels:
| Frame component | Cost |
|---|---|
| CRT layer (scanlines + vignette) | 92 ms |
| Glow on every body | 8 ms |
| Physics and all geometry | 7 ms |
| Total | 112 ms → 9 FPS |
The retro screen effect cost twelve times more than the entire simulation. It was filling the full screen twice per frame, once with a scanline pattern and once with a radial gradient, and both scale with pixel count rather than with anything happening in the scene.
Three changes followed. The effect moved back into a CSS overlay, which the compositor handles for free, and is now painted into the canvas only for frames that are actually being exported. The per-body glow stopped using canvas shadow blur, which runs a separate blur pass per body, and became an additive circle. And a render-scale control was added, because a high-density display quietly quadruples the pixel count.
| Resolution | Before | After |
|---|---|---|
| 14.3 MP (4K, 2×) | 112 ms / 9 FPS | 33 ms / 30 FPS |
| 3.6 MP (default) | — | 14.6 ms / 68 FPS |
| 900-body attractor | — | 11 ms / 90 FPS |
Exporting without dropped frames
Recording a canvas in real time means the recording inherits every stutter. The established answer in the generative art world is CCapture.js, which captures frame by frame at a fixed timestep so that heavy scenes still produce smooth video.
We implemented the same idea without the dependency. A canvas stream created with a
frame rate of zero emits nothing until you explicitly call requestFrame().
So the renderer advances physics by exactly one frame's worth of time, draws, pushes the
frame, and repeats. Content is frame-exact regardless of machine speed.
One honest caveat, because it matters: the WebM container timestamps frames by wall clock. If your machine cannot render a frame within its time budget, the resulting video is stretched rather than smooth. The tool measures its own pace over the first twelve frames and stops with a specific suggestion instead of handing you a slow-motion file twenty minutes later.
Designing the shot
A simulation is not an animation until someone decides where the camera goes. The lab has a timeline with keyframes that capture the full camera state, interpolated with a Catmull-Rom spline so a three-key move does not kink at the middle key.
Yaw is stored unwrapped, meaning it is allowed to exceed a full turn. Without that, a 360-degree orbit would interpolate the short way round and unwind back through zero instead of completing the circle. Three of the eight preset moves are built to close their loop exactly, so the exported video can be cut end to end without a visible seam.
Reproducibility
Everything random runs through a seeded generator rather than the browser's own. The same seed and the same shot produce an identical render, every time. For a tool meant to make finished animations, that is not a nicety, it is the difference between a toy and an instrument.
Try it
Nothing to install, nothing to sign up for. Drag a body and let go to throw it. Right-drag orbits the camera. Press the JELLY scenario and poke the cube to watch it fight its way back into shape.
Open PHYS//LAB →