Push the top north and it goes west. Not because the controls are inverted as a gimmick — because that is what a gyroscope does with a torque, and once you accept it the whole control scheme reorganises itself around a right angle that is never anywhere near where you want it to be.
The one line it all comes from
A heavy symmetric top on a fixed tip, linearised about upright, is a single
complex second-order ODE. Write the tilt of the spin axis as a complex number
ζ = θx + i·θy — the horizontal shadow of the axis, so |ζ| ≈ sinθ:
I⊥·ζ̈ = i·Is·S·ζ̇ + M·g·h·ζ + h·F·p
^gyroscopic ^gravity ^your finger
That is the entire model. Everything the demo does is a consequence, and the nice part is how much comes out of it that nobody had to author:
- Its slow root is precession,
ω_p = Mgh/(Is·S). The lean vector walks around a circle at that rate on its own, forever, without changing length. Which is why an untouched top does not stand still and does not run straight — it drives in a circle, and the circle isv/ω_pwide. - Its fast root is nutation,
ω_n = Is·S/I⊥— 37 Hz at launch, the shimmer you see for a fraction of a second after you knock a real top. - Both roots are real only while
Is²S² ≥ 4·I⊥·M·g·h. Below that they are a conjugate pair, one of them grows, and the top falls over. Nobody wrote that rule. It is the discriminant. For this top it lands at 638 rpm.
scripts/measure.mjs integrates the thing and checks it against those closed
forms, because a physics claim you haven’t measured is a physics hope:
| spin (rpm) | precession, measured | slow root | period | ω_n (Hz) | upright stable? |
|---|---|---|---|---|---|
| 4011 | 1.504 | 1.504 | 4.18 s | 37.6 | yes |
| 2865 | 2.119 | 2.119 | 2.96 s | 26.8 | yes |
| 1146 | 5.717 | 5.717 | 1.10 s | 10.7 | yes |
| 764 | 10.137 | 10.132 | 0.62 s | 7.2 | yes |
| 638 | 18.787 | 18.781 | 0.33 s | 6.0 | yes |
| 573 | — | — | — | 5.4 | no — e-folds in 0.12 s |
| 477 | — | — | — | 4.5 | no — e-folds in 0.08 s |
Why push north means go west
dL/dt = τ, and L is large and pointing up. A finger pushing the head in
direction p applies torque h·F·(ẑ × p), and after the algebra the push
enters the equation as itself — but ζ̇ picks up the factor i. The lean
moves 90° round from wherever you pushed, and the top goes where it leans.
The consequence takes a minute to sink in, and it is the actual control scheme:
- Push at where you’re already going → the lean turns. That is your steering.
- Push 90° off your travel → the lean grows or shrinks. That is your throttle and your brake, and which one you get depends on the side.
There is no button that does the obvious thing. The demo draws all three arrows — your push, where that sends the lean, and where the top is actually travelling — because the right angle is between the first two, and the third is the lean you already banked, which is why it sits at no particular angle to either. Getting that wrong is the single most common way to misdescribe this, and I misdrew it once before the screenshots caught me.
The number the whole thing is really about
Quasi-statically, your finger turns the lean at h·F/(Is·S·|ζ|) and gravity
turns it at Mgh/(Is·S). Take the ratio:
F / (M·g·|ζ|)
The spin cancels. Both moments of inertia cancel. There is exactly one lean angle where your finger and gravity steer with equal authority —
sinθ* = F/(Mg) → 8.20°
— and it does not move for the entire run, no matter how much spin is left. Below it you are driving. Above it gravity is, and you are a passenger holding a stick that no longer wins arguments. Since travel speed is lean, going fast means handing over the wheel. That is the whole game.
| lean | angle | speed | finger (rad/s) | gravity (rad/s) | ratio |
|---|---|---|---|---|---|
| 0.0500 | 2.87° | 6.0 cm/s | 5.973 | 2.093 | 2.854 |
| 0.1000 | 5.74° | 12.0 cm/s | 2.987 | 2.093 | 1.427 |
| 0.1427 | 8.20° | 17.1 cm/s | 2.093 | 2.093 | 1.000 |
| 0.2000 | 11.54° | 24.0 cm/s | 1.493 | 2.093 | 0.714 |
| 0.3000 | 17.46° | 36.0 cm/s | 0.996 | 2.093 | 0.476 |
And it shows up in the score
The game layer is thin on purpose: thread rings with the tip before the top
dies. Nothing in it knows about θ*. So I wrote an autopilot that knows one
rule — push 90° back the other way from wherever you want the lean to go,
which is pushFor() in src/, four lines — pointed it at a target lean, and
ran 24 runs per row.
| target lean | rings threaded | run length | rail hits |
|---|---|---|---|
| 0.0500 (2.9°) | 5.5 ± 0.9 | 46.9 s | 0.8 |
| 0.1000 (5.7°) | 11.3 ± 1.1 | 46.4 s | 0.5 |
| 0.1427 (8.2°) | 16.7 ± 1.2 | 45.6 s | 1.0 |
| 0.2000 (11.5°) | 15.0 ± 1.6 | 43.7 s | 2.5 |
| 0.3000 (17.5°) | 10.9 ± 2.2 | 37.7 s | 8.0 |
The optimum is at F/(Mg), on the nose. Below it the top is too slow to reach
rings before the spin clock runs down; above it gravity out-turns the finger,
the trajectories go wide and sloppy, and the rail starts collecting hits. I
didn’t place that peak — it is two constants and a division.
Where runs actually end, which is not where I expected
The critical spin is 638 rpm and almost nobody ever sees it. The finger’s
authority goes as 1/S for precisely the same reason precession does, so a top
that is nearly out of spin answers a nudge about five times as hard as a fresh
one — and every input you spent forty seconds learning is suddenly too big. The
endgame is a knife edge, and it is a knife edge made of the same constant that
made the midgame comfortable.
Same autopilot, easing off the stick (gain is the error at which it goes to
full push, so bigger means gentler):
| gain | rings threaded | run length | fell to spin loss | spin when it fell |
|---|---|---|---|---|
| 0.045 | 16.7 | 45.6 s | 0/24 | 835 rpm |
| 0.120 | 10.7 | 44.8 s | 0/24 | 822 rpm |
| 0.250 | 5.5 | 42.0 s | 0/24 | 804 rpm |
| 0.500 | 2.4 | 43.6 s | 9/24 | 694 rpm |
Only a pilot barely touching the thing coasts down to the real floor, having threaded almost nothing. Take your hands off entirely and it dies exactly where the discriminant says: critical at 57.9 s, rim on the felt at 59.0 s. 1.1 seconds of warning, all of it after the point of no return.
What is physics here and what isn’t
Worth being straight about, since the piece leans on the physics being real:
- Derived: both roots, the critical spin, the 90° steering response, θ*, and the fall angle (35.8° — where the rim geometrically reaches the felt, not a threshold someone picked).
- A modelling choice: the friction. A plain lab-frame damper
−c·ζ̇does the obvious thing to nutation and the wrong thing to precession — perturbing the slow root givesω_p − i·c·ω_p/(Is S), whose imaginary part has the growing sign, so the lean creeps outward until the top falls. That is a real phenomenon (internal friction destabilises a spinning body, which is why spacecraft are spun about their major axis) but it is not what a top on a table does. So the damper acts on the nutation residual,ζ̇ − i·ω_slow·ζ, and the rising-top effect gets its own explicit term. The first version of this file had the naive damper and every top fell over sideways at 50 s; the measure script is how I found out. - A game, not a claim: how a leaning top travels. Real tip-friction drift
is a paper, not a line, so it takes the shortcut every top does obey — it
goes where it leans, at a speed proportional to the lean. Everything above
the
driftconstant is physics; that constant is a design decision. - A table, not physics: the rail, which kills the outward lean, rings the top with a nutation impulse and takes 4% of the spin.
Reuse
src/precession.mjs is framework-free and has no canvas in it. createTop()
gives you step(dt, push), tilt, spin, pos, alive, and readouts
(leanDeg, precession, circleRadius, authority — that last one is
|ζ|/sinθ*, so above 1 means gravity has the wheel). railBounce() is the
cushion, pushFor()/aimAt() are the autopilot. Integration is RK4 substepped
at 1 kHz, because nutation runs at 37 Hz at launch and a 60 Hz frame step
aliases it into nonsense.
The demo is demo/index.html with its own copy of the module (ADR-0002),
keyboard and drag both, and a window.__demo hook the screenshot rig drives.
node scripts/measure.mjs prints every table above; node scripts/screenshot-demo.mjs regenerates the thumb and media and doubles as the
smoke test — it presses ArrowRight in a real browser and asserts the lean ends
up 90° round from east, give or take the precession it walked during the press.




