Aduok Code

Letter Strings, Built from a Canvas Silhouette, a Verlet Rope Per Thread, and One Shared Drag State

Introduction

This piece reads as a cluster of code characters with a few loose threads hanging off it, swaying and swinging when you touch them — but underneath, it is one array of characters split two ways: most of them are stamped onto a grid wherever a hidden canvas shape says 'filled,' and the rest are handed one at a time to independent rope simulations. Nothing here uses a physics library. Each thread is a short chain of points updated with Verlet integration, and the only shared state across all five threads is which single point, on which single thread, the pointer currently has hold of.

The illusion depends on three things staying decoupled: the silhouette is computed once, up front, on an offscreen canvas that is never drawn to the page — it only answers 'is this pixel inside the shape?' for each grid cell. The rope threads never know about the silhouette at all; they just start at an anchor point on its edge and fall under gravity. And the drag logic never touches physics directly — it only ever relocates one point per frame and lets the constraint solver figure out what the rest of the thread should do about it.

What you will learn

How to fill an arbitrary shape with characters by sampling a hidden canvas at grid points instead of hand-placing text — how Verlet integration lets a chain of points fall and swing without ever storing a velocity — how iterative distance constraints keep a rope's segment lengths honest after gravity or a drag has pulled them apart — how to hit-test a pointer against many independent chains at once and pin exactly one point while it's held — and how a single prefers-reduced-motion query can freeze a physics simulation into a readable static pose.

01
Part One
A Filled Silhouette From a Canvas Mask

Step 1 — Asking a Hidden Canvas Which Cells Are Inside

The blob shape is never drawn as an SVG path or a background image — it is drawn once onto an offscreen <canvas> that's never appended to the DOM, using two overlapping ellipses. Once that canvas has been filled, getImageData() reads back a flat array of pixel alpha values, and insideMask(x, y) becomes a single lookup: round the coordinate, find its index in that array, and check whether the alpha channel is above a threshold. Everything downstream treats that function as a boolean shape test and never touches the canvas again.

JS
const maskCanvas = document.createElement('canvas');
maskCanvas.width = W; maskCanvas.height = H;
const mctx = maskCanvas.getContext('2d');

mctx.fillStyle = '#000';
mctx.beginPath();
mctx.ellipse(cx, cy, R, R * 0.9, 0, 0, Math.PI * 2);
mctx.fill();
mctx.beginPath();
mctx.ellipse(cx - R * 0.25, cy + R * 0.55, R * 0.6, R * 0.4, 0.3, 0, Math.PI * 2);
mctx.fill();

const maskData = mctx.getImageData(0, 0, W, H).data;
function insideMask(x, y) {
  x = Math.round(x); y = Math.round(y);
  if (x < 0 || y < 0 || x >= W || y >= H) return false;
  const idx = (y * W + x) * 4 + 3;   // alpha channel
  return maskData[idx] > 100;
}

Filling the shape is then a plain nested loop over the silhouette's bounding box, spaced by the monospace character's width and line height. Every cell that passes insideMask() gets the next character pulled from a shared, stripped-of-whitespace source string, plus a small seeded jitter on its position and a small baked-in rotation, so the block reads as hand-set type rather than a perfect grid.

JS
for (let gy = cy - R * 1.05; gy < cy + R * 1.05; gy += LINE_H) {
  for (let gx = cx - R * 1.1; gx < cx + R * 1.1; gx += CHAR_W) {
    if (!insideMask(gx, gy)) continue;
    const jitterX = (rand() - 0.5) * 1.4;
    const jitterY = (rand() - 0.5) * 1.4;
    const span = document.createElement('span');
    span.textContent = nextChar();
    span.style.left = (gx + jitterX) + 'px';
    span.style.top  = (gy + jitterY) + 'px';
    stage.appendChild(span);
    blobChars.push({ el: span, hx: gx + jitterX, hy: gy + jitterY, baseRot: (rand() - 0.5) * 10 });
  }
}
02
Part Two
Turning Loose Characters Into a Rope

Step 2 — Verlet Integration: Position Remembers Velocity

Each thread is an array of nodes, and every node stores only two positions — its current x/y and its previous x/y from last frame — instead of a separate velocity field. Velocity is recovered each frame as the difference between the two, scaled by a damping factor, and then applied forward along with gravity. This is standard Verlet integration: it is numerically stable under the iterative corrections a rope needs, and it means a drag can simply overwrite a node's position without having to also compute or fake a matching velocity.

JS
for (let i = 1; i < nodes.length; i++) {
  const n = nodes[i];
  const vx = (n.x - n.oldx) * DAMPING;
  const vy = (n.y - n.oldy) * DAMPING;
  n.oldx = n.x; n.oldy = n.y;
  n.x += vx;
  n.y += vy + GRAVITY;
}

A Verlet node never asks 'how fast am I moving?' — it only ever asks 'where was I last frame?' That single substitution is what lets a drag, a gust of mouse-proximity wind, and gravity all push on the same point without three separate systems needing to agree on a velocity.

03
Part Three
Keeping Segments Together

Step 3 — Iterative Distance Constraints

After gravity moves every node independently, neighboring nodes are almost never still the correct segment length apart — so a constraint pass runs six times per frame, and each pass nudges every adjacent pair halfway back toward the target distance. Running it multiple times per frame, rather than solving it exactly once, is what makes a whole chain of thirty-plus nodes settle into a convincing rope curve instead of a jittery zig-zag; each pass only removes part of the error, and the errors shrink geometrically across iterations.

JS
for (let iter = 0; iter < ITER; iter++) {
  for (let i = 0; i < nodes.length - 1; i++) {
    const a = nodes[i], b = nodes[i + 1];
    const dx = b.x - a.x, dy = b.y - a.y;
    const dist = Math.hypot(dx, dy) || 0.0001;
    const diff = (dist - SEG) / dist;
    const offX = dx * 0.5 * diff, offY = dy * 0.5 * diff;
    if (!a.pinned) { a.x += offX; a.y += offY; }
    if (!b.pinned) { b.x -= offX; b.y -= offY; }
  }
  nodes[0].x = nodes[0].anchorX;   // the anchor is re-pinned every pass
  nodes[0].y = nodes[0].anchorY;
}
Why re-pin the anchor every iteration

Six constraint passes can drift the very first node by a fraction of a pixel each time gravity and neighboring corrections interact. Snapping node zero back to its exact anchor coordinate at the end of every single pass — not just once per frame — is what keeps thirty nodes of accumulated correction from slowly walking the whole thread's starting point off its silhouette edge.

04
Part Four
Grabbing a Thread

Step 4 — One Pointer, Many Chains, One Pinned Node

On pointerdown, nearestNode() walks every non-anchor node on every thread and returns whichever one is both closest to the pointer and inside a fixed grab radius. That single (string index, node index) pair becomes the only piece of drag state in the whole system. While it's set, the simulation and constraint loops both special-case that one node — skipping its gravity update and excluding it from constraint correction — and instead just assign it directly to the pointer's position every frame, letting the rest of the chain react to that motion exactly as it would to any other perturbation.

JS
function nearestNode(p) {
  let bestS = -1, bestI = -1, bestD = GRAB_R;
  for (let s = 0; s < strings.length; s++) {
    const nodes = strings[s].nodes;
    for (let i = 1; i < nodes.length; i++) {
      const d = Math.hypot(nodes[i].x - p.x, nodes[i].y - p.y);
      if (d < bestD) { bestD = d; bestS = s; bestI = i; }
    }
  }
  return { s: bestS, i: bestI };
}

function onMove(e) {
  const p = localPoint(e);
  if (dragIndex > 0) {
    const n = strings[dragString].nodes[dragIndex];
    n.x = p.x; n.y = p.y;    // pinned to the pointer, not the physics loop
  }
}

Releasing the pointer just resets dragString and dragIndex to -1. Nothing has to explicitly hand the node a throw velocity — because its oldx/oldy from the frames spent following the pointer are still sitting there, the very next gravity update reads a large position delta as velocity automatically, and the thread flies off exactly as fast as the pointer was moving when it let go.

05
Part Five
Ambient Sway and Reduced Motion

Step 5 — Keeping It Alive at Rest, and Turning It Off Cleanly

Every thread is seeded with its own phase and frequency, and each frame adds a small sine-based 'breeze' force scaled by how far a node sits down the chain — nodes near the anchor barely move, tips sway the most. Because each thread's phase and frequency are drawn from an independent random seed, the five threads never fall into sync, which is what keeps the piece from looking looped even with no pointer on the page at all. A single prefers-reduced-motion check bypasses the animation loop entirely and renders one static frame instead.

JS
const breeze = Math.sin(t * st.freq + st.phase) * st.amp;
n.x += vx + breeze * (i / nodes.length) * 0.06;
n.y += vy + GRAVITY;

// ...

if (reduceMotion) {
  renderRopes();               // one static layout pass, no rAF loop
} else {
  requestAnimationFrame(frame);
}

Tuning Reference

PropertyExampleEffect
SEG13pxRest length of each rope segment; larger values spread nodes further apart per character
GRAVITY0.55Downward acceleration applied to every unpinned node each frame
DAMPING0.985Fraction of implied velocity kept per frame; lower values settle threads faster
ITER6Constraint-solver passes per frame; more passes yield a stiffer, less stretchy rope
GRAB_R24pxPointer hit-test radius used to pick which node a drag latches onto
anchorAngles / ropeLensper threadWhere each thread leaves the silhouette, and how many nodes long it is

Full Source Code

The complete piece is a single self-contained HTML document — the mask canvas, the silhouette fill loop, five independent rope threads, the shared drag state, and the reduced-motion fallback, and nothing else. No build step, no external assets, no physics library.

HTML — letter-strings.html (complete)
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/letter-strings.html
Want to try it yourself?Open the live Letter Strings file