name: charm-harmonica description: "Physics-based animation for Go TUIs - damped spring oscillator and projectile motion. Use when adding spring animations, physics-based motion, or smooth transitions to Go terminal apps. No Ease function exists in this library."
charm-harmonica
Physics-based animation primitives: spring (damped harmonic oscillator) and projectile motion.
Quick Start
import "github.com/charmbracelet/harmonica"
// Create spring once - expensive coefficients computed here
spring := harmonica.NewSpring(harmonica.FPS(60), 6.0, 0.5)
// Per-frame state - YOU own these
var pos, vel float64
// Each frame:
pos, vel = spring.Update(pos, vel, targetPos)
Core API
FPS(n int) float64
Converts a frame rate to a delta time in seconds. Pass directly to NewSpring or NewProjectile.
dt := harmonica.FPS(60) // 0.01666...
Use the engine's actual delta time instead when available (e.g. real elapsed time between frames).
NewSpring(deltaTime, angularFrequency, dampingRatio float64) Spring
Pre-computes spring coefficients. Call once, reuse across frames.
| Parameter | Effect |
|---|---|
deltaTime |
Frame duration in seconds - use FPS(n) |
angularFrequency |
Speed of motion. Typical range: 1-20 |
dampingRatio |
Oscillation behavior |
Damping ratio guide:
< 1.0- under-damped, overshoots and oscillates= 1.0- critically damped, fastest without overshoot> 1.0- over-damped, slow and sluggish
Practical starting points: frequency 6.0, damping 0.5 (smooth). For bouncy: frequency 8.0, damping 0.15. For snappy: frequency 12.0, damping 1.0.
Spring.Update(pos, vel, target float64) (newPos, newVel float64)
Advances one frame. Returns new position and velocity - always capture both.
// One spring can drive multiple independent axes
x, xVel = spring.Update(x, xVel, targetX)
y, yVel = spring.Update(y, yVel, targetY)
radius, radiusVel = spring.Update(radius, radiusVel, targetRadius)
NewProjectile(deltaTime float64, pos Point, vel, acc Vector) *Projectile
Kinematic projectile - no spring, just position + velocity + acceleration per frame.
p := harmonica.NewProjectile(
harmonica.FPS(60),
harmonica.Point{X: 0, Y: 0, Z: 0},
harmonica.Vector{X: 5, Y: 0, Z: 0}, // initial velocity
harmonica.TerminalGravity, // acceleration: {0, 9.81, 0}
)
// Each frame:
pos := p.Update() // returns harmonica.Point
Gravity constants:
harmonica.Gravity-{0, -9.81, 0}- origin bottom-leftharmonica.TerminalGravity-{0, 9.81, 0}- origin top-left (standard TUI)
Projectile accessors: p.Position(), p.Velocity(), p.Acceleration()
No Ease Function
harmonica does not have an Ease function. It has Spring and Projectile only.
bubbletea Integration Pattern
The canonical pattern uses a frameMsg sentinel type and tea.Tick to drive the loop.
const fps = 60
type frameMsg time.Time
// Schedules the next frame tick
func animate() tea.Cmd {
return tea.Tick(time.Second/fps, func(t time.Time) tea.Msg {
return frameMsg(t)
})
}
type model struct {
x float64
xVel float64
spring harmonica.Spring
}
func (m model) Init() tea.Cmd {
return animate() // kick off the loop
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.(type) {
case frameMsg:
const target = 60.0
m.x, m.xVel = m.spring.Update(m.x, m.xVel, target)
// Stop ticking when close enough
if math.Abs(m.x-target) < 0.01 {
return m, nil
}
return m, animate() // schedule next frame
}
return m, nil
}
func main() {
m := model{
spring: harmonica.NewSpring(harmonica.FPS(fps), 7.0, 0.15),
}
tea.NewProgram(m).Run()
}
Changing spring parameters mid-animation
When frequency or damping changes, call NewSpring again with the same deltaTime. The current pos and vel carry over - do not reset them.
// User changed settings - recompute spring, keep state
spring = harmonica.NewSpring(harmonica.FPS(fps), newFreq, newDamp)
// x, xVel unchanged - animation continues smoothly from current state
Stopping the animation loop
Return nil (no command) instead of animate() to stop. Resume by returning animate() again on the next relevant message.
Common Mistakes
Forgetting to capture velocity. spring.Update returns two values. Discarding the velocity breaks the simulation on the next frame.
// wrong
pos, _ = spring.Update(pos, vel, target)
// right
pos, vel = spring.Update(pos, vel, target)
Creating Spring inside the update loop. NewSpring is expensive - it computes trig/exp coefficients. Create it once in main or Init, store it on the model.
Using Gravity instead of TerminalGravity in TUIs. TUI coordinate systems have Y increasing downward. Use TerminalGravity ({0, 9.81, 0}) so things fall down the screen, not up.
Not passing real delta time in non-fixed-FPS contexts. In game loops with variable frame time, pass the actual time.Since(last).Seconds() to NewSpring each frame instead of FPS(60). Recreating the spring with the real dt each frame is correct and expected.
Calling animate() unconditionally. Always check if the animation has converged before scheduling the next frame, or it runs forever at 60fps.
Checklist
-
NewSpringcalled once, stored on model struct - Both return values from
Updatecaptured (pos, vel = ...) -
animate()returnsnilwhen animation is done (convergence check) - Using
TerminalGravityfor TUI projectiles (Y-down coordinate space) -
frameMsgtype defined astype frameMsg time.Time - Spring recomputed (not state reset) when parameters change at runtime