loc bengaluru, ist | local --:-- srijanshukla18@gmail.com
[post]/tech/physics-explainer-whose-lessons-are-unit-tests

A physics explainer whose lessons are unit tests

/ 12 min read· others

How I built Spin Lab, an interactive table-tennis spin course, by keeping the physics pure, precomputing every shot into a frame timeline, and asserting the prose itself in a headless test suite. Plus the calibration bug that proved four of my serves were physically impossible.

I built Spin Lab, a nine-chapter interactive course on how table-tennis spin actually works. Magnus flight, the bounce kick, the still-racket trap, sidespin, corkscrew, serves, and a stroke matrix.

The interesting part is not the 3D. It is that the lessons are unit tests.

When chapter 2 says “at this pace topspin lands mid-table with room to spare, the flat ball barely scrapes the end line, and backspin floats off”, there is a test in tools/check.mjs that asserts exactly that, against the same simulation the browser runs:

report("ch2 default: topspin lands mid-table with margin",
  top.outcome === "lands" && top.bounce.x < 1.0);
report("ch2 default: flat ball lands but only just",
  flat.outcome === "lands" && flat.bounce.x > 1.0);
report("ch2 default: backspin floats long", back.outcome === "long");

There are 228 of these. If I tweak the drag coefficient and a sentence in the prose stops being true, the test suite tells me which sentence. That is the whole design, and everything below is in service of it.

the one architectural rule

physics.js      pure math. no DOM, no THREE, no rendering
    |
scenarios.js    simulate -> precomputed frame timelines
    |
player.js       plays a timeline back
    |
world.js        three.js scene. only draws what it is handed
    |
chapters/*      copy, controls, per-frame dressing
tools/*.mjs     run scenarios.js headless in node, assert the numbers

physics.js imports nothing. That is the rule that makes the test suite possible: Node can import the exact same module the browser does and get bit-identical numbers, with no jsdom, no canvas shim, no mocking.

precompute, then play

Nothing simulates inside requestAnimationFrame. Each chapter builds the entire shot up front as an array of frames, and the player interpolates through it:

export function simulateFrames(initial, { dt = SIM_DT, maxTime = 4, until = null } = {}) {
  const state = cloneState(initial);
  const frames = [makeFrame(state, 0)];
  for (let i = 1; i <= Math.ceil(maxTime / dt); i++) {
    const { prev, events } = stepWithEvents(state, dt, options);
    frames.push(makeFrame(state, i * dt, events[0]?.type ?? null));
    if (until?.(frames.at(-1), { prev, events, index: i })) break;
  }
  return { frames, events, final: cloneState(state) };
}

This buys three things at once. Slow motion and scrubbing become free, because they are just interpolation over a fixed array rather than a variable timestep that would drift. The outcome of a shot is known before it is drawn, so the UI can predict a landing with a dashed line. And the tests assert against the identical array, not a re-run.

one impulse solver for every contact

There are five places a table-tennis ball hits something: the table, the net, a still racket, the server’s racket, and your racket. Naively you write five functions and they quietly disagree about spin.

Instead there is one:

export function resolvePlaneImpact(state, normal, surfaceVelocity, restitution, friction) {
  const n = normalizeVec(normal, { x: 0, y: 1, z: 0 });
  const radiusVector = scaleVec(n, -BALL_R);
  const angularVelocity = vec(state.spinX || 0, state.spinY || 0, state.spin);
  const contactVelocity = addVec(vec(state.vx, state.vy, state.vz),
                                 crossVec(angularVelocity, radiusVector));
  const relativeVelocity = subVec(contactVelocity, surfaceVelocity);
  const normalSpeed = dotVec(relativeVelocity, n);
  if (normalSpeed >= 0) return;

  const normalImpulseMag = -(1 + restitution) * normalSpeed * BALL_M;
  // Coulomb friction cap, with the effective inverse mass of a sphere that
  // can both translate and spin up.
  const effectiveInvMass = (1 / BALL_M) + (BALL_R * BALL_R / BALL_I);
  const tangentialImpulseMag = Math.min(tangentialSpeed / effectiveInvMass,
                                        friction * normalImpulseMag);
  // ... apply, then derive the spin change from the torque
  const deltaAngularVelocity = scaleVec(crossVec(radiusVector, totalImpulse), 1 / BALL_I);
}

The min(ideal, mu * normal) is the whole trick: below the cap the contact rolls and spin is fully transferred, above it the surface slips. Because every single contact in all nine chapters goes through this function, spin transfer is consistent everywhere by construction. The bounce kick in chapter 3 and the serve in chapter 7 cannot disagree, because they are the same code.

the corkscrew lesson wrote itself

Magnus force is a cross product:

const omega = vec(state.spinX || 0, state.spinY || 0, state.spin);
const liftAxis = crossVec(omega, velocity);

For most of the build I only had two spin axes: spin about z for topspin/backspin, spinY about vertical for sidespin. The x component was hardcoded to zero.

Then I read up on corkscrew spin, where the axis points along the flight path like a thrown American football. I added spinX, threaded it through the state helpers, and the physics was already there. omega x velocity with omega parallel to velocity is zero. A corkscrew ball does not curve, and I never wrote a line of code to make that true. It emerged.

So I wrote a chapter about it, and a test:

report("ch6 spin axis along the flight path produces zero Magnus force",
  Math.hypot(corkMagnus.x, corkMagnus.y, corkMagnus.z) < 1e-9);

And then the simulation corrected me. The ball drifts 8.2 cm, not zero.

The reason is real and better than what I had written. omega x v vanishes only when the axis is exactly along the velocity, and a served ball is descending - so a horizontal axis sits about 22 degrees off the path. Better still, that angle is not arbitrary: it is exactly acos(|vx| / |v|), the angle the flight path makes with the axis, verified to 1e-9. Since the cross product scales with sin(theta), roughly 38% of the sideways force survives, which is why the drift lands near a third of a pure sidespin break rather than at zero.

I had written “flies dead straight”. I changed the prose, not the number, and put the alignment percentage in the readout so a reader can watch the cancellation be imperfect.

The tests changed too, and this is the part I would defend hardest. The drift assertion used to be a tolerance I picked (< 0.11). Now it asserts the physics: that the angle is exactly acos(|vx|/|v|), that the residual Magnus force equals the analytic value at that angle, and that the drift comes out at sin(theta) of a pure sidespin break (0.33 measured against 0.38 predicted). A suite that asserts your prose only works if you change the prose more often than you move the thresholds.

inverse strokes, forward strokes, and being honest about which

Two ways to model a stroke, and they are epistemically different.

Forward: the user sets a bat angle and a swing direction, physics decides where the ball goes. This is applyManualStroke. It can fail, and that is the point. The sandbox chapter uses it, so when you net a ball it is because your racket was genuinely wrong.

Inverse: I declare the intended landing spot, bisection-solve the launch velocity that reaches it, then derive the racket that would have produced that launch. This is applyStroke plus solveVy. It always “works”, because I worked backwards from the answer.

The 18-cell stroke matrix uses the inverse model, because a coaching example needs to demonstrate a specific lesson reliably. That is a legitimate thing to do and a dishonest thing to hide, so the chapter says so in the UI:

export const MATRIX_DISCLOSURE =
  "Curated coaching example - the racket is derived from the intended lesson. " +
  "Use The Answer sandbox for forward-simulated experiments.";

There is a test asserting that disclosure still contains the words “curated” and “forward-simulated”. If someone later softens the wording, the build complains.

landing is not winning

A returned ball that lands on the table is not necessarily a good shot. So there is a judgeReturn that grades the ball you handed back:

if ((netClearance !== null && netClearance > 0.25) || apex > 0.5) {
  tier = "smashable";
  reason = `floats ${floatCm} cm over the net at ${speed} km/h - a real opponent ends the point`;
}

Two matrix cells are marked verdict: "punished". They land, and they lose you the point. The tests assert both halves: that those two cells land, and that they are classified as smash food. In the sandbox, one of the offered presets is deliberately a trap, and the test says so out loud:

const wantSmash = preset.label === "Touch push";
report(`ch7 preset "${preset.label}" quality is ${wantSmash ? "smash food (the lesson)" : "not smash food"}`,
  wantSmash ? q.tier === "smashable" : q.tier !== "smashable");

the calibration bug that proved four serves impossible

This is my favourite thing that went wrong.

The serve chapter has a contact clock: a draggable dial on a picture of the ball. 12 o’clock loads topspin, 6 o’clock backspin, 3 and 9 sidespin, the diagonals give the real-world combinations. Drag toward the centre and the spin dies while the swing stays identical, which is exactly what deception is.

I mapped the dial to a racket like this:

faceDeg: -26 * edge * Math.cos(rad),
brushDeg:  62 * edge * Math.cos(rad),
swingSideDeg: 52 * edge * Math.sin(rad)

Elegant. Both face and brush derived from one angle, so the dial can never produce an incoherent racket. Shipped it with eight serve families.

Four of them faulted. Pendulum, ghost, kicker, fast long: into the net, or off the end.

I could not fix that by taste, because picking spin-loading angles by intuition is guessing. So I ran the simulator as a search. First, what does the backspin region of the dial actually reach?

outcomes: { net: 15498, inplay: 408, short: 321, long: 2895, over: 30 }
landed: 408
landed with ANY backspin: 0

Zero. Out of 19,152 combinations of clock angle, contact offset, swing speed, toss height and rubber grip, there was not one landing serve with any backspin at all. The pendulum serve, the most-played ball in the sport, was unreachable at every setting.

The cause was that pretty mapping. It pins the face-to-brush ratio at a constant -26/62 = -0.42. A raw search over bat angles showed landing backspin serves need ratios anywhere from -0.56 to -1.13. My dial was geometrically incapable of them, so backspin serves got a 50 degree downward brush behind a barely-open face, which strips all forward carry. The ball launched at vy = -0.8 m/s, bounced on my own side, and died in the net.

The fix was to stop coupling them, which is also just better physics: where you brush the ball and how you incline the bat are two independent choices, and every coach on earth teaches them separately. The clock now sets the swing path only. Bat angle is its own control.

Then I let a grid search pick the numbers for all eight families, scored against realistic spin and length profiles from the literature:

serveclockbatspeedresult
Pendulum135 degopen 5012half-long, 37 rps back + side
Tomahawk80 degclosed 188half-long, side + light top
Kicker350 degclosed 188long, 46 rps topspin
Ghost170 degopen 4310short, 38 rps back
Float180 degopen 285short, 2 rps

The ghost and the float ended up with nearly the same 6 o’clock action, differing almost entirely in rubber grip, 0.95 versus 0.12. That is exactly what the disguise serve is in real life, and I did not plan it. The search found it.

the advisor that was cheating, and how I stopped it

The serve chapter tells you how to receive each serve. The first version of that was hand-written coaching prose behind regex tests:

report("ch7 middle placement is framed as a decision problem",
  /decision/i.test(advice.movement));

That verifies a string contains a word. It does not verify the advice is correct. Every other claim in the project is simulated and asserted, so this was a different epistemic class sitting in the same UI with no label. Which is exactly the thing MATRIX_DISCLOSURE exists to prevent.

So I rebuilt it. There are now ten candidate rackets - touch, push, chop, block, flick, loop, drive, counter-loop, open-up loop, punch-block - and each is played against the real incoming ball through the same impulse solver, then graded by judgeReturn. The recommendation is whatever wins.

Three things made it honest rather than merely mechanical.

A stroke is a family, not a racket. A real receiver adjusts face yaw and swing speed constantly without changing which stroke they are playing. Holding those fixed failed candidates for reasons that had nothing to do with the stroke choice, so both are searched while face angle and brush direction - the stroke’s actual identity - stay fixed.

Contact timing is a searched variable too. My prose said “take it early”. The model had no way to express that, because I had hardcoded contact at the top of the bounce. Now it tries early on the rise, at the top, and late as it drops, and reports which one won. The advice can finally back up its own instruction.

Aim had to be searched, and that rediscovered chapter 5. With every candidate hitting straight, the sidespin serves returned 0/8 landed - every single stroke squirted off the side of the table. That is precisely the still- racket lesson from chapter 5, arriving unbidden in chapter 7. Once aim was a free variable, the winning return against a breaking serve came back with the face angled into the break, exactly as the earlier chapter teaches.

The output is now concrete instead of assertive: “Touch it short, taken late as it drops, face 30 degrees into the break, 7 km/h swing - 7 of 10 strokes land.” And the named mistake is a stroke that demonstrably failed: “Drive it against this exact ball and it goes into the net - simulated, not asserted.”

Two more corrections fell out of it. First, a serve nothing answers well is a strong serve, and saying so beats promoting the least-bad option: the fast topspin kicker gets 2/10 landing with the best still smash food, so the UI says that outright and admits the model’s own limit (the real answer is taking it closer than the model lets the receiver stand). Second, I had written that a dead ball is “the one ball you should simply attack” - but against a short dead serve a controlled touch genuinely outranks a swing. That advice is now length-aware, because the simulation disagreed with me.

The footwork advice is still prose, because you cannot simulate a decision. So it is labelled “coaching, not simulated” in the UI, and verified is null when no contact state was supplied. The two classes are never blurred.

can you even produce a corkscrew? a geometric answer

The corkscrew chapter injects spinX directly onto the feed rather than deriving it from a racket, which invites the obvious question: could a racket do it? I had dodged that. The answer turns out to be exact.

A plane impact applies its impulse at r = -R * n, so the torque r x J - and therefore the entire spin change - is always perpendicular to the face normal. Asserted to 7.16e-16:

report("ch6 identity: a flat-face impact can only add spin PERPENDICULAR to its normal",
  worstDot < 1e-9);

And the ball leaves mostly along that normal. So the axis is mostly across the path, which is the opposite of corkscrew. Aligning them requires the friction impulse to dominate the normal impulse, and that means a grazing contact that barely drives the ball forward.

Searched over face angle, face yaw, brush direction, lateral sweep and speed, the best a single contact reaches is 24 degrees off the flight path, and at that point vx = 2.2 m/s. The literature calls corkscrew “a slow serve” with “little forward momentum”, rare and needing a high toss. The engine now derives that from first principles instead of quoting it.

two more things the simulator told me I was wrong about

A fast serve cannot have zero spin. I asserted that the fast long serve carries “no brush, no spin, all speed”. I searched for a spinless serve that lands deep and found none, ever. A truly spinless ball cannot clear the net after its own-side bounce. That is why real fast serves carry a trace of topspin, and the literature agrees: 1000 to 2000 rpm. My preset at 16 rps is if anything below the real range. I rewrote the copy.

A lateral brush does not just spin the ball, it pushes it sideways. My pendulum was landing 76 cm off centre, which is on the sideline, with every positive aim value faulting. Obvious in hindsight: the tangential impulse that creates sidespin has to push the ball somewhere. Each serve family now carries a solved face-yaw putting it where that serve is genuinely aimed.

small things I liked

Reverse-integrating the flight model. To show the ball approaching the feeder’s racket, I step the flight backwards:

for (let i = 0; i < steps; i++) {
  stepFlight(rewound, -SIM_DT);   // negative dt
  frames.push(makeFrame(rewound, 0));
}

A negative timestep through the same drag and Magnus integrator. Cheap, and it cannot disagree with the forward model because it is the forward model.

Moving a per-frame loop to the GPU. The ball’s trail faded by walking all 110 points every frame and re-uploading the buffer. Instead, the alpha channel now stores each point’s insertion stamp, and one uniform does the fade in the vertex shader:

vColor.a = pow(0.988, max(0.0, uTrailNow - vColor.a));

Adding a point writes 7 floats and bumps a uniform. addUpdateRange narrows the upload from 440 floats to 4. It matches the old CPU loop to 2.1e-7, which is far under what 8-bit alpha can represent. And because that patch depends on three.js shader chunk names, the smoke test pins them, so a future upgrade that renames color_vertex fails loudly instead of silently dropping the fade.

A three.js r170 trap. BufferGeometry.setFromPoints iterates the old vertex count when a position attribute already exists, so reusing a line with fewer points reads past the end of the array. You have to delete the attribute first:

this.geo.deleteAttribute("position");
this.geo.setFromPoints(points.map(p => new THREE.Vector3(p.x, p.y, p.z)));

Measuring instead of guessing. The story panel and hint bar were anchored to hardcoded offsets above the control dock. Then a chapter added a 104 px dial and the text collided. Now a ResizeObserver publishes the real dock height as a CSS variable and everything anchors to calc(var(--dock-h) + 12px).

what I would tell someone building the same thing

Keep the model layer import-free. Every good property here descends from physics.js having no dependencies. It is what lets the tests be real tests rather than screenshot diffs.

Assert the claims, not the code. Coverage of resolvePlaneImpact would have told me nothing. “The loop that beats backspin sails long against topspin” tells me the course is still true.

You cannot tune physics by vibes. Four of my eight serves were not mis-tuned, they were unreachable, and no amount of nudging numbers would have found that. A 19,152-case search found it in twelve seconds and told me the mapping itself was wrong.

When the simulation contradicts the prose, the prose is usually wrong. It happened three times: corkscrew curving 8 cm, fast serves needing spin, lateral brush aiming the ball. Each time the honest version was more interesting than what I had written.

The source is all in the open, unbundled, no build step: srijanshukla.com/artifacts/spin-lab/. There is a “how it was made” panel in the top bar with this same write-up, and tools/check.mjs is the file I would read first.