research brief · fed to the builders

The ZeSign craft

The cloth physics, the pointer field, text through the cloth, the phantom mark, the steganography module and Spare's sigil construction.

1. The cloth physics model

This is a Verlet-integrated mass-spring grid (position-based dynamics, not force-integration with explicit velocity state). Standard game-physics technique (Thomas Jakobsen, "Advanced Character Physics," 2001 — the classic cloth-sim reference; same lineage as every browser cloth demo since).

Grid: GRID_W=88 × GRID_H=40 particles, spaced CELL_W = AWIDTH/(GRID_W-1), CELL_H = AHEIGHT/(GRID_H-1) apart over an 1080×480 area. Each particle is one character cell.

Particle state — no explicit velocity vector is trusted; velocity is inferred each step from the position delta (this is Verlet's whole trick: implicit velocity = free damping, unconditionally stable, no exploding integrators):

// particle: {pos:{x,y}, oldPos:{x,y}, acc:{x,y}, pinned, char}
function integrate(p, dt) {
  if (p.pinned) { p.acc = {x:0,y:0}; return; }
  const vx = (p.pos.x - p.oldPos.x) * DAMPING;   // DAMPING = 0.99
  const vy = (p.pos.y - p.oldPos.y) * DAMPING;
  p.oldPos = { x: p.pos.x, y: p.pos.y };
  const dd = dt*dt;
  const gy = GRAVITY / dd;                        // GRAVITY = 0.18, dt-normalized so gravity feels frame-rate independent
  p.pos.x += vx + p.acc.x*dd;
  p.pos.y += vy + (p.acc.y + gy)*dd;
  p.acc = {x:0, y:0};
}

Note the GRAVITY/dd and acc*dd pattern — this is deliberately scaling constant acceleration by dt² so the sim is dt-aware despite Verlet not carrying an explicit velocity term. dt is clamped Math.min(33, tNow-lastT) to prevent a tab-switch stall from launching the cloth.

Constraints (distance constraints, solved via Gauss-Seidel relaxation, ITER=10 passes/frame — more iterations = stiffer cloth, this is the classic PBD trade of stiffness for iteration count):

function solve(con) {
  const dx = con.p2.pos.x - con.p1.pos.x, dy = con.p2.pos.y - con.p1.pos.y;
  const d = Math.hypot(dx, dy);
  if (d === 0) return;
  let target = con.length;
  if (d < con.min) target = con.min;
  else if (d > con.max) target = con.max;
  else return;                                    // inside the slack band → no correction (this is the "cloth" softness)
  const pct = (target - d)/d * 0.5;
  const ox = dx*pct, oy = dy*pct;
  if (!con.p1.pinned) { con.p1.pos.x -= ox; con.p1.pos.y -= oy; }
  if (!con.p2.pinned) { con.p2.pos.x += ox; con.p2.pos.y += oy; }
}

Key insight for a builder: this is not a rigid-rod constraint (d !== target always corrects) — it's a slack band. Below min or above max it snaps back; inside the band it's free. That's what gives the drape its "loose cloth" rather than "rigid mesh" character, and it's cheap (early-return skips most pairs most frames).

Top row (j===0) is pinned and its x is force-reset to pinBaseX[i] every frame — this cloth never blows in wind; it hangs dead still until touched.

2. Pointer interaction (radial repulsion field)

const dx = mouseX - p.pos.x, dy = mouseY - p.pos.y;
const ls = dx*dx + dy*dy;                         // squared dist, avoid sqrt in the gate
if (ls < MOUSE_SIZE && ls > 1) {                  // MOUSE_SIZE = 6000 (px² radius)
  const a = Math.atan2(dy, dx) - Math.PI;         // point AWAY from cursor
  const str = smoothstep(MOUSE_SIZE, -2000, ls) * MOUSE_STRENGTH / 300;  // MOUSE_STRENGTH = 4.2
  p.acc.x += Math.cos(a)*str;
  p.acc.y += Math.sin(a)*str;
}

smoothstep(6000, -2000, ls) is an inverted falloff curve (edge0 > edge1) — force is near-zero at the boundary, ramps smoothly to max near center, no hard cutoff or discontinuity. There's also a grab: pointerdown within dist²<400 of a particle pins it to the pointer for direct dragging (temporarily overriding pinned), released on pointerup/cancel/blur. This is a textbook example of "the pointer perturbs, never drives" — remove all pointer listeners and the cloth still integrates, settles, and renders correctly; it just never gets touched.

3. Rendering text through the cloth (the atlas trick)

Each glyph is pre-rendered once to an offscreen canvas at build time — never fillText per-frame per-particle (would be catastrophic: 88×40=3520 fillText calls/frame). Instead:

  1. Build a Set of unique characters in the source string.
  2. For each character, render N discrete energy levels into stamps (this repo uses 3 levels + 1 cyan-core variant): a chromatic-split RGB stamp (red offset -0.5px, cyan offset +0.5px, white center, globalCompositeOperation='lighter' so the offsets add light rather than occlude) at increasing opacity/blur per level.
  3. Per particle per frame: pick the level from an "energy" scalar (p.energy = p.energy*0.92 + speed*0.18, an EMA of local motion speed) — still particles render faint/thin, disturbed particles render bright/bold. This is how "the cloth glows where touched" without any explicit lighting model — it's literally velocity-as-brightness.
  4. Draw the pre-rendered stamp with a rotation matrix derived from the local downCon edge direction — each glyph tilts to match the cloth's local slope, which is what sells "printed on fabric" rather than "grid of independent sprites":
let cos=1, sin=0;
if (p.downCon) {
  const dx = p.downCon.p2.pos.x - p.downCon.p1.pos.x;
  const dy = p.downCon.p2.pos.y - p.downCon.p1.pos.y;
  const ang = Math.atan2(dy, dx) - Math.PI/2;
  cos = Math.cos(ang); sin = Math.sin(ang);
}
ctx.setTransform(cos, sin, -sin, cos, (p.pos.x+ox)*dpr, (p.pos.y+oy)*dpr);
ctx.scale(dpr, dpr);
ctx.drawImage(stamp, -half, -half, size, size);

setTransform (absolute, not save/rotate/restore) avoids a matrix-stack push/pop per glyph — meaningfully cheaper at 3500 draws/frame.

Canvas is cleared with clearRect, never filled — the transparency is what lets the phantom mark and background shader show through the gaps between glyphs. This is fundamental to the two-layer look and trivially portable.

4. The phantom mark — breathe / drift / lean

This lives in HeroComposition.tsx, is CSS only (not canvas), and layers behind the cloth (z-index:1 vs cloth's z-index:2) so it shows through the gaps.

const pulse = Math.pow(Math.sin(t * 1.55), 4);   // sharp systole, long diastole, no bounce
const breatheOpacity = baseOpacity * (0.85 + 0.15*pulse);
const breatheScale = 1 + 0.015*pulse;

Portable core (vanilla, no React):

function updatePhantom(mark, t, scrollFrac, hovered, reduced) {
  const pulse = reduced ? Math.pow(Math.sin(3.0*1.55), 4) : Math.pow(Math.sin(t*1.55), 4);
  const drift = reduced ? 0 : scrollFrac * -36;
  const opacity = reduced ? 0.38 : 0.38 * (1 - scrollFrac*0.6) * (0.85+0.15*pulse);
  const split = hovered ? 4 : 2;
  mark.style.transform = `translateY(${drift}px) scale(${1+0.015*(reduced?pulse:pulse)})`;
  mark.style.opacity = opacity;
  mark.style.textShadow = `-${split}px 0 rgba(255,55,55,.28), ${split}px 0 rgba(60,220,230,.28)`;
}

5. Steganography module (lib/steganography.ts)

Two independently-portable pure functions, no DOM/React dependency at all — copy verbatim:

In ClothManifesto.tsx, the render loop picks level=3 (a distinct "cyan-core" atlas stamp, saturated rgba(0,240,255,0.95), not the red/cyan split of ordinary levels — pure essence-cyan, single color, printed dead-center with no offset) for any particle whose grid cell is in the cyanSet, overriding the energy-driven level entirely. Direct violation risk for the canon: essence #00f0ff is supposed to mark "LIVE elements only" per the laws you're building under — a static hidden-message glyph rendered in pure essence cyan is arguably not "live." If you port this, either (a) reserve essence for elements that are genuinely reactive to state/time, and render the hidden-cell reveal in --sigil-text or witness/signal-at-full instead, or (b) accept it only if the cyan cells are gated behind a live/interactive reveal (as this component does via the hover zones) so the cyan appearance itself is conditional on live user action — that's the more defensible reading and matches how zesign already gates it (Layer C: hover-to-reveal, not always-on).

Three-layer system as documented, in case you replicate the full puzzle:

6. What's portable vs. not, concretely

Directly portable, no changes needed: the Verlet integrator, the constraint solver, the atlas pre-render technique, the pointer repulsion field, encodeCyanCells/decodeCyanCells/sha256, the hover-zone overlay pattern, useActiveRaf's IntersectionObserver+visibilitychange+reduced-motion gating logic (rewrite as a plain function, not a hook — same three conditions: in-view, tab-visible, not-reduced).

Needs adaptation:

Vanilla rAF gate (replacing useActiveRaf):

function activeRaf(cb, el, { rootMargin='200px', reduced=false } = {}) {
  if (reduced) { requestAnimationFrame(cb); return () => {}; }
  let raf=0, inView=false, running=false;
  const tick = t => { if(!running) return; cb(t); raf=requestAnimationFrame(tick); };
  const start = () => { if(running) return; running=true; raf=requestAnimationFrame(tick); };
  const stop = () => { running=false; cancelAnimationFrame(raf); };
  const sync = () => (inView && document.visibilityState==='visible') ? start() : stop();
  const io = new IntersectionObserver(([e]) => { inView=e.isIntersecting; sync(); }, {rootMargin});
  io.observe(el);
  document.addEventListener('visibilitychange', sync);
  return () => { stop(); io.disconnect(); document.removeEventListener('visibilitychange', sync); };
}

Not portable / needs a hard decision:

7. Chaos-magic sigil construction (Austin Osman Spare's method)

Spare's technique (from his automatic-drawing/"Atavistic Resurgence" writings, early 20th c., popularized in modern chaos magic via Peter Carroll's Liber Null): take a stated intent, strip it to its irreducible letter-set, then compose that letter-set into a single interlocking glyph so the original semantic content is visually destroyed — the sigil is meant to be charged and then forgotten, its meaning inaccessible to the conscious mind.

Algorithm:

  1. Normalize: uppercase, strip non-letters (or keep a controlled symbol set if you want numerology extensions — skip that here for glyph purity).
  2. Strip vowels: remove A E I O U (classic Spare method omits vowels to keep the glyph angular and consonant-dense — an aesthetic/practical choice, not universal across all sigil systems, but it's specifically what you were asked for).
  3. Deduplicate letters: keep first occurrence only, drop repeats — this is the step that actually breaks legibility; without it the sigil often still reads as the phrase.
  4. Map each remaining letter to a fixed glyph-primitive (stroke set) on a shared coordinate grid — canonically a 3×3 or 5×5 grid of anchor points, each letter of the alphabet pre-assigned a small set of line segments between anchor points (similar in spirit to a seven-segment/font-skeleton scheme, but arbitrary — Spare himself drew freehand; a programmatic version needs a fixed stroke alphabet).
  5. Superimpose: draw all selected letters' strokes on the same canvas region, sharing the same anchor grid, so strokes overlap and interlock rather than sitting side-by-side. This overlap is what makes it read as one glyph rather than a row of letters.
  6. Optional: apply a small random-but-deterministic (seeded) rotation/reflection per stroke-set to increase interlocking, or connect endpoints of consecutive letters' strokes with a continuous connecting line (some practitioners draw the letters as one unbroken path, literally lifting the pen only where needed).

Concrete 1-bit implementation (deterministic, no external font, seed = the intent string itself so "same seed, same output" holds trivially):

// 5x5 anchor grid per letter-slot; letters mapped to stroke endpoints on that grid.
// Only need strokes for consonants (post vowel-strip); extend map as needed.
const GRID = 5; // 0..4 in x and y
// Each letter: array of [x0,y0,x1,y1] stroke segments on the 0..4 grid.
// A minimal deterministic "skeleton" font — straight-line strokes only (no curves),
// satisfies the 1-bit / typographic-glyph-only constraint trivially since it's pure vector strokes.
const STROKES = {
  B:[[0,0,0,4],[0,0,3,1],[0,2,3,1],[0,2,3,3],[0,4,3,3]],
  C:[[3,0,0,0],[0,0,0,4],[0,4,3,4]],
  D:[[0,0,0,4],[0,0,2,1],[2,1,2,3],[2,3,0,4]],
  // ... one entry per consonant, hand-authored once, reused forever (deterministic)
  T:[[0,0,4,0],[2,0,2,4]],
  X:[[0,0,4,4],[4,0,0,4]],
  // etc — build out A-Z minus vowels; treat missing letters by skipping (never invent curves)
};

function sigilLetters(intent) {
  const seen = new Set();
  const out = [];
  for (const ch of intent.toUpperCase()) {
    if (!/[A-Z]/.test(ch)) continue;
    if ('AEIOU'.includes(ch)) continue;
    if (seen.has(ch)) continue;
    seen.add(ch);
    out.push(ch);
  }
  return out; // deterministic given intent string — this IS the seed
}

function drawSigil(ctx, intent, cx, cy, scale, strokeColor) {
  const letters = sigilLetters(intent);
  ctx.strokeStyle = strokeColor;   // e.g. signal at o-1, or sigil-text #cd5860 for a "named" sigil
  ctx.lineWidth = 1;
  ctx.lineCap = 'square';          // square, not round — matches "corners are square" doctrine
  const cell = scale / GRID;
  for (const ch of letters) {
    const strokes = STROKES[ch];
    if (!strokes) continue;
    // Deterministic per-letter transform so strokes interlock rather than stack identically:
    // rotate by a hash of (ch, position-in-list) — pure function of the intent string, no RNG.
    const idx = letters.indexOf(ch);
    const angle = ((ch.charCodeAt(0) * 37 + idx * 53) % 360) * Math.PI / 180 * 0.15; // small deterministic jitter
    ctx.save();
    ctx.translate(cx, cy);
    ctx.rotate(angle);
    for (const [x0,y0,x1,y1] of strokes) {
      ctx.beginPath();
      ctx.moveTo((x0-GRID/2)*cell, (y0-GRID/2)*cell);
      ctx.lineTo((x1-GRID/2)*cell, (y1-GRID/2)*cell);
      ctx.stroke();
    }
    ctx.restore();
  }
}

Design notes for fitting this under the doctrine:

Simpler fallback if hand-authoring 21 stroke-letters is too much work: use the browser's own font to rasterize each surviving consonant into a small offscreen bitmap, XOR-composite all of them into one shared bitmap centered on the same point (so overlapping ink cancels via XOR, producing genuinely interlocked, semantically-scrambled strokes), then trace/draw only the resulting 1-bit edge as your sigil stroke. This is fewer lines of code than a hand-built stroke alphabet and stays fully deterministic (same intent string → same bitmap → same edges), at the cost of relying on whatever monospace fallback the artifact ends up with for its typographic character (acceptable — Departure's excluded-glyph list ⌗ ≡ ☰ ⋮ and astrological symbols doesn't restrict plain A-Z consonants at all, so this is safe under §07).

8. What's hard / likely to break