Introduction
In this article we build Silly String — an interactive silly string physics simulation that runs entirely on the HTML5 Canvas API. You hold down the mouse to spray colourful string from a procedurally drawn aerosol can. The string arcs through the air, curls from pressure and chaos, and drapes over a large ADUOK logo rendered at the centre of the screen. The logo is itself a collision surface — string wraps around the letterforms and settles on them. No physics engine, no Canvas library, no frameworks. Just raw Verlet integration, constraint solving, and a pixel-sampled SVG collision map.
The simulation is controlled by five live sliders: Pressure (spray speed), Gravity (downward acceleration, supports negative for floating), Curliness (periodic sideways curl), Thickness (strand render width), and Chaos (random angle wobble). All sliders update in real time — no restart needed.
How Verlet integration replaces velocity with positional history — how to build a constraint-based rope from linked particles — how to rasterise an SVG into a per-pixel alpha collision map — how to emit particles from a procedural nozzle with curl and chaos — how to render smooth quadratic-curve strands with glow — how to draw a detailed spray can entirely with Canvas 2D APIs — how to cap memory usage by trimming the oldest strand particles when a budget is exceeded.
How Verlet Integration Works
Standard physics simulations store a position and a velocity for each particle and update them separately every frame. Verlet integration throws away the explicit velocity and instead derives it implicitly from the difference between the current position and the previous position. Each frame: new_pos = current + (current - previous) * damping + gravity. The previous position becomes the new current after the update.
Verlet integration is numerically stable, cheap, and trivially constraint-friendly — which is exactly why it is the standard choice for rope and cloth simulations.
The payoff is constraint solving. To enforce a fixed distance between two particles, you simply move both particles toward each other until the distance matches the rest length. Because velocity is implicit, any positional correction is automatically reflected in the next frame's implied velocity. This makes it trivial to build rope-like chains: each particle is linked to its neighbour, and two rounds of constraint solving per frame is enough to produce stable, convincing results.
Step 1 — Class Structure
The simulation is built from four classes. Particle holds position, previous position, a rest counter, and an update method. Link holds references to two particles, a rest length, and a solve method that enforces the distance constraint. Strand holds an array of particles, an array of links, and a colour index, plus an emit method that appends a new particle. The top-level loop manages an array of Strand instances and drives physics, rendering, and input.
| Class / Function | Responsibility | Category |
|---|---|---|
| Particle | Position, previous position, Verlet update, logo collision, boundary clamping | Physics |
| Link | Constraint between two adjacent particles — enforces rest length, breaks on stretch | Physics |
| Strand | Ordered chain of particles and links, one colour per strand, emit() appends particles | Physics |
| buildLogo() | Loads SVG twice (display + thick), rasterises thick version into a Uint8Array collision map | Collision |
| emitString() | Reads sliders, computes nozzle tip world position, appends 3 particles per frame | Emission |
| drawCan() | Draws the full aerosol can procedurally using Canvas 2D gradient, rrect, and text APIs | Rendering |
| renderStrands() | Iterates strands, builds quadratic-curve paths, draws glow pass + core pass + highlight pass | Rendering |
| loop() | RAF loop: clear, emit, step physics, render strands, blit logo, draw can | Loop |
Step 2 — Particle & Link Implementation
Each Particle stores x, y, prevX, prevY, restCount, and resting. The update method applies damping (0.968×) to the implied velocity, adds gravity, then resolves logo and boundary collisions. If a collision is detected, the particle attempts to slide along the surface axis by axis — first trying to undo only the Y displacement, then only X, then both. Each collision frame increments restCount; once it exceeds 16 the particle is marked resting and stops updating entirely.
class Particle {
constructor(x, y) {
this.x = x; this.y = y;
this.prevX = x; this.prevY = y;
this.restCount = 0;
this.resting = false;
}
update() {
if (this.resting) return;
const g = sliderVal('r-gravity') / 100 * 0.42;
const vx = (this.x - this.prevX) * 0.968;
const vy = (this.y - this.prevY) * 0.968;
this.prevX = this.x; this.prevY = this.y;
this.x += vx; this.y += vy + g;
if (isLogoPixel(this.x, this.y)) {
if (!isLogoPixel(this.x, this.prevY)) { this.y = this.prevY; }
else if (!isLogoPixel(this.prevX, this.y)) { this.x = this.prevX; }
else { this.x = this.prevX; this.y = this.prevY; }
this.prevX = this.x; this.prevY = this.y;
this.restCount += 4;
}
if (this.restCount > 16) this.resting = true;
}
}Each Link stores references to its two particles and a restLen of 2.5px. The solve method computes the current distance, breaks the link if it exceeds 28px (the strand stretched too far), and otherwise nudges both particles toward their ideal separation. The correction factor is (restLen - dist) / dist * 0.25 — the 0.25 stiffness keeps the solver stable across two iterations.
class Link {
constructor(a, b) {
this.a = a; this.b = b;
this.restLen = 2.5; this.broken = false;
}
solve() {
if (this.broken) return;
const dx = this.b.x - this.a.x;
const dy = this.b.y - this.a.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 0.001;
if (dist > 28) { this.broken = true; return; }
const f = (this.restLen - dist) / dist * 0.25;
if (!this.a.resting) { this.a.x -= dx * f; this.a.y -= dy * f; }
if (!this.b.resting) { this.b.x += dx * f; this.b.y += dy * f; }
}
}Step 3 — Strand Emission
Three particles are emitted per active frame. Each particle is placed at the nozzle tip world position with an initial velocity derived from the aim angle plus curl and chaos offsets. Curl is a sine wave keyed on a spray timer and per-particle phase offset, making the strand spiral as it flies. Chaos adds random angle perturbation. Pressure controls raw speed. All five values come directly from sliders and update in real time.
function emitString(cx, cy) {
if (!activStrand) return;
sprayTimer++;
const angle = getAimAngle(cx, cy);
const tip = getNozzleTip(cx, cy, angle);
const pressure = 3 + sliderVal('r-pressure') / 100 * 11;
const curlAmt = sliderVal('r-curl') / 100 * 4;
const chaos = sliderVal('r-chaos') / 100 * 1.25;
for (let j = 0; j < 3; j++) {
const speed = pressure + Math.random() * 2;
const curl = Math.sin(sprayTimer * 0.34 + j * 1.8) * curlAmt;
const wobble = (Math.random() - 0.5) * (0.15 + chaos);
const perp = angle + Math.PI / 2;
activStrand.emit(tip.x, tip.y,
Math.cos(angle + wobble) * speed + Math.cos(perp) * curl,
Math.sin(angle + wobble) * speed + Math.sin(perp) * curl);
}
}After each emission frame, the total particle count across all strands is summed. While it exceeds 9000 and there are at least two strands, the oldest strand (strands[0]) has its head particle and head link removed. When the oldest strand is fully consumed it is spliced from the array. This keeps memory flat during long spray sessions without any GC pressure — no objects are created during trimming, only array references are dropped.
Step 4 — Rasterising the Logo as a Collision Map
The ADUOK logo is defined as SVG path and ellipse elements. It is loaded twice each time the viewport resizes. The first load uses the display stroke width (6px) and is blitted directly onto the canvas each frame. The second load uses a fatter stroke (10px) — this extra coverage ensures particles collide with the letterform edges, not just their centres. The thick version is drawn onto a full-viewport offscreen canvas, and getImageData extracts the alpha channel into a Uint8Array collision map.
async function buildLogo() {
logoReady = false;
logoW = W * 0.62;
logoH = logoW * (92 / 262);
logoX = (W - logoW) / 2;
logoY = (H - logoH) / 2;
logoImg = await loadImg(svgToURL(makeSVG(6)));
const collImg = await loadImg(svgToURL(makeSVG(10)));
const off = document.createElement('canvas');
off.width = W; off.height = H;
const offCtx = off.getContext('2d');
offCtx.drawImage(collImg, logoX, logoY, logoW, logoH);
const data = offCtx.getImageData(0, 0, W, H).data;
collisionMap = new Uint8Array(W * H);
for (let i = 0; i < W * H; i++) collisionMap[i] = data[i * 4 + 3];
logoReady = true;
}
function isLogoPixel(x, y) {
if (!logoReady) return false;
const px = x | 0, py = y | 0;
if (px < 0 || py < 0 || px >= W || py >= H) return false;
return collisionMap[py * W + px] > 18;
}btoa encodes a string to base64 but fails silently on any character outside the Latin-1 range. SVG template literals can contain Unicode — especially in comments or attribute values — and a single out-of-range character produces a corrupt data URL that loads as a broken image with no error. encodeURIComponent percent-encodes all special characters before embedding the SVG inline as data:image/svg+xml,..., which is safe for any content and avoids the base64 layer entirely.
Step 5 — Procedural Spray Can with Canvas 2D
The spray can is drawn entirely in Canvas 2D each frame — no images, no sprites. It is translated to the cursor position and rotated by an aim angle computed from the cursor's position relative to the canvas centre. The body is a rounded rect filled with a horizontal linear gradient from dark blue through bright blue and back. Two metal bands (top and bottom) use a silver gradient. A highlight window uses a low-opacity white fill. The nozzle is a trapezoidal filled path. The spray tip is a small filled circle.
function drawCan(cx, cy) {
const angle = getAimAngle(cx, cy);
ctx.save();
ctx.translate(cx, cy); ctx.rotate(angle);
// Body with shadow
ctx.save();
ctx.shadowColor = 'rgba(0,0,0,0.1)';
ctx.shadowBlur = 10; ctx.shadowOffsetY = 4;
const bg = ctx.createLinearGradient(-CAN_W/2, 0, CAN_W/2, 0);
bg.addColorStop(0, '#005f99');
bg.addColorStop(0.18, '#0090e8');
bg.addColorStop(0.45, '#00b8ff');
bg.addColorStop(0.65, '#009ee0');
bg.addColorStop(0.88, '#0080cc');
bg.addColorStop(1, '#004a7a');
ctx.fillStyle = bg;
rrect(ctx, -CAN_W/2, -CAN_H/2, CAN_W, CAN_H, 4);
ctx.fill();
ctx.restore();
// Label text
ctx.fillStyle = 'rgba(255,255,255,0.88)';
ctx.font = 'bold 4.5px "Space Mono", monospace';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('ADUOK', 0, -1.5);
}The aim angle deliberately damps the raw atan2 angle by a factor of 0.15 on the sine axis, keeping the can nearly vertical while still tilting slightly toward the logo. Without this damping the can would spin wildly when the cursor moves close to the canvas centre.
Step 6 — Three-Pass Strand Rendering
Each strand is rendered in three passes over the same quadratic-curve path. The first pass draws a wide, low-alpha stroke with a canvas shadow matching the strand colour — this produces a soft glow halo. The second pass draws the core strand at full opacity and the slider-controlled thickness. The third pass draws a very thin highlight at 18% opacity using a lighter tint of the strand colour, simulating a specular shine along the rope.
// Build the strand path once
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (let i = 1; i < pts.length; i++) {
const lk = strand.links[i - 1];
if (lk && lk.broken) { ctx.moveTo(pts[i].x, pts[i].y); continue; }
const dx = pts[i].x - pts[i-1].x;
const dy = pts[i].y - pts[i-1].y;
if (dx*dx + dy*dy > 600) { ctx.moveTo(pts[i].x, pts[i].y); continue; }
ctx.quadraticCurveTo(
pts[i-1].x, pts[i-1].y,
(pts[i-1].x + pts[i].x) / 2,
(pts[i-1].y + pts[i].y) / 2
);
}
// Pass 1: glow
ctx.save();
ctx.shadowColor = col; ctx.shadowBlur = 3;
ctx.strokeStyle = col; ctx.globalAlpha = 0.1;
ctx.lineWidth = thick + 2.5; ctx.stroke();
ctx.restore();
// Pass 2: core
ctx.strokeStyle = col; ctx.globalAlpha = 0.88;
ctx.lineWidth = thick; ctx.stroke();
// Pass 3: highlight
ctx.strokeStyle = colL; ctx.globalAlpha = 0.18;
ctx.lineWidth = thick * 0.28; ctx.stroke();Step 7 — Input Handling & Demo Autoplay
On first load the simulation runs in demo mode: the can sweeps back and forth autonomously using a sine function keyed on the RAF tick counter, creating a new strand every 160 particles. Any mouse movement outside the UI panels immediately exits demo mode. Mousedown starts a spray (new Strand pushed to the array), mouseup ends it. Touch events are handled in parallel with passive: false to allow preventDefault on scroll. When the cursor enters the control panel or reset button, overUI is set and the spray can is hidden — but the normal browser cursor is restored via cursor: pointer on the buttons themselves.
Tuning Reference
| Parameter | Default | Effect |
|---|---|---|
| Pressure slider | 50 | Maps to 3–14px/frame initial speed. Higher = string shoots farther before curling or falling |
| Gravity slider | 30 | Maps to ±0.42px/frame². Negative values make string float upward |
| Curliness slider | 50 | Sine-wave curl amplitude up to ±4px. Produces loose spirals at high values |
| Thickness slider | 40 | Core stroke width 0.5–4px. Also scales glow pass and highlight pass proportionally |
| Chaos slider | 25 | Random wobble up to ±1.25 radians added to aim angle. Produces scattered spray at high values |
| Particle budget | 9000 | Total particle count before oldest-strand trimming begins. Increase for denser scenes |
| Link break distance | 28px | Distance at which a link snaps. Increase for more elastic string, decrease for brittle |
| Constraint iterations | 2 | Solve passes per frame. Increase to 4–6 for stiffer, less stretchy chains |
| restCount threshold | 16 | Collision-frame accumulator before a particle is frozen. Decrease for faster settling |
| Damping factor | 0.968 | Per-frame velocity multiplier. Lower = more air resistance, higher = more bouncy |
| Demo strand length | 160 | Particles emitted per demo strand before a new colour is started |
| Glow shadow blur | 3px | Canvas shadowBlur for the glow pass. Increase for a broader, softer halo |
Full Source Code
Save the following as silly-string.html and open in any modern browser. Zero dependencies, zero build step.
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/silly-string.htmlEvery particle, every curl, every strand of string — driven by a 30-line physics loop and two rounds of constraint solving per frame.