Concepts
Understand parametric curves, the skeleton, and the engine.
Parametric Curves
A parametric curve is a path drawn by a point moving through space. Instead of describing the shape directly, you describe how the point moves: where it is at each moment in time.
Imagine a dot moving across a piece of paper. At any instant, the dot has an exact position: an x coordinate and a y coordinate. As time passes, the dot traces a path. A parametric curve is simply the record of that path.
The key idea is that you control when the dot is at each position. You can speed up, slow down, or even run time backwards. The same geometric shape can be traced at different speeds, producing different animations.
How it works: The trail you see is just the last few positions of the dot. Each frame, time advances slightly, the dot moves to its new position, and the oldest point in the trail fades away. The result is a tail that follows the curve.
The parameter t controls exactly where on that path the dot is at any moment.
Click a position to see the trail rebuild as if the dot had traveled naturally to that point
You can head to the playground to experiment with parametric curves and try them out in real time.
The Skeleton
The skeleton is the complete path the curve traces during one full cycle, shown as a faint background behind the animated trail. It acts as a reference as to where the curve will go.
The skeleton is computed when the sarmal is created. It samples the curve function across one complete period, capturing every point the trail will eventually visit. Because it is a snapshot, it represents the curve at its starting moment, before any time has elapsed.
Skeleton Modes
Most curves have a fixed shape. For example, the geometry of a rose curve or an astroid curve never changes. For these, the skeleton can be computed once and reused. This is the default mode: 'static'.
Some curves change shape over time. For example, the Lamé curve morphs continuously, as its corners are rounding and sharpening over time. For these, a static skeleton quickly becomes wrong, as the background stops matching the trail after some time.
The 'live' mode fixes this. Each frame, the skeleton is redrawn using the current time, so it always matches the shape the trail is tracing.
A third option exists for curves that animate but still have a natural reference shape. The skeletonFn option accepts an explicit function that returns the canonical form, drawn the same way regardless of elapsed time.
| Mode | When to use |
|---|---|
'static' (default) | Shape does not change |
'live' | Shape changes with time |
skeletonFn | Curve animates but has a stable reference shape |
Watch the difference on the Lamé curve, which morphs continuously. The static skeleton freezes at the initial shape and quickly falls out of sync with the trail. The live skeleton redraws each frame and follows the current curvature.
Engine
The engine owns time, state, and math. It does not know about pixels, colors, or rendering.
The pipeline:
// You write the math
const fn = (t, time, params) => ({
x: Math.cos(t),
y: Math.sin(t)
});
// The engine advances t, manages the trail buffer, handles morphing
const engine = createEngine(curveDef);
const points = engine.tick(dt); // math-space coordinates
// Then, the renderer handles sizing, color, DPR, canvas/SVG
renderer.render(points, skeleton);
fn is called each tick with three arguments:
t(position along the curve, 0 -> period),time(elapsed seconds, useful for time-varying shapes),params(reserved for now; always{}).
The renderer automatically fits the curve to its container by using the skeleton’s bounding box. So, you don’t really have to set a scale or size on the curve itself.
Watch the engine and renderer working together. The numbers on the right are raw {x, y} math-space coordinates received from engine.tick(dt). These values are then translated by the renderer into pixels on the left:
What the engine manages:
- t: position along the curve (advances via
tick(dt), overridden withseek(t)) - actualTime: actual elapsed seconds (only advances with
tick(), never withseek()) - Trail buffer: the last
Npoints. It is a mutable, same array reference each tick - Morphing state: when transitioning between curves
The engine never touches pixels, canvas, SVG elements, colors, opacity, DPR, container dimensions.
Closing the Loop
Your curve does not need to be circular or act like a circle, but it needs to be periodic. Its fn must return to its exact starting point after one full period. The shape can be loopy, angular, asymmetric, or anything else as long as it closes.
When that holds, Sarmal loops seamlessly.
When it doesn’t, the head dot teleports from the end back to the start on every cycle.
So, when making a curve:
starting point = ending pointEveryone is happystarting point ≠ ending pointThe engine is sad, the renderer is helpless, and the user is disappointed.
Instance Lifecycle
Sarmal instances are created with createSarmal() (canvas) or createSarmalSVG() (SVG). By default, they start animating right away. Unless you opt out of autoStart, you don’t need to call play() at all.
Auto-start
The autoStart option (default: true) controls whether the animation loop begins automatically:
// Starts animating immediately (default)
const s1 = createSarmal(canvas, curves.artemis2);
// Created in a dormant state, so the canvas shows skeleton at t=0
const s2 = createSarmal(canvas, curves.artemis2, { autoStart: false });
s2.play(); // Start when ready
Initial position
The initialT option sets the starting position along the curve. It calls seek() internally, so the trail is reconstructed naturally:
// Start animating from t=π
const s3 = createSarmal(canvas, curves.artemis2, { initialT: Math.PI });
// Create at a specific position and keep it there until `play()` call
const s4 = createSarmal(canvas, curves.artemis2, {
autoStart: false,
initialT: Math.PI
});
// Canvas shows skeleton + trail at t=π
s4.play(); // Animation starts from there
initialT: Math.PI
autoStart: false
Dormant at creation. Trail pre-drawn to t=π. Play to animate from there.
Why you don’t always need play()
For simple loading indicators, the defaults work without any lifecycle calls:
// This is enough
const spinner = createSarmal(canvas, curves.artemis2);
// Cleanup when done
spinner.destroy();
Use autoStart: false when you need precise control over when animation begins, such as:
- Waiting for data to load
- Coordinating multiple animations
- Implementing play/pause controls