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:
| serve | clock | bat | speed | result |
|---|---|---|---|---|
| Pendulum | 135 deg | open 50 | 12 | half-long, 37 rps back + side |
| Tomahawk | 80 deg | closed 18 | 8 | half-long, side + light top |
| Kicker | 350 deg | closed 18 | 8 | long, 46 rps topspin |
| Ghost | 170 deg | open 43 | 10 | short, 38 rps back |
| Float | 180 deg | open 28 | 5 | short, 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.setFromPointsreuses the old vertex count ifpositionalready exists on the geometry, so if your new trajectory has fewer points it reads past the end of the array. You gotta callthis.geo.deleteAttribute("position")beforesetFromPoints.
If you wanna play around with it, the offline bundle is up on Gumroad.