← All studies MVS Solves  /  Study 04  /  SPRING FIELD

Every dot is a damped spring with somewhere to be.

The word is drawn once to an offscreen canvas, its bitmap sampled on a grid, and every lit pixel becomes an anchor. The cursor applies an inverse-square repulsion; the springs pull everything home. All positions live in flat Float32Arrays — no objects, no allocation per frame.

Particles—
Stiffness k0.014
Damping0.90
Settle~120f
Fig. 04 — live
Move to disturb
Click to blast
Typed arrays · canvas 2D · no library · MVS Solves ↗
Study 04 — spring field

Five thousand springs want a cursor.

Every dot is a damped spring anchored to a pixel of rendered text, scattered by an inverse-square force from your pointer. Without a pointer there is nothing to scatter it.

Below is how it is built.

Best on a desktop — bring a pointer ↗
Engineering note

Thousands of bodies on the main thread

The constraint

Around 5,000 independently simulated particles at 60fps, in JavaScript, on the main thread, while the rest of the page stays responsive. That is a 16ms budget shared with layout and paint.

Why the obvious route fails

An array of particle objects allocates on every frame and hands the garbage collector thousands of short-lived references. The result is not a low frame rate — it is periodic stalls, which read worse than consistent slowness.

What I did instead

Every quantity lives in a flat Float32Array — positions, velocities, targets, per-particle seed. No objects, no allocation inside the loop, nothing for the collector to sweep. All particles are drawn as one batched path with a single fill, then a second pass tints only those moving fast enough to matter.

The trade-off I accepted

Particles do not collide with each other. Pairwise collision is O(n²), and at this count it would need spatial hashing that buys nothing visually — the anchors already keep the field readable. Repulsion comes from a single point: your cursor.

Integration
Damped spring, k = 0.014, damping 0.90
Repulsion
Inverse-square, softened, single source
Storage
6 Float32Arrays, zero per-frame allocation
Measured settle
mean error 988px → 0.002px over 240 frames
Anchors
Sampled from rendered text, 3–5px grid
Degradation
Reduced-motion → settled static frame