sugarcraft / sugar-spring
PHP port of charmbracelet/harmonica — damped spring physics + Newtonian projectile simulation for terminal animation.
Requires
- php: ^8.3
- sugarcraft/sugar-colorprofile: dev-master
- sugarcraft/sugar-core: dev-master
Requires (Dev)
- phpunit/phpunit: ^10.5
- sugarcraft/sugar-testing: dev-master
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-23 18:35:31 UTC
README
SugarSpring
Formerly published as
sugarcraft/honey-bounce(renamed 2026-10). Namespace changed:SugarCraft\Bounce\→SugarCraft\Spring\.
sugar-spring — damped-spring physics and Newtonian projectile simulation for animation, for PHP 8.3+. Pure math; no terminal dependency.
composer require sugarcraft/sugar-spring
Spring
Damped harmonic oscillator (Ryan-Juckett's algorithm). Choose dampingRatio:
< 1 oscillates, = 1 is critical (no overshoot, fastest convergence), > 1 is
over-damped.
use SugarCraft\Spring\Spring; $spring = new Spring( deltaTime: Spring::fps(60), // 1/60 of a second angularFrequency: 6.0, // rad/sec dampingRatio: 1.0, // critical ); $pos = 0.0; $vel = 0.0; $target = 100.0; for ($frame = 0; $frame < 60; $frame++) { [$pos, $vel] = $spring->update($pos, $vel, $target); echo sprintf("frame %2d pos=%.2f vel=%.2f\n", $frame, $pos, $vel); }
Spring::fps(float $n) returns 1.0 / $n for the deltaTime — pair with the
same $n per-second simulation cadence.
Driving a frame loop
A spring bakes its deltaTime in at construction, so the simulation only
matches wall-clock when you actually call update() at that rate. Two
honest patterns:
-
Fixed cadence (terminals, replays). Build one spring with
Spring::fps(60)and tick it once per 60 Hz frame — with sugar-core programs, once perTickyourProgram::subscriptions()emits at1/60of a second:use SugarCraft\Spring\Spring; $spring = new Spring(Spring::fps(60), 6.0, 1.0); $pos = 0.0; $vel = 0.0; foreach ([10.0, 60.0, 60.0] as $target) { // one frame per tick [$pos, $vel] = $spring->update($pos, $vel, $target); // render $pos this frame }
If the loop drifts slower than 60 Hz the motion slows with it. That is a feature for deterministic replays; for wall-clock motion measure the real frame time instead:
use SugarCraft\Spring\Spring; $previous = hrtime(true); $pos = 0.0; $vel = 0.0; $target = 100.0; foreach ([10.0, 60.0, 100.0] as $target) { // stand-in for a real frame loop $now = hrtime(true); $deltaTime = min(($now - $previous) / 1e9, 0.1); // clamp long stalls $previous = $now; $spring = new Spring($deltaTime, 6.0, 1.0); // coeffs are cheap [$pos, $vel] = $spring->update($pos, $vel, $target); // render $pos this frame }
Reduced motion
Spring::update() snaps to $target instantly — returning
[<target>, 0.0] — when reduced motion is requested. The signal is
environment-only: REDUCE_MOTION or PREFERS_REDUCED_MOTION set to a
truthy value (the lookup is
SugarCraft\ColorProfile\Probe::reducedMotion(), which reads exactly
those two env vars; there is no terminal query or accessibility-daemon
channel). This satisfies the WCAG 2.1 reduced-motion
guideline
when the host sets one of the variables.
Two more knobs: the constructor's fourth parameter
?bool $reducedMotionOverride pins the behaviour for one spring —
pass false to animate even when the env var is set, true to snap even
when it is not (null, the default, defers to the environment). And the
scope is Spring::update() only: Projectile::update(), the Easing
curves and SpringChain advance regardless — gate those yourself if your
scene uses them.
use SugarCraft\Spring\Spring; // With REDUCE_MOTION=1 the spring skips animation entirely: putenv('REDUCE_MOTION=1'); $spring = new Spring(deltaTime: Spring::fps(60), angularFrequency: 6.0, dampingRatio: 1.0); [$pos, $vel] = $spring->update(0.0, 0.0, 100.0); // returns [100.0, 0.0] // Pin one spring to ignore the environment (e.g. a required progress cue): $alwaysAnimates = new Spring( deltaTime: Spring::fps(60), angularFrequency: 6.0, dampingRatio: 1.0, reducedMotionOverride: false, );
Spring presets
Spring::fromPreset(SpringPreset $preset, ?float $deltaTime = null) constructs
a spring from a named preset at 60 fps (override the frame time as needed).
Five presets are available, translated from UIKit's canonical values:
| Preset | Feel | Tension | Friction | Mass |
|---|---|---|---|---|
Gentle |
soft, slow overshoot | 100 | 10 | 1 |
Wobbly |
bouncy oscillation | 180 | 12 | 1 |
Stiff |
snappy snap | 500 | 20 | 1 |
Slow |
heavy, lazy settle | 50 | 6 | 1 |
Molasses |
barely moves | 30 | 4 | 1 |
use SugarCraft\Spring\{Spring, SpringPreset}; $spring = Spring::fromPreset(SpringPreset::Wobbly); // With custom frame rate $spring60 = Spring::fromPreset(SpringPreset::Stiff, 1.0 / 60.0); $spring30 = Spring::fromPreset(SpringPreset::Gentle, 1.0 / 30.0);
SpringConfig
SpringConfig accepts physical parameters (tension / friction / mass) and
derives the angularFrequency and dampingRatio consumed by Spring:
angularFrequency = sqrt(tension / mass)
dampingRatio = friction / (2 * sqrt(tension * mass))
use SugarCraft\Spring\{SpringConfig, Spring}; $config = new SpringConfig(tension: 180.0, friction: 12.0, mass: 1.0); $spring = $config->springAt60Fps(); // or ->spring($deltaTime)
Both SpringConfig::spring() and SpringConfig::springAt60Fps() return
a pre-wired Spring instance ready to drive update() calls.
Projectile
Newtonian-physics simulator for arcs / bouncing balls / particle effects.
use SugarCraft\Spring\{Point, Projectile, Spring, Vector}; $p = Projectile::new( deltaTime: Spring::fps(60), position: Point::zero(), velocity: new Vector(5.0, 10.0), // up-and-to-the-right (Y-up) acceleration: Projectile::gravity(), // (0, -9.81) — pulls down ); for ($i = 0; $i < 60; $i++) { $p = $p->update(); echo sprintf("t=%2d pos=(%.1f, %.1f)\n", $i, $p->position->x, $p->position->y); }
Gravity constants: Projectile::GRAVITY (9.81) and
Projectile::TERMINAL_GRAVITY (53.0). Helper factories
Projectile::gravity() and Projectile::terminalGravity() return Y-up
Vector instances ((0, -9.81, 0) / (0, -53, 0)) ready to drop into
the constructor; gravityYDown() / terminalGravityYDown() keep the
legacy Y-down sign reachable.
SugarCraft\Spring\Gravity exposes the same vectors as static
accessors at the package level — Gravity::standard(),
Gravity::terminal(), Gravity::standardYDown(),
Gravity::terminalYDown() — so gravity presets read
uniformly across call sites.
Damping-ratio regimes
The dampingRatio argument to Spring picks one of three classical
behaviours:
- Under-damped (
ζ < 1) — oscillates around the target, amplitudes decaying each cycle. Picks for "bouncy" feel. - Critically-damped (
ζ = 1) — fastest convergence with no overshoot. The default for "snap to value" animations. - Over-damped (
ζ > 1) — converges without overshoot but slower than critical. Picks for slow, weighty motion.
Negative damping ratios are clamped to 0 (a pure oscillator with
no decay would never settle).
Coordinate systems
Both Vector and Point are 3D (x, y, z) — the constructor's
$z defaults to 0.0 so existing 2D call sites still compile
unchanged. Use the third dimension when a scene needs a Z
axis (parallax / depth-shaded particle systems).
The Y-axis convention is Y-up by default: Gravity::standard() returns (0, -9.81, 0) so increasing
Y means "up the screen". Terminal renderers usually grow downward —
flip to Gravity::standardYDown() (or its Projectile::gravityYDown()
alias) when you want gravity to pull toward the bottom of the grid
without manually negating every coordinate.
Projectile::update() returns a new Projectile instance each
call (immutable-with-pattern) — update() never mutates the
receiver. Read the new
position from result->position rather than $p->position().
SpringChain
Sequence multiple springs so that one spring's settle triggers the next. Useful for staggered animations where each stage must complete before the next begins.
use SugarCraft\Spring\Spring; use SugarCraft\Spring\SpringChain; use SugarCraft\Spring\SpringPreset; $chain = (new SpringChain([])) ->withStage(Spring::fromPreset(SpringPreset::Gentle), 0.0, 0.0, 50.0) ->withStage(Spring::fromPreset(SpringPreset::Wobbly), 0.0, 0.0, 100.0) ->withStage(Spring::fromPreset(SpringPreset::Stiff), 0.0, 0.0, 75.0); while (!$chain->isComplete()) { [$positions, $complete, $chain] = $chain->tick(); // $positions reflects settled stages + the currently animating stage. // MUST reassign $chain: tick() is copy-on-write and returns the advanced // chain as its third element — dropping it replays the same frame forever. }
Each tick() call advances only the active stage and returns
[positions, chainComplete, newChain]. When that stage reaches its target
— position within 5e-4 of the target and velocity within 5e-4 of
zero (SpringChain::SETTLING_THRESHOLD, looser than a lone spring's
Spring::SETTLING_THRESHOLD of 1e-4 so stages hand off while still
visually moving) — the chain activates the next stage. isComplete()
returns true when all stages have settled. Note withStage() restarts
the new chain at stage 0: build the full stage list before the first
tick().
SpringCollection
Keep many named springs animating at once. Like every other type here,
the collection is immutable: withSpring(), without(), withTarget()
and tick() each return a new collection, so the carry-the-result
assignment is mandatory:
use SugarCraft\Spring\Spring; use SugarCraft\Spring\SpringCollection; $group = (new SpringCollection()) ->withSpring('menu', Spring::fromPreset(\SugarCraft\Spring\SpringPreset::Stiff), 0.0, 0.0, 40.0) ->withSpring('panel', Spring::fromPreset(\SugarCraft\Spring\SpringPreset::Molasses), 0.0, 0.0, 90.0); for ($frame = 0; $frame < 120; $frame++) { $group = $group->tick(); // tick(): self — NOT a position map $thisFrame = $group->all(); // array<string, float> lives here } echo sprintf("menu=%.2f panel=%.2f\n", $group->get('menu'), $group->get('panel'));
Read with get($id) / all() / getTarget($id) / has($id). The
earlier in-place add() / remove() / setTarget() mutators are
deprecated shims that emit E_USER_DEPRECATED and will be dropped;
migrate to the with*() forms above (the shims mutate the receiver and
return void, so $c = $c->add(...) assigns null).
Easing
SugarCraft\Spring\Easing\Easing provides named easing curves via its
ease(float $t): float method — apply to any normalized time value in
[0.0, 1.0]:
use SugarCraft\Spring\Easing\Easing; $ease = Easing::ElasticOut; for ($f = 0; $f <= 60; $f++) { $t = $f / 60.0; echo sprintf("frame %2d t=%.3f eased=%.3f\n", $f, $t, $ease->ease($t)); }
CubicBezier
CubicBezier implements the CSS cubic-bezier() easing algorithm
(Newton-Raphson root-finding with binary-search fallback). Construct via
static factory methods: 26 named presets in total — the five
CSS-spec-defined timing functions (ease, easeIn, easeOut,
easeInOut, linear) plus 21 Penner-style curves approximated with the
easings.net table. Note that CSS itself standardises only the four
keywords and arbitrary cubic-bezier(); the Sine/Quad/Cubic/Quart/Quint/
Expo/Circ rows are the Penner easing family expressed as bezier control
points, not a CSS standard list:
use SugarCraft\Spring\Easing\CubicBezier; // CSS named easings $ease = CubicBezier::ease(); // 0.25, 0.10, 0.25, 1.00 $easeIn = CubicBezier::easeIn(); // 0.42, 0.00, 1.00, 1.00 $easeOut = CubicBezier::easeOut(); // 0.00, 0.00, 0.58, 1.00 $easeInOut = CubicBezier::easeInOut(); // 0.42, 0.00, 0.58, 1.00 $linear = CubicBezier::linear(); // 0.00, 0.00, 1.00, 1.00 // Sine $easeInSine = CubicBezier::easeInSine(); $easeOutSine = CubicBezier::easeOutSine(); $easeInOutSine = CubicBezier::easeInOutSine(); // Quadratic $easeInQuad = CubicBezier::easeInQuad(); $easeOutQuad = CubicBezier::easeOutQuad(); $easeInOutQuad = CubicBezier::easeInOutQuad(); // Cubic $easeInCubic = CubicBezier::easeInCubic(); $easeOutCubic = CubicBezier::easeOutCubic(); $easeInOutCubic = CubicBezier::easeInOutCubic(); // Quartic / Quintic / Exponential / Circular $easeInQuart = CubicBezier::easeInQuart(); $easeOutQuart = CubicBezier::easeOutQuart(); $easeInOutQuart = CubicBezier::easeInOutQuart(); $easeInQuint = CubicBezier::easeInQuint(); $easeOutQuint = CubicBezier::easeOutQuint(); $easeInOutQuint = CubicBezier::easeInOutQuint(); $easeInExpo = CubicBezier::easeInExpo(); $easeOutExpo = CubicBezier::easeOutExpo(); $easeInOutExpo = CubicBezier::easeInOutExpo(); $easeInCirc = CubicBezier::easeInCirc(); $easeOutCirc = CubicBezier::easeOutCirc(); $easeInOutCirc = CubicBezier::easeInOutCirc(); for ($f = 0; $f <= 60; $f++) { $t = $f / 60.0; echo sprintf("frame %2d eased=%.4f\n", $f, $easeInOutCubic->evaluate($t)); }
CubicBezier::evaluate(float $t): float maps [0, 1] → [0, 1]
using the Newton-Raphson algorithm from the W3C CSS Easing spec.
Public API
Spring—__construct($dt, $ω, $ζ, ?bool $reducedMotionOverride = null)/update($pos, $vel, $target)/fps(float)/fromPreset(SpringPreset, ?float)/ staticsettled($pos, $vel, $target, $threshold = SETTLING_THRESHOLD).update()short-circuits to[$target, 0.0]when the env-driven reduced-motion lookup — or the per-instance override — resolves totrue.SpringChain—__construct($stages)/new($stages)/withStage(Spring, $pos, $vel, $target)(build-time only; returns a chain restarted at stage 0) /tick(): array{0: list<float>, 1: bool, 2: self}— carry the returned chain ($chain = ...via list destructuring) or the run never advances /currentPositions(): list<float>/isComplete(): bool/activeStage(): int.SpringCollection—__construct($springs, $positions, $velocities, $targets)/withSpring($id, Spring, $pos, $vel, $target)/without($id)/withTarget($id, $target)/tick(): self(new collection; positions map viaall()) /get($id): float/has($id): bool/all(): array<string,float>/getTarget($id): float. The in-placeadd()/remove()/setTarget()mutators are deprecatedE_USER_DEPRECATEDshims slated for removal — use thewith*()forms.SpringPreset—Gentle/Wobbly/Stiff/Slow/Molasses.resolve()returns aSpringConfig.SpringConfig—__construct(tension, friction, mass)/spring(float $dt)/springAt60Fps().Projectile—Projectile::new(...)/update()/position()/velocity()/acceleration()/gravity()/terminalGravity()/gravityYDown()/terminalGravityYDown()/GRAVITY/TERMINAL_GRAVITY.Gravity— package-level static accessors:standard(),terminal(),standardYDown(),terminalYDown().Vector— immutable 3D vector withadd/sub/scale/length/dot/cross/Vector::zero().Point— immutable 3D point withadd(Vector)/distance/Point::zero().Easing— enum withease(float $t): float. Cases:Linear,QuadraticIn/Out/InOut,CubicIn/Out/InOut,ElasticIn/Out/InOut,BounceIn/Out/InOut,BackIn/Out/InOut.CubicBezier—evaluate(float $t): float. 26 static factories: the five CSS-spec timing functions (ease,easeIn,easeOut,easeInOut,linear) and 21 Penner-style curves (easeIn/Out/InOut×Sine,Quad,Cubic,Quart,Quint,Expo,Circ) expressed as bezier control points.
Exception messages are translated through SugarCraft\Spring\Lang
(sugarcraft i18n registry, lang/<code>.php files); set the locale with
the shared core Lang machinery as elsewhere in the stack.
Test
cd sugar-spring && composer install && vendor/bin/phpunit
Snapshot tests
Numeric trajectory output is pinned as golden files (JSON) in
tests/fixtures/, using sugar-testing's GoldenFile::save()/load()
with plain assertEquals equality in tests/GoldenRenderTest.php —
sugar-testing's assertGolden* helper family is ANSI/cell-grid specific
and not used here. Any change to the physics output must be intentional —
re-record the fixtures with:
UPDATE_GOLDENS=1 vendor/bin/phpunit
(there is no --update-golden phpunit flag).
Demos
Projectile motion
Spring physics
Particle field
Credits & inspiration
Originally inspired by the Go Charm ecosystem; SugarCraft is developed as a native PHP project.


