Loading demo...
canvasphysicsinteractivesandbox

Particle Sandbox

A canvas particle sandbox with a hold-to-open tool wheel, paintable particles, and placeable gravity bodies — drop a black hole and watch it eat the field.

A real-time particle sandbox on a 2D canvas. Eight tools, up to 3,000 particles you can paint in or vacuum out, and gravity bodies you drop into the field and drag around. Hold Tab (or right-click, or long-press on touch) to open the tool wheel — time dilates to 15% while it's open, so you pick without losing the field.

The cursor is the active tool. Keys 1–8 jump straight to one, T cycles the color theme.

The tools

  1. Attract — pulls the field toward the cursor
  2. Repel — shoves everything away
  3. Vortex — spins particles into orbit
  4. Freeze — damps velocity to zero inside the ring
  5. Spawn — paints new particles
  6. Erase — vacuums particles out
  7. Singularity — drops a black hole
  8. Repulsor — drops a low-gravity bubble

Dragging from an existing body picks it up and moves it, whichever tool is selected — so you never have to switch tools just to reposition something.

Force models

The three force brushes apply a vector to every particle relative to the cursor. Let r be the vector from particle to cursor and |r| its magnitude. Each brush clamps |r| to a floor before dividing, which is what stops the force from exploding as a particle crosses the cursor.

Attract — inverse-distance pull, capped:

F = min(0.3 / max(|r|, 50), 0.8) · 0.8

Repel — inverse-square push, capped harder because the near field is where it matters:

F = min(50 · 1.0 / max(|r|, 30)², 1.5)

Vortex — a tangential component perpendicular to r, plus a weak radial term so the orbit slowly tightens instead of flinging outward:

F_tangent = 0.4 / max(|r|, 40) · 0.5
F_radial  = 0.05 / max(|r|, 40)

Each brush carries its own friction — 0.98, 0.97, and 0.985 respectively — which is most of why they feel different. Repel's lower friction makes the field settle fast after a shove; vortex's higher friction lets orbits persist. Everything else runs at a base 0.98.

A Force slider scales all three from 0.1x to 3x.

Gravity bodies

Bodies affect particles, but not each other. They stay where you drop them, which makes the sandbox composable — you can build a stable arrangement instead of watching your orbs drift off-screen.

Singularity

An inverse-square well with a hard event horizon:

F = min(900 · m / max(|r|, horizon + 8)², 1.6)

plus a tangential swirl at 35% of the radial force, which is what produces the accretion spiral rather than particles falling straight in.

Any particle crossing the horizon is consumed — it respawns at a random screen edge and the body's mass ticks up by 0.0016, capped at 3.2. So the black hole visibly fattens as it feeds, the particle count stays honest against the HUD readout, and the field never drains. The counter tracks total consumed.

Repulsor

The inverse of the well, with a soft shell:

F = -min(620 · m / max(|r|, 0.8r)², 1.4)

Between the shell radius and 1.8x that, velocity gets an extra 0.965 damping per step. That band is what makes particles pool into a shimmering bubble around the orb instead of being cleanly blasted away.

Particle storage

Particles live in a structure-of-arrays layout — parallel Float32Arrays for position, velocity, size, and a per-particle color seed, allocated once at a 4,000 ceiling with a live count. Spawning bumps the count. Erasing does a swap-remove against the last live index. Neither allocates.

Trails are a ring buffer: one Float32Array of 4000 · 6 · 2 floats with a rotating head index, so recording a trail point is two writes to a fixed offset. The earlier version allocated a fresh {x, y} object per particle per frame, which was survivable at 300 particles and not at 3,000.

When a particle wraps an edge, its whole trail collapses to the new position — otherwise it draws a streak clean across the screen.

Fixed timestep

The simulation advances in fixed 1/60s substeps against an accumulator, capped at 3 substeps per frame:

accumulator += min(dt, 0.1) · timeScale
while (accumulator >= 1/60 && steps < 3) { step(); ... }

The force constants are all tuned as per-frame velocity deltas at 60Hz, so a fixed substep preserves the feel exactly while making it frame-rate independent. Without this the field runs literally twice as fast on a 120Hz display.

timeScale is also the tool wheel's slow-motion — it eases to 0.15 while the wheel is open and springs back on release.

Connections

Constellation lines are drawn only near the cursor, and only while it's over the canvas. Particles within 250px of the pointer get bucketed into a uniform grid at the 100px connection distance; each cell then pair-checks itself plus four forward neighbors, so every pair is visited once.

The naive version — all pairs among everything near the cursor — is fine at 300 particles and around 80,000 distance checks per frame at 3,000. The grid keeps it roughly linear.

Roughly linear, that is, only while the field stays spread out. Vortex and a singularity's accretion disk do the opposite: they collapse most of the field into a single grid cell, and the per-cell pair check goes quadratic again — about 180,000 checks per frame at 600 particles, a 55x jump over a uniform field. So cell membership is capped. A clump that dense is already alpha-saturated, so the lines it drops are invisible, and the cost becomes independent of particle count.

Surviving pairs are sorted into 5 alpha buckets and stroked as 5 batched Path2Ds, so line count doesn't drive draw-call count.

Rendering

Everything is batched. Particles are counting-sorted into 32 buckets — 8 hue x 4 alpha — and each bucket draws as exactly two Path2D fills: a soft glow pass and a sharp core. Trails are stroked as polylines in 4 line-width buckets. So a 3,000-particle frame is on the order of 70 draw calls, not 6,000.

Glow is an additive lighter composite pass rather than shadowBlur, which is dramatically cheaper and reads better against a dark field.

The background is a translucent fill rather than a clear, which is where the motion blur comes from.

Sound

All audio is synthesized at runtime through WebAudio — oscillators and filtered noise, no asset files. The wheel sweeps up as it opens, segments tick as you cross them, singularities land on a low sine thud under a noise burst, repulsors on a bell. Master gain sits at 0.12 and each cue is rate-limited so a held spawn drag doesn't machine-gun.

Browsers block audio until the first user gesture, so the AudioContext is created lazily on first interaction — the first sound lands on your first click, not on page load. The mute toggle persists to localStorage.

Performance and access

The RAF loop is gated by an IntersectionObserver plus visibilitychange, so it stops when the demo scrolls offscreen or the tab is backgrounded. Canvas sizing runs off a ResizeObserver with device pixel ratio clamped to 2.

Under prefers-reduced-motion the loop never starts: the field renders a single static frame and the whole HUD stays live, so the controls are still explorable without any animation.

Tech

Canvas 2D, TypeScript, React, framer-motion for the wheel and HUD. No WebGL, no physics library, no sprite atlases.