GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

Verlet Physics 9 Demos: No Library, Just Canvas

Verlet physics moves a point by the gap between where it is now and where it was last frame — no velocity stored.

Auto-plays · click a tile to jump to its section · all nine in one zip

The order is the order the material grows in. Board 01 is the smallest body there is — fourteen points joined by distance constraints into a rope. Board 02 spreads those same constraints into a grid and the result is cloth; 03 wraps the mesh into a ring and gives it an inside pressure, which turns it into a jelly. Boards 04 and 05 add rotation and joints, so the points become rigid shapes and then a figure with limbs. Boards 06 and 07 each lay one extra condition on top — water, and a chain reaction. Board 08 feeds the grid a wind that changes over time, and 09 abandons the mesh entirely and scatters the points as free particles — the last material a verlet physics engine ever reaches. The boards share their canvas with the animated chart drawing set but stand on the computing side of it, not the drawing side.

How is this different from a verlet physics simulation library

The earlier JavaScript physics interaction set worked by adding forces to a velocity and letting the velocity push a position around — a spring overshooting, a free fall, a pendulum, an inverse-square pull, a wall bounce. This set reaches the same screen through a different door: position-based Verlet integration and iterative constraint solving. Neither approach is the better one; they just start the arithmetic from different ends, and that starting point decides whether you can hold a soft body together at all.

Integration that keeps no velocity

Verlet integration computes the next position as the current position plus the difference between the current and the previous one. Because no velocity is stored, a collision or a constraint can force a position back and the very next frame stays consistent on its own. In a velocity-storing integrator, the moment you teleport a position it disagrees with the velocity; when only positions exist, there is nothing to disagree with.

Solving constraints by repetition

A single distance constraint does exactly one thing: pull two points back to their target separation. Rope, cloth, jelly, and ragdoll are all built from that one move, repeated a few times per frame until the whole body converges. Raising the slider on board 01 from 3 solver passes to 8 visibly tightens the rope.

Collisions that rotate

Board 04 stacks two more calculations on top: an impulse that swaps velocity along the collision normal, and a torque that appears whenever the contact point sits off center. Integrating an angle alongside each point is what makes a struck box spin away as it bounces. Inside the general-purpose verlet physics engine projects people usually reach for, these two calculations are the heaviest seats at the table.

01Rope chain

What you see

Grab the end and drag — the whole rope follows as distance constraints are solved over and over. It sits just as naturally under a playful hero banner you tug, or a drawer handle you drag open.

Rope chain
Open in a new tab

01 · Rope chain — Measured area 10.067% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

requestAnimationFramepointerdownpointermovetranslateY

Where to look in the code

The render measured 10.067% cumulative changed area at intensity 135.4 across 23 of 23 frame gaps.

  for (var k = 0; k < iters; k++) {
    for (var j = 0; j < N - 1; j++) {
      var a = pts[j], b = pts[j + 1];
      var dx = b.x - a.x, dy = b.y - a.y;
      var d = Math.sqrt(dx * dx + dy * dy) || 0.0001;
      var off = (d - SEG) / d * 0.5;
      var ax = dx * off, ay = dy * off;
      if (!a.pinned && j !== dragging || k === 0 && j !== dragging && !a.pinned) { /* no-op */ }
      var aFree = !a.pinned && j !== dragging;
      var bFree = !b.pinned && j + 1 !== dragging;
      if (aFree && bFree) { a.x += ax; a.y += ay; b.x -= ax; b.y -= ay; }
      else if (aFree) { a.x += ax * 2; a.y += ay * 2; }
      else if (bFree) { b.x -= ax * 2; b.y -= ay * 2; }
    }
  }
  if (dragging >= 0) { pts[dragging].x = dragX; pts[dragging].y = dragY; }

02Cloth tear

What you see

Yank a thread hard and only the links stretched past the tear multiple snap, leaving a hole that never heals. A reset button weaves a fresh cloth.

Cloth tear
Open in a new tab

02 · Cloth tear — Measured area 11.325% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

requestAnimationFramepointerdownpointermovetranslateY

Where to look in the code

The record shows 11.325% cumulative area at intensity 70.5 across 23 of 23 gaps.

  for (var k = 0; k < 3; k++) {
    for (var j = 0; j < links.length; j++) {
      var L = links[j];
      if (L.dead) continue;
      var a = pts[L.a], b = pts[L.b];
      var dx = b.x - a.x, dy = b.y - a.y;
      var d = Math.sqrt(dx * dx + dy * dy) || 0.0001;
      // tear check — a constraint stretched past tear times its target length snaps for good
      if (d > L.len * tear) {
        L.dead = true; torn++;
        ripVal.textContent = String(torn);
        continue;
      }
      var off = (d - L.len) / d * 0.5;
      var ax = dx * off, ay = dy * off;
      var aFree = !a.pin && L.a !== dragging;
      var bFree = !b.pin && L.b !== dragging;
      if (aFree && bFree) { a.x += ax; a.y += ay; b.x -= ax; b.y -= ay; }
      else if (aFree) { a.x += ax * 2; a.y += ay * 2; }
      else if (bFree) { b.x -= ax * 2; b.y -= ay * 2; }
    }

03Soft-body jelly

What you see

Press the jelly and the area you squeeze away comes back as extra outward pressure — release, and the rim wobbles its way to full volume again. It suits a poke-able mascot or a squishy like-button feedback.

Soft-body jelly
Open in a new tab

03 · Soft-body jelly — Measured area 6.381% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

requestAnimationFramepointerdowntranslateY

Where to look in the code

The frames measured 6.381% cumulative area at intensity 136.5 across 23 of 23 gaps.

  // (2) pressure — the area shortfall becomes a force along the rim normal, itself
  //     computed from the difference of the two neighboring points
  var A = area();
  var press = (AREA0 - A) / AREA0 * k * 7200 * dt * dt;
  for (var v = 0; v < N; v++) {
    var q = pts[v];
    var pr = pts[(v + N - 1) % N], nx2 = pts[(v + 1) % N];
    var tx = nx2.x - pr.x, ty = nx2.y - pr.y;
    var tl = Math.hypot(tx, ty) || 0.0001;
    // points are laid out counterclockwise (positive shoelace). In this loop the
    // outward normal is (ty, -tx) — flip the sign and the shortfall pushes inward,
    // and the jelly folds itself up
    q.nx = ty / tl; q.ny = -tx / tl;
    q.x += q.nx * press;
    q.y += q.ny * press;
  }

04Rolling collisions

What you see

Throw a shape — velocities swap along the collision normal and the off-center hit makes it roll away. A restitution slider sets how lively the bounce is.

Rolling collisions
Open in a new tab

04 · Rolling collisions — Measured area 6.933% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

requestAnimationFrametranslaterotate

Where to look in the code

The measurement is 6.933% cumulative area at intensity 157.5 across 23 of 23 gaps — the highest intensity of the nine.

function impulse(a, b, n, cx2, cy2, e) {
  var avx = a.x - a.px, avy = a.y - a.py;
  var bvx = b.x - b.px, bvy = b.y - b.py;
  var vRel = (avx - bvx) * n.x + (avy - bvy) * n.y;
  if (vRel > 0) return false;                 // already separating
  var j = -(1 + e) * vRel / (a.im + b.im);
  var jx = n.x * j, jy = n.y * j;
  a.px -= jx * a.im; a.py -= jy * a.im;       // forward impulse = velocity gain
  b.px += jx * b.im; b.py += jy * b.im;
  // torque — if the contact arm sits off center it becomes angular velocity (boxes only)
  if (a.kind === 'b') {
    var la = toLocal(a, cx2, cy2);
    a.pa -= (la.x * n.y - la.y * n.x) * j * a.im * 0.00013;
  }
  if (b.kind === 'b') {
    var lb = toLocal(b, cx2, cy2);
    b.pa += (lb.x * n.y - lb.y * n.x) * j * b.im * 0.00013;
  }
  return true;
}

05Ragdoll joints

What you see

Drag any joint and length constraints unravel in a chain — the whole figure folds and flops. A click-to-collapse character easter egg is the natural home for it.

Ragdoll joints
Open in a new tab

05 · Ragdoll joints — Measured area 11.719% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

requestAnimationFramepointerdownatan2

Where to look in the code

The measurement is 11.719% cumulative area at intensity 138.7 across 23 of 23 gaps.

function solveLink(lk, strength) {
  var a = pts[lk.a], b = pts[lk.b];
  var dx = b.x - a.x, dy = b.y - a.y;
  var d = Math.sqrt(dx * dx + dy * dy) || 0.0001;
  var target = d;
  if (d > lk.len) target = lk.len;
  else if (d < lk.min) target = lk.min;
  else return;                               // inside the band — leave it
  var off = (d - target) / d * 0.5 * strength;
  var ox = dx * off, oy = dy * off;
  var aFree = lk.a !== dragging, bFree = lk.b !== dragging;
  if (aFree && bFree) { a.x += ox; a.y += oy; b.x -= ox; b.y -= oy; }
  else if (aFree) { a.x += ox * 2; a.y += oy * 2; }
  else if (bFree) { b.x -= ox * 2; b.y -= oy * 2; }
}

06Buoyancy float

What you see

Toss an object in — buoyancy grows with the submerged area, and it floats where it balances gravity. Beneath the surface a heavier fluid drag bleeds off velocity.

Buoyancy float
Open in a new tab

06 · Buoyancy float — Measured area 9.128% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

requestAnimationFramepointermovetranslateY

Where to look in the code

The measurement is 9.128% cumulative area at intensity 88.1 across 23 of 23 gaps.

  var sinkSum = 0;
  for (var i = 0; i < bodies.length; i++) {
    var b = bodies[i];
    if (i === dragging) { b.x = dragX; b.y = dragY; continue; } // the hand holds it (px untouched → inertia on release)
    var bh = b.shape === 'c' ? b.r * 2 : b.h;
    var wl = surfaceY(b.x, t);
    var d = Math.max(0, Math.min(bh, b.y + half(b) - wl)); // submerged depth
    var full = b.shape === 'c' ? Math.PI * b.r * b.r : b.w * b.h;
    var sub = b.shape === 'c' ? capArea(b.r, d) : b.w * d;
    var ratio = strength * RHO * sub / (b.dens * full);     // buoyancy/gravity — 1 is equilibrium
    var inW = d > 0.5;
    var damp = inW ? FLUID : AIR;
    var slope = (surfaceY(b.x + 3, t) - surfaceY(b.x - 3, t)) / 6; // wave tilt → horizontal push
    var vx = (b.x - b.px) * damp, vy = (b.y - b.py) * damp;
    b.px = b.x; b.py = b.y;
    b.x += vx + G * Math.min(ratio, 1.4) * slope * 0.16 * dt * dt;
    b.y += vy + G * (1 - ratio) * dt * dt;

07Domino chain

What you see

Drag the first piece right — its falling rotation hands angular velocity to the next and the chain runs from there. A spacing slider widens the gaps between pieces.

Domino chain
Open in a new tab

07 · Domino chain — Measured area 12.262% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password

requestAnimationFramepointerdownrotate

Where to look in the code

The measurement is 12.262% cumulative area at intensity 93.4 across 11 of 23 gaps — the chain ends early and the remaining frames hold still.

  var crit = Math.atan2(HW, H2); // tilt at which the center of mass passes the pivot
  var rest = 1.485;              // nearly flat on the floor
  var fell = 0;
  for (var i = 0; i < N; i++) {
    var t = tiles[i];
    var angacc;
    if (t.a >= crit) {
      t.down = true;
      angacc = 2200 / H2 * Math.sin(t.a + 0.18); // toppling moment — grows as it goes
    } else {
      angacc = -2600 / H2 * Math.sin(crit - t.a); // restoring moment — stands back up
    }
    // angular Verlet: a += a - pa + angacc·dt²
    var aa = t.a;
    t.a = t.a + (t.a - t.pa) * 0.996 + angacc * dt * dt;
    t.pa = aa;
    if (t.a < 0) { t.a = 0; t.pa = t.a; }            // absorb floor bounce
    if (t.a > rest) { t.a = rest; t.pa = t.a - (t.pa - t.a) * 0.1; } // lies down with a small settle

08Wind flag

What you see

A point grid pinned only along the pole ripples in a phase-shifted sine wind. Rest the cursor on the board and that spot becomes a distance-faded gust source.

Wind flag
Open in a new tab

08 · Wind flag — Measured area 25.599% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

requestAnimationFrametranslateY

Where to look in the code

The measurement is 25.599% cumulative area at intensity 109.2 across 23 of 23 gaps — the most movement of the nine.

  // (1) integration — sine wind + cursor gust + gravity
  for (var r = 0; r < ROWS; r++) {
    for (var c = 0; c < COLS; c++) {
      var p = pt(r, c);
      if (p.pin) continue;
      var fall = 0.35 + 0.65 * (c / (COLS - 1)); // farther from the pole, more wind
      var fx = Math.sin(t * 2.2 + r * 0.9 + c * 0.35) * 520 * base * fall + 90 * base; // phase-modulated sine + weak steady current
      var fy = Math.sin(t * 1.6 + c * 0.5) * 140 * base * fall;
      if (gustOn) {
        var dx = p.x - gustX, dy = p.y - gustY;
        var d2 = Math.hypot(dx, dy);
        var g = 3200 * Math.exp(-d2 / 90); // distance-faded gust
        if (d2 > 0.5) { fx += dx / d2 * g; fy += dy / d2 * g * 0.35; }
      }
      var vx = (p.x - p.px) * DAMP, vy = (p.y - p.py) * DAMP;
      p.px = p.x; p.py = p.y;
      p.x += vx + fx * dt * dt;
      p.y += vy + (fy + GRAV) * dt * dt;
    }
  }

09Liquid droplet

What you see

Each particle reads its neighbor density and pushes back when packed — the drop clumps, splits, and clumps again like surface tension at work.

Liquid droplet
Open in a new tab

09 · Liquid droplet — Measured area 21.528% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

requestAnimationFramepointerdowntranslate

Where to look in the code

The measurement is 21.528% cumulative area at intensity 44.5 across 23 of 23 gaps — the particles spread evenly, so intensity reads low.

  // (2) local density — sum of the kernel w = 1 - r/h over neighbors within radius h
  //     O(N²), but N=120 means 14,400 pairs per frame — light enough
  var dens = new Float64Array(N);
  var nbr = [];               // neighbor lists (reused by pressure, viscosity, surface tension)
  for (var a = 0; a < N; a++) nbr.push([]);
  for (var a2 = 0; a2 < N; a2++) {
    for (var b = a2 + 1; b < N; b++) {
      var dx = ps[b].x - ps[a2].x, dy = ps[b].y - ps[a2].y;
      var d2 = dx * dx + dy * dy;
      if (d2 >= h2) continue;
      var d = Math.sqrt(d2) || 0.0001;
      var w = 1 - d / H;
      dens[a2] += w; dens[b] += w;
      nbr[a2].push(b); nbr[b].push(a2);
    }
  }
  var RHO0 = 5.5;             // rest density — what a stable droplet holds under this kernel (tuned)

Variations — changing one number

One number each, and the character of a board changes — three worth pulling out of the nine.

Name Value changed What it feels like
More constraint iterations 01 solver passes 3 → 8 The rope stretches less and reads taut
Higher restitution 04 restitution 0.5 → 0.85 Struck shapes keep rolling far longer
Stronger wind 08 wind coefficient 0.6 → 1.4 The flag's tail whips harder

Where it breaks — the trap

The sign of the jelly normal

The pressure in 03 pushes the area shortfall outward along the rim normal, and in a ring laid out counterclockwise that outward normal is (ty, -tx). Write the sign the other way and the missing area gets pushed inward, so the jelly folds itself up instead of wobbling back. The render fought the same class of bug — a gloss painted at a fixed screen position drifted out of place against the sunken body, so the highlight is now painted along the body's centroid and the outline goes on top of it. A mascot built the same way is drawn and timed in the mascot character animation set.

A domino chain that stops is correct

Board 07 showing movement in only 11 of 23 gaps is not a broken measurement — each piece receives the toppling moment only after its tilt crosses the critical angle, so when the chain ends the board is physically at rest. Widen the spacing multiple and the incoming angular velocity dies before it reaches the next piece, leaving the run stalled halfway. The recorded 12.262% area and 93.4 intensity include that stillness.

The archive holding the full source of all nine boards opens with the password zxsbjc6j and contains each board's index.html as-is.

One torque coefficient turns billiards into a tumbler

The rotation in 04 converts the contact arm's offset from center into angular velocity through a coefficient of 0.00013 — deliberately tiny. Raise it and every box accumulates spin on each hit until the scene stops reading as billiards and starts reading as gymnastics. Restitution and the torque coefficient have to move together or the hand feel falls apart. Spinning a whole scene rather than a shape is the job of the globe and orbit canvas set.

Accessibility (reduced-motion)

All nine boards carry role="application" with tabindex="0" on their interaction field. Under prefers-reduced-motion: reduce, the drag-driven boards 01, 02, 03, and 05 move only the grabbed point and let the constraints follow instantly with no wobble; boards 06 and 09 never start their automatic demo — 09 additionally listens for the change event, so a preference switched mid-session takes effect at once — and 08 stops the virtual gust it blows while no cursor is present. Boards 04 and 07 run their automatic demo on a repeating cycle, so it keeps running under reduce as well; on 07, though, a drag that carries the first piece past the critical angle skips the inertia and applies only the chain-starting velocity. The moment a hand touches any board, the automatic demo breaks off and the real pointer takes over. The criterion for how much motion to remove follows WCAG's animation from interactions.

FAQ

Do these verlet physics engine demos run with no library at all?

Yes. All nine boards run on Canvas 2D, requestAnimationFrame, Verlet integration, and constraint repetition — not a single CDN line or physics library in the files, so opening one runs it right away (see the requestAnimationFrame reference).

Does a verlet rope physics board behave the same at 120Hz as at 60Hz?

Every integration multiplies forces by the square of the time step, and that step is clamped at 0.033 seconds, so a rope or cloth stretches at the same rate regardless of the screen's refresh rate.

Can I drag and throw on a touch screen?

The boards use Pointer Events, one event set covering mouse, pen, and finger (see the pointer support table). Grabbing a rope or hurling a shape with a finger works exactly as with a mouse, and the underlying event properties are documented in Pointer events on MDN.

Enter the archive password

The password is inside this article. You will find it as you read.