Verlet rope
x' = 2x − xₚᵣₑᵥ + a·dt²
How it works
Velocity you never store
Each point keeps its current position and its previous one, and nothing else. Velocity is the gap between them, so a step is x' = 2x − xₚᵣₑᵥ + a·dt² — take the last displacement, apply it again, and add acceleration.
The consequence is the whole reason to use it: anything that moves a point also changes its implied velocity. Dragging a link and letting go throws the rope, with no code for throwing anywhere in the piece. Damping is a single multiplier on the implied displacement.
Constraints, not forces
A spring between each pair of points would need a stiffness constant, and a stiff spring plus a large timestep explodes. This uses position-based dynamics instead: move the points first, ignoring the rope entirely, then repair the damage by shoving every neighbouring pair back to the rest length — half the error each, so momentum stays put.
Fixing one pair disturbs the pair next to it, so the repair is repeated. That is Gauss–Seidel relaxation, and it converges: 14 passes per frame here reads as rope, two would read as elastic, and stiffness becomes an iteration count rather than a number that can blow up. Pinned points simply refuse to move, and the correction is absorbed entirely by their neighbour.
Because corrections are applied to positions directly and velocity is derived from positions, the solver cannot inject energy the way a force-based integrator can. The rope is unconditionally stable at any iteration count.
Grabbing
Pointer-down picks the nearest unpinned point within 46px and pins it to the cursor for as long as you hold it, updating its previous position each step so the implied velocity tracks your hand. Release, and that stored velocity is what the rope flies away with.
Bounds are enforced in the same pass as the constraints, by clamping any point that leaves the box. Clamping a position rather than reflecting a velocity means a rope pressed into the floor slides along it and buckles, which is roughly what a rope does.
Source
'use client';
import { useRef } from 'react';import { useCanvasCraft, useCraftTheme } from '../useCraft';
const GRAVITY = 900;const SEGMENT = 11; // rest length between neighbouring points, pxconst ITERATIONS = 14; // constraint relaxation passes per frameconst DAMPING = 0.995; // velocity retained per step, via the Verlet historyconst GRAB_RADIUS = 46;
const ROPES = [ { anchor: 0.18, length: 20, colour: '#ff6600' }, { anchor: 0.5, length: 27, colour: '#00d4ff' }, { anchor: 0.82, length: 22, colour: '#c5ff3b' },];
interface Point { x: number; y: number; px: number; py: number; pinned: boolean }interface Rope { points: Point[]; colour: string }
interface State { ropes: Rope[]; w: number; h: number; /** Index of the point currently held, if any. */ held: { rope: number; point: number } | null;}
export default function VerletRope() { const theme = useCraftTheme(); const state = useRef<State>({ ropes: [], w: 0, h: 0, held: null });
const seed = (w: number, h: number) => { state.current = { w, h, held: null, ropes: ROPES.map(spec => { const x = w * spec.anchor; const count = Math.max(8, Math.min(spec.length, Math.floor((h * 0.82) / SEGMENT))); const points: Point[] = []; for (let i = 0; i < count; i++) { const y = 6 + i * SEGMENT; points.push({ x, y, px: x, py: y, pinned: i === 0 }); } return { points, colour: spec.colour }; }), }; };
const canvasRef = useCanvasCraft(({ ctx, w, h, dt, pointer }) => { const s = state.current; if (!s.ropes.length) return;
// A held point is teleported to the cursor each step; the constraint solver // drags the rest of the chain along behind it. if (s.held && pointer.inside) { const p = s.ropes[s.held.rope]?.points[s.held.point]; if (p) { p.px = p.x; p.py = p.y; p.x = pointer.x; p.y = pointer.y; } } else if (!pointer.inside) { s.held = null; }
const step = Math.min(dt, 1 / 60); for (const rope of s.ropes) { integrate(rope, step); for (let k = 0; k < ITERATIONS; k++) relax(rope, w, h); }
ctx.clearRect(0, 0, w, h);
// Faint anchor rail across the top, so the ropes read as hanging from something. ctx.strokeStyle = theme === 'dark' ? 'rgba(240,240,240,0.16)' : 'rgba(20,20,20,0.14)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(0, 6.5); ctx.lineTo(w, 6.5); ctx.stroke();
for (const rope of s.ropes) drawRope(ctx, rope); }, seed);
const grab = (e: React.PointerEvent<HTMLCanvasElement>) => { const s = state.current; const rect = e.currentTarget.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top;
let best = GRAB_RADIUS; let found: State['held'] = null; s.ropes.forEach((rope, ri) => { rope.points.forEach((p, pi) => { if (p.pinned) return; const d = Math.hypot(p.x - x, p.y - y); if (d < best) { best = d; found = { rope: ri, point: pi }; } }); });
s.held = found; if (found) e.currentTarget.setPointerCapture(e.pointerId); };
const release = () => { state.current.held = null; };
return ( <canvas ref={canvasRef} onPointerDown={grab} onPointerUp={release} onPointerCancel={release} style={{ width: '100%', height: '100%', display: 'block', cursor: 'grab' }} /> );}
/* ── Position-based dynamics ─────────────────────────────────────────────── */
/** * Verlet integration. Velocity is never stored — it is implied by the gap * between this position and the last one, so anything that moves a point also * changes its velocity for free. That is what makes dragging feel physical. */function integrate(rope: Rope, dt: number) { for (const p of rope.points) { if (p.pinned) continue; const vx = (p.x - p.px) * DAMPING; const vy = (p.y - p.py) * DAMPING; p.px = p.x; p.py = p.y; p.x += vx; p.y += vy + GRAVITY * dt * dt; }}
/** * One relaxation pass: pull every neighbouring pair back to the rest length by * moving both ends half the error, then clamp to the box. Repeating this is * Gauss–Seidel — each pass fixes one constraint and slightly breaks the last, * but it converges, and a stiff rope is just more iterations. */function relax(rope: Rope, w: number, h: number) { const pts = rope.points;
for (let i = 0; i < pts.length - 1; i++) { const a = pts[i]; const b = pts[i + 1]; const dx = b.x - a.x; const dy = b.y - a.y; const d = Math.hypot(dx, dy) || 0.0001; const correction = (d - SEGMENT) / d / 2; const cx = dx * correction; const cy = dy * correction;
if (!a.pinned) { a.x += cx; a.y += cy; } if (!b.pinned) { b.x -= cx; b.y -= cy; } }
for (const p of pts) { if (p.pinned) continue; if (p.x < 2) p.x = 2; if (p.x > w - 2) p.x = w - 2; if (p.y > h - 2) p.y = h - 2; if (p.y < 2) p.y = 2; }}
function drawRope(ctx: CanvasRenderingContext2D, rope: Rope) { const pts = rope.points;
// Quadratic curves through the midpoints smooth the chain without adding points. ctx.strokeStyle = rope.colour; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.lineWidth = 2.4; ctx.beginPath(); ctx.moveTo(pts[0].x, pts[0].y); for (let i = 1; i < pts.length - 1; i++) { const mx = (pts[i].x + pts[i + 1].x) / 2; const my = (pts[i].y + pts[i + 1].y) / 2; ctx.quadraticCurveTo(pts[i].x, pts[i].y, mx, my); } ctx.lineTo(pts[pts.length - 1].x, pts[pts.length - 1].y); ctx.stroke();
const tip = pts[pts.length - 1]; ctx.fillStyle = rope.colour; ctx.beginPath(); ctx.arc(tip.x, tip.y, 4.5, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(pts[0].x, pts[0].y, 2.5, 0, Math.PI * 2); ctx.fill();}The shared loop every craft runs on, plus the noise and dither helpers.
'use client';
import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from 'react';
/** Decorative previews don't need every frame; the detail pages get the lot. */const PREVIEW_FPS = 30;
/** * Counts capped loops so each can be given a different phase. Without this they * all skip the same frames and compute on the same ones, so the index page does * nothing for one frame and every simulation at once on the next — which shows * up as a dropped frame rather than as evenly spread work. */let phaseCounter = 0;const PHASES = 3;
/** * rAF loop that only runs while the target is on screen and the tab is visible. * Each craft runs its own, so several visible at once is several animations' * worth of work — which is why the grid previews are capped: on the index a * handful of simulations compete for one main thread, and at a glance nobody can * tell 30 frames from 60. */export function useVisibleRaf( targetRef: RefObject<HTMLElement | null>, onFrame: (t: number, dt: number) => void,) { const cb = useRef(onFrame); cb.current = onFrame;
useEffect(() => { const el = targetRef.current; if (!el) return;
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; // The class that marks a stage as a thumbnail rather than the main event. const isPreview = el.closest('.craft-stage-preview') !== null; // A frame of slack, so a 30fps cap doesn't skip to 20 by missing a vsync. const minGap = isPreview ? 1000 / PREVIEW_FPS - 4 : 0; const phase = minGap ? ((phaseCounter++ % PHASES) * minGap) / PHASES : 0;
let raf = 0; let running = false; let visible = false; const start = performance.now(); let last = start - phase;
const loop = (now: number) => { raf = requestAnimationFrame(loop); if (now - last < minGap) return; const dt = Math.min((now - last) / 1000, 0.05); last = now; cb.current((now - start) / 1000, dt); };
const play = () => { if (running || !visible || document.hidden) return; running = true; // Keep this loop's phase on resume, or scrolling would resync them all. last = performance.now() - phase; raf = requestAnimationFrame(loop); };
const pause = () => { running = false; cancelAnimationFrame(raf); };
const io = new IntersectionObserver( ([entry]) => { visible = entry.isIntersecting; if (visible) play(); else pause(); }, { threshold: 0.05 }, ); io.observe(el);
const onVis = () => (document.hidden ? pause() : play()); document.addEventListener('visibilitychange', onVis);
// Reduced motion: paint one frame, then stop. if (reduced) { cb.current(0, 0); io.disconnect(); document.removeEventListener('visibilitychange', onVis); return; }
return () => { pause(); io.disconnect(); document.removeEventListener('visibilitychange', onVis); }; }, [targetRef]);}
export interface CraftFrame { ctx: CanvasRenderingContext2D; w: number; h: number; t: number; dt: number; pointer: { x: number; y: number; inside: boolean };}
/** * Canvas sized to its CSS box with DPR scaling, driven by useVisibleRaf. * `onInit` fires once per resize — use it to seed grids and particles. */export function useCanvasCraft( onFrame: (f: CraftFrame) => void, onInit?: (w: number, h: number) => void,) { const canvasRef = useRef<HTMLCanvasElement>(null); const box = useRef({ w: 0, h: 0 }); const pointer = useRef({ x: -9999, y: -9999, inside: false }); const frame = useRef(onFrame); const init = useRef(onInit); frame.current = onFrame; init.current = onInit;
useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return;
const resize = () => { const rect = canvas.getBoundingClientRect(); if (!rect.width || !rect.height) return; const dpr = Math.min(window.devicePixelRatio || 1, 2); box.current = { w: rect.width, h: rect.height }; canvas.width = Math.round(rect.width * dpr); canvas.height = Math.round(rect.height * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); init.current?.(rect.width, rect.height); };
resize(); const ro = new ResizeObserver(resize); ro.observe(canvas);
/* * Touch has no hover: pointermove only arrives while a finger is down, and * the browser stops sending it the moment it decides the gesture is a * scroll. So the position is also taken on pointerdown — otherwise a tap, * or a grab that hasn't moved yet, reads as no pointer at all. */ const at = (e: PointerEvent) => { const r = canvas.getBoundingClientRect(); pointer.current = { x: e.clientX - r.left, y: e.clientY - r.top, inside: true }; };
const down = (e: PointerEvent) => { at(e); // Keeps a drag that wanders off the canvas mid-gesture still tracking. try { canvas.setPointerCapture(e.pointerId); } catch { // Pointer already gone; nothing to capture. } };
const up = (e: PointerEvent) => { // A lifted finger stops existing, where a clicked mouse is still hovering. if (e.pointerType !== 'mouse') pointer.current.inside = false; };
const leave = () => { pointer.current.inside = false; };
canvas.addEventListener('pointerdown', down); canvas.addEventListener('pointermove', at); canvas.addEventListener('pointerup', up); canvas.addEventListener('pointercancel', leave); canvas.addEventListener('pointerleave', leave);
return () => { ro.disconnect(); canvas.removeEventListener('pointerdown', down); canvas.removeEventListener('pointermove', at); canvas.removeEventListener('pointerup', up); canvas.removeEventListener('pointercancel', leave); canvas.removeEventListener('pointerleave', leave); }; }, []);
useVisibleRaf(canvasRef, (t, dt) => { const canvas = canvasRef.current; const ctx = canvas?.getContext('2d'); if (!ctx) return; frame.current({ ctx, w: box.current.w, h: box.current.h, t, dt, pointer: pointer.current }); });
return canvasRef;}
/** Current theme, kept in sync with the dock's toggle. Starts 'light' so SSR matches. */export function useCraftTheme(): 'light' | 'dark' { const [theme, setTheme] = useState<'light' | 'dark'>('light');
useLayoutEffect(() => { const el = document.documentElement; const read = () => setTheme((el.dataset.theme as 'light' | 'dark') ?? 'light'); read(); const mo = new MutationObserver(read); mo.observe(el, { attributes: true, attributeFilter: ['data-theme'] }); return () => mo.disconnect(); }, []);
return theme;}
/** Seeded PRNG, so anything generated looks the same on every visit. */export function mulberry32(seed: number) { return () => { seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };}
/** Ordered 4×4 Bayer matrix, normalised to 0..1. Shared by the dithered pieces. */export const BAYER4 = [ 0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5,].map(v => (v + 0.5) / 16);
/** Cheap hash-based value noise — enough character for a flow field, no lookup tables. */export function noise2(x: number, y: number): number { const xi = Math.floor(x); const yi = Math.floor(y); const xf = x - xi; const yf = y - yi; const u = xf * xf * (3 - 2 * xf); const v = yf * yf * (3 - 2 * yf);
const h = (a: number, b: number) => { let n = Math.imul(a, 374761393) + Math.imul(b, 668265263); n = Math.imul(n ^ (n >>> 13), 1274126177); return ((n ^ (n >>> 16)) >>> 0) / 4294967295; };
return ( h(xi, yi) * (1 - u) * (1 - v) + h(xi + 1, yi) * u * (1 - v) + h(xi, yi + 1) * (1 - u) * v + h(xi + 1, yi + 1) * u * v );}