The Anatomy of Weight — Arcade Movement and Jumping
Since I left out a physics engine, I wrote movement and jumping directly as pure functions.
The core idea is to represent the player's position in polar coordinates of the tunnel cross-section.
The angle (where you are around the tunnel's circumference) and the height (the distance lifted off the wall surface toward the center) — those two were all I needed.

For left-right movement, the game feel comes from it not being instantaneous.
The old version snapped the angle the moment you pressed a key.
This time a key press applies angular acceleration, and when you release the key the angular velocity bleeds off through damping.
So even after you let go, the rabbit doesn't stop dead — it slides.
That "extra beat where it won't stop" is exactly what weight is.
The jump is built with artificial gravity.
When you jump, you get an upward velocity, and every frame gravity shaves that velocity down.
It rises, naturally hits an apex, and falls.
let { height, radialVel, grounded } = p;
if (input.jump && grounded) { radialVel = PLAYER.JUMP_VELOCITY; grounded = false; }
if (!grounded) {
height += radialVel * dt;
radialVel -= PLAYER.GRAVITY * dt;
if (height <= 0) { height = 0; radialVel = 0; grounded = true; }
}
I set the values to JUMP_VELOCITY = 9 and GRAVITY = 24.
That puts the natural apex at v²/2g ≈ 1.69m.
I added an upper clamp (2.4m) to guard against any runaway just in case, but normally it never reaches it.
There's a reason I wrote it as pure functions.
Since it's not tied to Three.js, I can verify the jump trajectory directly with Jest.
I feed in the input and dt and unit-test whether the height rises and returns to 0 — catching game feel with tests instead of with my eyes.
Next time: the collision story of judging impacts on top of these coordinates.