1---
2name: charm-harmonica
3description: "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."
4---
5
6# charm-harmonica
7
8Physics-based animation primitives: spring (damped harmonic oscillator) and projectile motion.
9
10## Quick Start
11
12```go
13import "github.com/charmbracelet/harmonica"
14
15// Create spring once - expensive coefficients computed here
16spring := harmonica.NewSpring(harmonica.FPS(60), 6.0, 0.5)
17
18// Per-frame state - YOU own these
19var pos, vel float64
20
21// Each frame:
22pos, vel = spring.Update(pos, vel, targetPos)
23```
24
25## Core API
26
27### FPS(n int) float64
28
29Converts a frame rate to a delta time in seconds. Pass directly to `NewSpring` or `NewProjectile`.
30
31```go
32dt := harmonica.FPS(60) // 0.01666...
33```
34
35Use the engine's actual delta time instead when available (e.g. real elapsed time between frames).
36
37### NewSpring(deltaTime, angularFrequency, dampingRatio float64) Spring
38
39Pre-computes spring coefficients. Call once, reuse across frames.
40
41| Parameter | Effect |
42|-----------|--------|
43| `deltaTime` | Frame duration in seconds - use `FPS(n)` |
44| `angularFrequency` | Speed of motion. Typical range: 1-20 |
45| `dampingRatio` | Oscillation behavior |
46
47**Damping ratio guide:**
48- `< 1.0` - under-damped, overshoots and oscillates
49- `= 1.0` - critically damped, fastest without overshoot
50- `> 1.0` - over-damped, slow and sluggish
51
52Practical 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`.
53
54### Spring.Update(pos, vel, target float64) (newPos, newVel float64)
55
56Advances one frame. Returns new position and velocity - always capture both.
57
58```go
59// One spring can drive multiple independent axes
60x, xVel = spring.Update(x, xVel, targetX)
61y, yVel = spring.Update(y, yVel, targetY)
62radius, radiusVel = spring.Update(radius, radiusVel, targetRadius)
63```
64
65### NewProjectile(deltaTime float64, pos Point, vel, acc Vector) *Projectile
66
67Kinematic projectile - no spring, just position + velocity + acceleration per frame.
68
69```go
70p := harmonica.NewProjectile(
71 harmonica.FPS(60),
72 harmonica.Point{X: 0, Y: 0, Z: 0},
73 harmonica.Vector{X: 5, Y: 0, Z: 0}, // initial velocity
74 harmonica.TerminalGravity, // acceleration: {0, 9.81, 0}
75)
76
77// Each frame:
78pos := p.Update() // returns harmonica.Point
79```
80
81**Gravity constants:**
82- `harmonica.Gravity` - `{0, -9.81, 0}` - origin bottom-left
83- `harmonica.TerminalGravity` - `{0, 9.81, 0}` - origin top-left (standard TUI)
84
85**Projectile accessors:** `p.Position()`, `p.Velocity()`, `p.Acceleration()`
86
87### No Ease Function
88
89harmonica does not have an Ease function. It has Spring and Projectile only.
90
91## bubbletea Integration Pattern
92
93The canonical pattern uses a `frameMsg` sentinel type and `tea.Tick` to drive the loop.
94
95```go
96const fps = 60
97
98type frameMsg time.Time
99
100// Schedules the next frame tick
101func animate() tea.Cmd {
102 return tea.Tick(time.Second/fps, func(t time.Time) tea.Msg {
103 return frameMsg(t)
104 })
105}
106
107type model struct {
108 x float64
109 xVel float64
110 spring harmonica.Spring
111}
112
113func (m model) Init() tea.Cmd {
114 return animate() // kick off the loop
115}
116
117func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
118 switch msg.(type) {
119 case frameMsg:
120 const target = 60.0
121 m.x, m.xVel = m.spring.Update(m.x, m.xVel, target)
122
123 // Stop ticking when close enough
124 if math.Abs(m.x-target) < 0.01 {
125 return m, nil
126 }
127
128 return m, animate() // schedule next frame
129 }
130 return m, nil
131}
132
133func main() {
134 m := model{
135 spring: harmonica.NewSpring(harmonica.FPS(fps), 7.0, 0.15),
136 }
137 tea.NewProgram(m).Run()
138}
139```
140
141### Changing spring parameters mid-animation
142
143When frequency or damping changes, call `NewSpring` again with the same `deltaTime`. The current `pos` and `vel` carry over - do not reset them.
144
145```go
146// User changed settings - recompute spring, keep state
147spring = harmonica.NewSpring(harmonica.FPS(fps), newFreq, newDamp)
148// x, xVel unchanged - animation continues smoothly from current state
149```
150
151### Stopping the animation loop
152
153Return `nil` (no command) instead of `animate()` to stop. Resume by returning `animate()` again on the next relevant message.
154
155## Common Mistakes
156
157**Forgetting to capture velocity.** `spring.Update` returns two values. Discarding the velocity breaks the simulation on the next frame.
158```go
159// wrong
160pos, _ = spring.Update(pos, vel, target)
161
162// right
163pos, vel = spring.Update(pos, vel, target)
164```
165
166**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.
167
168**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.
169
170**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.
171
172**Calling `animate()` unconditionally.** Always check if the animation has converged before scheduling the next frame, or it runs forever at 60fps.
173
174## Checklist
175
176- [ ] `NewSpring` called once, stored on model struct
177- [ ] Both return values from `Update` captured (`pos, vel = ...`)
178- [ ] `animate()` returns `nil` when animation is done (convergence check)
179- [ ] Using `TerminalGravity` for TUI projectiles (Y-down coordinate space)
180- [ ] `frameMsg` type defined as `type frameMsg time.Time`
181- [ ] Spring recomputed (not state reset) when parameters change at runtime