Hopper dynamics
The bounce solver behind both hoppers: an exact parabola in the air, an exact spring-mass stance on the ground, and the split between them derived rather than dialled.
view
variant
drive
stiffness40
height50%
Drag down inside the frame to load the spring, then let go. Soften the spring under a tall hop and it bottoms out on its own coils.
- contact
- 0.55s
- flight
- 2.00s
- duty
- 21%
Neither of those times is a control: both fall out of the drop height and the spring rate.
Theming
Set a role and the same CSS goes in your own app — every robot under it follows.
Install
bunx --bun shadcn@latest add https://robocn.dev/r/hopper-dynamics.jsonNotes
- Units are mass 1 and gravity 1, so a hop unit is whatever the drawing decides one is, and a load of 1 is the machine's own weight. Static sag is 1 / stiffness.
- Contact ends when the spring force returns to zero, which is not half a period — gravity biases the oscillation, and the closed form is (2/ω)(π − atan(vω/g)). That is the number a duty-factor knob would have got wrong.
- No damping inside the stance, no horizontal travel, no friction, no spin-up from contact, no material and no actuator. The loss is taken at take-off as a restitution coefficient, which is how a bounce is actually measured.
Usage
import { solveHop, hopTimings, solveDrop, springCoils, squashRadii } from "@/lib/robocn/hopper"
const timings = hopTimings({ height: 0.5, stiffness: 40 })
timings.duty // fraction of the cycle spent touching the ground
const state = solveHop({ phase: 0.3, stiffness: 40 })
state.altitude // negative while the compliance is loaded
solveDrop({ time: 4, height: 1, restitution: 0.6 }).resting
springCoils({ length: 40, turns: 6, radius: 7, wire: 1.4 }).bottomedOut
squashRadii(30, 0.25) // { rx, ry }, at constant volumeAPI
| Prop | Type | Default | Description |
|---|---|---|---|
| solveHop | (options?: HopOptions) => HopState | — | The steady bounce as a function of the cycle fraction: touchdown at phase 0, stance, take-off, flight. Wraps in both directions. |
| HopState | { altitude, compression, squeeze, contact, velocity, load, bounce, resting } | — | Altitude is height above the machine's free-standing rest and goes negative while the compliance is loaded; load is in machine weights, so 1 is standing still. |
| hopTimings | (options?: HopOptions) => HopTimings | — | Contact, flight, cycle, duty, depth, take-off speed, static sag and natural frequency — every one a consequence of the drop height and the spring rate. |
| solveDrop | (options?: DropOptions) => HopState | — | A machine released from height at time 0 and left alone: apexes decay by the square of the restitution until the rebound cannot lift it clear, after which it sits at its static sag. |
| dropTimings | (options?: DropOptions) => { bounces, settleTime, apexAt } | — | How many contacts the sequence makes, when it is over, and when each apex falls — enough to loop a settling behaviour on. |
| springCoils | (options?: SpringOptions) => SpringCoils | — | A helix seen side-on, which is a sinusoid. Coil count and radius are invariant, and the length is clamped at the solid height and reported as bottomedOut. |
| squashRadii | (radius: number, squeeze: number) => { rx, ry } | — | An oblate spheroid at constant volume: rx² ry = r³. |
Source
src/lib/robocn/hopper.ts
/**
* Bounce dynamics: the two regimes a machine that leaves the ground has, and
* the boundary between them.
*
* Flight is a projectile — a parabola, exactly. Stance is a mass on a linear
* spring, which is simple harmonic motion about its static sag — exactly. What
* makes this worth solving rather than tweening is that *how long each lasts*
* is not a control: given a drop height and a spring rate, the flight time, the
* contact time and therefore the duty factor all fall out of the physics.
* Stiffen the spring and contact gets shorter and harder without the hop
* changing.
*
* Units: mass 1, gravity 1. Lengths are "hop units" — whatever the drawing
* decides one is — and a `load` of 1 is the machine's own weight.
*
* Not modelled: damping inside the stance (the loss is taken at take-off as a
* restitution coefficient, the way a bounce is actually measured), horizontal
* travel, friction, spin-up from contact, material, or any energy source that
* would keep a real hopper going.
*
* Design note: docs/bouncing-machines.md
*/
import type { Vec2 } from "@/lib/robocn/kinematics"
const finite = (value: number | undefined, fallback: number) =>
typeof value === "number" && Number.isFinite(value) ? value : fallback
const clamp = (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, value))
const wrap = (value: number) =>
Number.isFinite(value) ? ((value % 1) + 1) % 1 : 0
/** Apex height in hop units. Zero is a machine that only ever bobs on its spring. */
const HEIGHT = { min: 0, max: 1, fallback: 0.5 }
/** Spring rate, in weights per hop unit. Static sag is its reciprocal. */
const STIFFNESS = { min: 4, max: 400, fallback: 40 }
/** Below this apex a rebound cannot lift the machine, and the sequence is over. */
const REST_HEIGHT = 0.004
export interface HopOptions {
/** Cycle fraction from touchdown. Wraps in both directions. */
phase?: number
/** Apex of the hop above the machine's free-standing rest, 0–1 hop units. */
height?: number
/** Spring rate in weights per hop unit, 4–400. Static sag is `1 / stiffness`. */
stiffness?: number
}
export interface DropOptions extends Omit<HopOptions, "phase"> {
/** Seconds since release, from the apex with no upward throw. */
time?: number
/** Fraction of the landing speed returned at take-off, 0–1. */
restitution?: number
/** Apex below which a rebound counts as none, ending the sequence. */
restHeight?: number
}
export interface HopState {
/**
* Height of the machine's reference point above where it stands at rest with
* the compliance free, in hop units. Negative while the compliance is loaded:
* the body sinks, the foot does not move.
*/
altitude: number
/** How far the compliance is compressed, in hop units. Zero in flight. */
compression: number
/** The same, as 0–1 of the hardest impact of this sequence — for drawing. */
squeeze: number
/** Touching the ground. */
contact: boolean
/** Vertical velocity in hop units per second, up positive. */
velocity: number
/** Ground reaction force in machine weights: 0 in flight, 1 standing still. */
load: number
/** Which contact of a decaying sequence this is. Always 0 for a steady hop. */
bounce: number
/** A decaying sequence has run down and the machine is sat on its spring. */
resting: boolean
}
export interface HopTimings {
/** Seconds on the ground. */
contact: number
/** Seconds in the air. */
flight: number
/** One whole bounce. */
cycle: number
/** Fraction of the cycle spent touching the ground. */
duty: number
/** Deepest compression reached, in hop units. */
depth: number
/** Speed at take-off and at touchdown, in hop units per second. */
takeoff: number
/** Static sag: how far the compliance gives under the machine standing still. */
sag: number
/** Natural frequency of the compliance under the machine, in radians a second. */
omega: number
}
const resolve = (options: HopOptions) => {
const stiffness = clamp(
Math.abs(finite(options.stiffness, STIFFNESS.fallback)),
STIFFNESS.min,
STIFFNESS.max,
)
return {
stiffness,
omega: Math.sqrt(stiffness),
sag: 1 / stiffness,
height: clamp(finite(options.height, HEIGHT.fallback), HEIGHT.min, HEIGHT.max),
}
}
/**
* How long a contact at this touchdown speed lasts.
*
* Contact ends when the spring force returns to zero — the compliance back at
* its free length — which is *not* half a period, because gravity biases the
* oscillation. Solving `x0(1 − cos ωt) + (v/ω) sin ωt = 0` for the second root
* gives this closed form, which tends to a full half period plus the sag's own
* quarter as the landing speed goes to zero.
*/
const contactTime = (speed: number, omega: number) =>
(2 / omega) * (Math.PI - Math.atan(speed * omega))
/** Deepest the compliance goes on a contact at this speed. */
const contactDepth = (speed: number, omega: number, sag: number) =>
sag + Math.hypot(sag, speed / omega)
/** The state during one contact, `elapsed` seconds after touchdown at `speed`. */
function stanceState(
elapsed: number,
speed: number,
omega: number,
sag: number,
stiffness: number,
reference: number,
bounce: number,
): HopState {
const angle = omega * elapsed
// Downward displacement of the body from the compliance's free length.
const sink = sag * (1 - Math.cos(angle)) + (speed / omega) * Math.sin(angle)
const compression = Math.max(0, sink)
return {
altitude: -compression,
compression,
squeeze: reference > 0 ? clamp(compression / reference, 0, 1) : 0,
contact: true,
velocity: -(sag * omega * Math.sin(angle) + speed * Math.cos(angle)),
load: stiffness * compression,
bounce,
resting: false,
}
}
/** The state during one flight, `elapsed` seconds after take-off at `speed`. */
function flightState(elapsed: number, speed: number, bounce: number): HopState {
return {
altitude: Math.max(0, speed * elapsed - (elapsed * elapsed) / 2),
compression: 0,
squeeze: 0,
contact: false,
velocity: speed - elapsed,
load: 0,
bounce,
resting: false,
}
}
/** Sat on the spring with nothing left: the end of a decaying sequence. */
function restingState(sag: number, stiffness: number, bounce: number): HopState {
return {
altitude: -sag,
compression: sag,
squeeze: 0,
contact: true,
velocity: 0,
load: stiffness * sag,
bounce,
resting: true,
}
}
/** Timings of one steady bounce. Every number here is a consequence, not a control. */
export function hopTimings(options: HopOptions = {}): HopTimings {
const { omega, sag, height, stiffness } = resolve(options)
const takeoff = Math.sqrt(2 * height)
const contact = contactTime(takeoff, omega)
const flight = 2 * takeoff
const cycle = contact + flight
return {
contact,
flight,
cycle,
duty: cycle > 0 ? contact / cycle : 1,
depth: contactDepth(takeoff, omega, sag),
takeoff,
sag,
omega: Math.sqrt(stiffness),
}
}
/**
* The steady bounce: touchdown at phase 0, stance, take-off, flight, round
* again. This is the ideal lossless case — a real machine would have to put
* back what the contact took, and nothing here draws that.
*/
export function solveHop(options: HopOptions = {}): HopState {
const { omega, sag, height, stiffness } = resolve(options)
const takeoff = Math.sqrt(2 * height)
const contact = contactTime(takeoff, omega)
const flight = 2 * takeoff
const cycle = contact + flight
const depth = contactDepth(takeoff, omega, sag)
const time = wrap(options.phase ?? 0) * cycle
return time <= contact
? stanceState(time, takeoff, omega, sag, stiffness, depth, 0)
: flightState(time - contact, takeoff, 0)
}
export interface DropTimings {
/** Contacts the machine makes before it has nothing left to rebound with. */
bounces: number
/** Seconds from release until it is sat on its spring for good. */
settleTime: number
/** When the apex of flight `index` happens. Flight 0 is the release itself. */
apexAt(index: number): number
}
/** Hard stop on the walk, so a pathological restitution cannot spin. */
const MAX_BOUNCES = 200
/** Walks the sequence, calling back once per contact, until it runs down. */
function sequence(
options: DropOptions,
visit: (bounce: {
index: number
/** When this contact starts, and its touchdown speed. */
start: number
speed: number
duration: number
/** Take-off speed and flight length. Zero once it cannot rebound. */
rebound: number
flight: number
}) => boolean | void,
) {
const { omega, height } = resolve(options)
const restitution = clamp(finite(options.restitution, 1), 0, 1)
const restHeight = Math.max(0, finite(options.restHeight, REST_HEIGHT))
const minimum = Math.sqrt(2 * restHeight)
let speed = Math.sqrt(2 * height)
// Released at the apex with no throw, so the first flight is the fall alone.
let clock = speed
for (let index = 0; index < MAX_BOUNCES; index += 1) {
const duration = contactTime(speed, omega)
const rebound = restitution * speed < minimum ? 0 : restitution * speed
const stop = visit({ index, start: clock, speed, duration, rebound, flight: 2 * rebound })
if (stop || rebound === 0) return { bounces: index + 1, settleTime: clock }
clock += duration + 2 * rebound
speed = rebound
}
return { bounces: MAX_BOUNCES, settleTime: clock }
}
/** How many bounces a drop gets, how long it takes to die, and where the apexes fall. */
export function dropTimings(options: DropOptions = {}): DropTimings {
const apexes: number[] = [0]
const run = sequence(options, ({ start, duration, rebound }) => {
if (rebound > 0) apexes.push(start + duration + rebound)
})
return {
...run,
apexAt: (index: number) => apexes[clamp(Math.round(index), 0, apexes.length - 1)] ?? 0,
}
}
/**
* A machine released from `height` at `time` 0 and left alone: it falls,
* bounces lower every time by the square of the restitution, and finally sits
* down on its own spring. Past the settle time it stays there.
*
* Contact time does not go to zero as the speed does — it tends to `2π/ω` — so
* an ideal Zeno bounce would never finish. The sequence ends instead when the
* rebound can no longer lift the machine past `restHeight`, which is the honest
* version of coming to rest.
*/
export function solveDrop(options: DropOptions = {}): HopState {
const { omega, sag, height, stiffness } = resolve(options)
const time = Math.max(0, finite(options.time, 0))
const first = Math.sqrt(2 * height)
const reference = contactDepth(first, omega, sag)
// The fall from the release apex, before anything has been touched.
if (time < first) {
return {
...flightState(time, 0, 0),
altitude: Math.max(0, height - (time * time) / 2),
velocity: -time,
}
}
let state: HopState | null = null
const run = sequence(options, ({ index, start, speed, duration, rebound, flight }) => {
if (time < start + duration) {
state = stanceState(time - start, speed, omega, sag, stiffness, reference, index)
return true
}
if (rebound > 0 && time < start + duration + flight) {
state = flightState(time - start - duration, rebound, index + 1)
return true
}
return false
})
return state ?? restingState(sag, stiffness, run.bounces)
}
export interface SpringOptions {
/** Free length along the axis, in drawing units. Clamped at the solid height. */
length?: number
/** Active coils. Never changes with compression — a spring twists its wire. */
turns?: number
/** Coil radius. Never changes either. */
radius?: number
/** Wire diameter, which is what sets the solid height. */
wire?: number
}
export interface SpringCoils {
/** The helix seen side-on, which is a sinusoid: x across the axis, y along it. */
points: Vec2[]
/** Length actually drawn, after the solid-height clamp. */
length: number
/** Distance between coils. */
pitch: number
/** Shortest the spring can be: every coil touching its neighbour. */
solid: number
/** The requested length was past that, and has been clamped. */
bottomedOut: boolean
}
/** Samples per coil. A multiple of four, so the extremes are sampled exactly. */
const COIL_STEPS = 16
/**
* A helical spring seen side-on. The projection of a helix is a sinusoid, so it
* is sampled rather than drawn as a zig-zag, and the coil count and radius are
* invariant: a real spring compresses by twisting its wire, not by losing or
* fattening coils. It cannot go past its own solid height.
*/
export function springCoils(options: SpringOptions = {}): SpringCoils {
const turns = clamp(Math.abs(finite(options.turns, 6)), 1, 40)
const radius = Math.max(0.1, Math.abs(finite(options.radius, 6)))
const wire = Math.max(0.05, Math.abs(finite(options.wire, 1)))
const solid = turns * wire
const requested = Math.max(0, finite(options.length, solid))
const length = Math.max(solid, requested)
const steps = Math.max(8, Math.round(turns * COIL_STEPS))
const points = Array.from({ length: steps + 1 }, (_, index) => {
const s = index / steps
return { x: radius * Math.sin(2 * Math.PI * turns * s), y: length * s }
})
return { points, length, pitch: length / turns, solid, bottomedOut: requested < solid }
}
/**
* A shell flattened against the ground, at constant volume: an oblate spheroid
* with `rx² ry = r³`. A ball squashed on impact has to get wider, and by
* exactly this much.
*/
export function squashRadii(radius: number, squeeze: number): { rx: number; ry: number } {
const r = Math.max(0.01, Math.abs(finite(radius, 1)))
// Past this it stops being a ball and starts being a puddle.
const depth = clamp(finite(squeeze, 0), 0, 0.85)
if (depth === 0) return { rx: r, ry: r }
const ry = r * (1 - depth)
return { rx: r * Math.sqrt(r / ry), ry }
}