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

how I built Spin Lab

/ 5 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, an interactive web app that explains how spin works in table tennis - Magnus effect in flight, how the ball kicks on bounce, why a still racket gets punished, sidespin, corkscrew spin, serves, and return strokes.

The coolest part of building this wasn’t the 3D rendering, it was setting up the lessons as unit tests.

Whenever the text in a chapter makes a claim like “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 matching test in tools/check.mjs that runs the exact same physics code in Node and checks if the sentence is actually true:

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 assertions like this. If I tweak the air drag coefficient and some sentence in Chapter 2 stops being true, the test suite fails and tells me which sentence broke.

Keeping physics.js pure

The directory setup looks like this,

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

One strict rule: physics.js is not allowed to import anything. No Three.js, no browser globals, nothing. Because of that, Node can import the exact same file the browser uses and get bit-identical numbers without messing around with jsdom or headless Chrome.

Precompute the frames upfront

Nothing is simulated live inside requestAnimationFrame. Whenever you change a slider, the app computes the whole shot upfront into an array of frames, and the player just scrubs through that array:

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) };
}

Doing it this way makes slow-mo, scrubbing backwards, and drawing the predicted landing spot super easy, coz the whole trajectory is already sitting in memory before frame 1 even renders.

One collision function for everything

In table tennis the ball hits five different things: your side of the table, the net, the other side, the server’s bat, and the receiver’s bat. If you write separate bounce logic for the table vs the racket, they will end up disagreeing on how spin works.

So everything goes through a single impulse solver,

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);
}

Math.min(tangentialSpeed / effectiveInvMass, friction * normalImpulseMag) handles the grip vs slip transition. Below the friction cap, the rubber grips the ball; above it, the ball slips. Table bounce and racket hit are the exact same math, just with different restitution, friction, and surfaceVelocity numbers.

Where the simulation proved my text wrong

1. Corkscrew spin doesn’t actually fly straight

Magnus force in flight is a cross product between the spin vector and velocity vector:

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

When I added Chapter 6 (corkscrew spin, where the spin axis points forward along x like a rifle bullet), I wrote in the lesson text that a corkscrew ball “flies dead straight” in the air because omega x velocity is zero when the two vectors are parallel, and only kicks sideways after hitting the table.

Then I wrote the test for it:

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

And the test told me the ball actually curves 8.2 cm sideways in the air!

Why? Because a served ping pong ball isn’t flying horizontally - it’s dropping and rising at an angle (around 22 degrees off horizontal). So a horizontal spinX axis isn’t parallel to the velocity vector v, it’s 22 degrees off (acos(|vx| / |v|)). That means sin(22°) ≈ 38% of the sideways Magnus force still survives in flight!

I had to rewrite the lesson text to explain why corkscrew spin still drifts about a third as much as pure sidespin in the air.

2. Four of my eight serves were physically impossible

In the serve chapter, I built a “contact clock” UI where you drag a dot around the face of the ball (12 o’clock for topspin, 6 o’clock for backspin, 3/9 for sidespin). Originally I coupled the bat angle and the brush angle together from that single clock position:

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

Looked clean in code. Except four of my eight serve presets (pendulum, ghost, kicker, fast long) were faulting into the net or flying off the end.

Instead of randomly tweaking numbers by hand, I wrote a script to grid-search 19,152 combinations of clock angle, swing speed, toss height, and rubber grip to find a working backspin serve:

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

Zero! Out of 19,152 combinations, not a single serve with backspin landed on the table.

Why? Because coupling faceDeg and brushDeg fixed their ratio at -26/62 = -0.42. Whenever you brushed down at 6 o’clock to get backspin, the bat face stayed too vertical instead of opening up underneath the ball, so the ball launched downward at vy = -0.8 m/s and died in the net.

Decoupled the bat angle from the brush clock (which makes sense anyway - in real table tennis, how much you open your bat and which direction you brush are two separate things), re-ran the search, and all eight serves worked:

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

Funny side effect from the grid search: the Ghost serve and the Float serve ended up with almost identical swing mechanics, differing mostly in rubber grip (0.95 vs 0.12). Which is literally how players disguise a no-spin float serve in real life.

3. Fast serves cannot have zero spin

I originally wrote that a fast long serve has “no brush, no spin, all speed”. Tried to find a zero-spin fast serve in the simulator that clears the net after bouncing on the server’s side AND still lands before the far baseline. Doesn’t exist. Gravity alone doesn’t curve the arc down fast enough at high speed. You have to put ~15-30 rps (1000-2000 rpm) of topspin on a fast long serve just to pull it down onto the other side of the table. Rewrote that copy too.

4. Replacing hardcoded return advice with simulated returns

Initially my serve-return advisor was just hardcoded coaching strings with dumb regex tests (/decision/i.test(advice.movement)).

Ended up replacing that by actually simulating 10 return stroke types (touch, push, chop, block, flick, loop, drive, etc.) across different contact timings (early on the rise, peak of bounce, late as it drops) and face yaw angles against the incoming serve, then grading the return with judgeReturn:

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`;
}

Because landing the ball on the table isn’t enough if you pop it 30 cm high over the net and get smashed.

Forward vs inverse simulation

One thing I made sure to label clearly in the UI is the difference between two modes in the app:

  • Forward simulation (used in the sandbox): you set bat angle + swing, physics decides where the ball goes. Usually into the net until you get the hang of it.
  • Inverse simulation (used in the 18-cell coaching matrix): I pick where the ball should land for the demonstration, bisection-solve the velocity needed, and work backwards to show the bat angle.

Added a disclosure banner (MATRIX_DISCLOSURE) on the matrix chapter so nobody thinks those 18 presets were tuned by hand, and put a test on the disclosure string too.

Other small implementation notes

  • Rewinding flight: To animate the ball coming into the feeder’s racket before contact, I just run stepFlight(rewound, -SIM_DT) with a negative timestep for a few frames.
  • GPU trail fade: Instead of re-uploading all 110 trail vertices from CPU every frame just to fade alpha, the alpha attribute stores the birth timestamp of each point and the vertex shader computes vColor.a = pow(0.988, max(0.0, uTrailNow - vColor.a)).
  • Three.js r170 gotcha: BufferGeometry.setFromPoints reuses the old vertex count if position already exists on the geometry, so if your new trajectory has fewer points it reads past the end of the array. You gotta call this.geo.deleteAttribute("position") before setFromPoints.

If you wanna play around with it, the offline bundle is up on Gumroad.