Introduction
This sketch reads as a curtain of code hanging in mid-air, but there is no fabric asset and no physics engine underneath it — just a grid of points connected by distance constraints, rendered as the sketch’s own script text. The top row is pinned, gravity pulls the rest down, and a handful of relaxation passes per frame keep the mesh from stretching indefinitely, which is enough to look like cloth.
Three responsibilities stay separate the whole way through: the mesh only knows how to hold knots at roughly fixed distances from their neighbors, the sprite cache only knows how to turn a character into a bitmap once, and the pointer handler only knows how to nudge nearby knots outward. None of the three inspect each other’s internals.
How to build a Verlet-style cloth from plain position/previous-position pairs instead of a physics library — how to pre-rasterize glyphs into an offscreen canvas so the render loop never calls fillText — how to orient each glyph to the local thread angle so the curtain reads as woven rather than tiled — and how to read a script’s own text via a DOM reference so the curtain is always literally made of its own code.
Step 1 — A Grid of Points and Distance Constraints
Each knot stores only its current position and its previous position — no explicit velocity field. Subtracting the two and reapplying the difference each frame is enough to carry momentum forward, which is the entire trick behind Verlet integration. A Thread just measures the rest distance between two knots once, then nudges both points toward that distance every relaxation pass, clamped between a floor and ceiling ratio so vertical threads barely compress but horizontal ones can bunch up like real fabric.
class Knot {
advance(dtSq, pull) {
if (this.anchored) { this.pushX = this.pushY = 0; return; }
const vx = (this.x - this.lastX) * Loom.FRICTION;
const vy = (this.y - this.lastY) * Loom.FRICTION;
this.lastX = this.x; this.lastY = this.y;
this.x += vx + this.pushX * dtSq;
this.y += vy + (this.pushY + pull) * dtSq;
this.pushX = this.pushY = 0;
}
}
class Thread {
tighten() {
const dx = this.end.x - this.start.x, dy = this.end.y - this.start.y;
const span = Math.hypot(dx, dy) || 0.0001;
let goal = this.rest;
if (span < this.floor) goal = this.floor;
else if (span > this.ceil) goal = this.ceil;
else return;
const shift = (goal - span) / span / 2;
if (!this.start.anchored) { this.start.x -= dx * shift; this.start.y -= dy * shift; }
if (!this.end.anchored) { this.end.x += dx * shift; this.end.y += dy * shift; }
}
}Only the top row of knots is marked anchored, which is what gives the mesh its curtain-like silhouette — everything below it is free to fall and only stays roughly grid-shaped because of the threads pulling on it.
Step 2 — One Bitmap per Character, Not per Knot
With a few hundred knots on screen, calling fillText that many times every frame would be wasteful, since most knots share the same handful of characters. Instead, a Sprite is rasterized once per distinct character into its own small offscreen canvas, and every knot just stores a reference to the sprite it needs. The render loop only ever calls drawImage, which is far cheaper than re-drawing text repeatedly.
class Sprite {
constructor(character, pixelSize, pixelRatio) {
const plate = document.createElement('canvas');
plate.width = plate.height = Math.ceil(pixelSize * 1.4) * pixelRatio;
const brush = plate.getContext('2d');
brush.scale(pixelRatio, pixelRatio);
brush.font = `700 ${pixelSize}px "Space Mono", monospace`;
brush.textAlign = 'center';
brush.textBaseline = 'middle';
brush.fillText(character, plate.width / (2 * pixelRatio), plate.height / (2 * pixelRatio));
this.bitmap = plate;
}
}The sprite cache doesn’t know it’s drawing code — it just knows how to turn one character into one bitmap, once.
Step 3 — Advance, Then Relax, Then Paint
Each frame runs three passes in strict order: every knot advances under gravity and friction, then several relaxation passes tighten every thread back toward its rest length, and only then does the mesh get painted. Doing relaxation multiple times per frame — five passes here — is what keeps the cloth from stretching into spaghetti; a single pass per frame is visibly springier and less fabric-like.
frame(time) {
requestAnimationFrame(next => this.frame(next));
const dt = this.prevTime ? Math.min(2, (time - this.prevTime) / 16.7) : 1;
this.prevTime = time;
const dtSq = dt * dt;
for (const k of this.knots) k.advance(dtSq, Loom.PULL);
for (let i = 0; i < Loom.RELAX_PASSES; i++) {
for (const t of this.threads) t.tighten();
}
this.paint();
}Every knot (except the bottom row) owns a reference to the thread running below it. When painting, the angle of that thread is measured with atan2 and used to rotate the glyph before drawing it, so each character visually follows the local fold of the cloth instead of floating upright over a distorted mesh.
Step 4 — A Radial Push, Not a Pick-Up
Rather than tracking which single knot the pointer has grabbed, every pointermove event checks every knot against a fixed reach radius and nudges the ones inside it outward, weighted by how close they are to the pointer. This avoids any grab/release state machine entirely — the cursor is just a continuous source of outward force while it moves.
disturb() {
const REACH_SQ = 5000, FORCE = 4;
for (const k of this.knots) {
const dx = k.x - this.cursor.x, dy = k.y - this.cursor.y;
const distSq = dx * dx + dy * dy;
if (distSq >= REACH_SQ) continue;
const falloff = 1 - distSq / REACH_SQ;
const dist = Math.sqrt(distSq) || 0.0001;
k.nudge((dx / dist) * falloff * FORCE, (dy / dist) * falloff * FORCE);
}
}Tuning Reference
| Property | Example | Effect |
|---|---|---|
| grid columns / rows | 36 / 30 | Density of the weave; more knots read as finer cloth, fewer as a coarser mesh |
| FRICTION | 0.99 | How much momentum survives each frame; lower settles the cloth faster |
| PULL (gravity) | 0.2 | How heavily the curtain sags; higher pulls the bottom edge down faster |
| RELAX_PASSES | 5 | How rigid the cloth feels; more passes resist stretching harder |
| thread floor/ceil ratio | 0.02–1.1 (vertical), 0.6–4 (horizontal) | How much a thread may compress or stretch before it’s pulled back |
| pointer REACH_SQ / FORCE | 5000 / 4 | Size and strength of the ripple the cursor produces when it moves through the cloth |
Full Source Code
The complete sketch is a single self-contained HTML document — the knot/thread mesh, the glyph sprite cache, the integrate-then-relax simulation loop, and the pointer-driven disturbance, with no build step or external physics library. The curtain reads its own text straight from the enclosing <script> tag, so it is always literally woven from the code that produces it.
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/code-weave.html