The Assemble Algorithm

How flying digit particles find their way home — explained for CS students

Steering Angle Distance-Proportional Speed State Machine Event-Driven Reveal

Big Picture: What Does "Assemble" Actually Do?

In Beale Cipher 3, when you click Decode or Assemble, dozens of little digit characters that are flying all over the canvas have to find their way back to exact spots and form letter groups — and when every digit for a letter arrives, that letter materialises with a pop-burst.

This is called a steering algorithm. Each particle is treated like a tiny homing missile: it knows where it wants to go (homeX, homeY), and every animation frame it adjusts its heading a little bit toward that target and moves forward.

Live demo — eight particles homing toward two letter clusters  T I

T
I
1
4
3
2
4
8
9
1

Each digit knows which letter group it belongs to. Purple digits → T, cyan digits → I (cipher numbers 1432 and 489 for TIME MACHINE).

Key Idea: The algorithm runs inside p5.js's draw() loop, which fires ~30 times per second. Every call to draw() moves every particle a tiny bit closer to its target. Thirty of those tiny steps per second creates the illusion of smooth motion.

The Particle Object

Every digit on screen is stored as a plain JavaScript object in the array digitPtcls[]. Here is the structure of one particle (from buildDigitsForDecrypt() in swBealeCipher3Sketch.js):

// One entry in digitPtcls[] — created for each digit of a cipher number.
// e.g. the number 1432 for the letter 'T' creates FOUR particles:
// one for '1', one for '4', one for '3', one for '2'.

{
    // ── Current position (user coordinates) ──
    x:         startX,       // random start position on canvas
    y:         startY,

    // ── Target / home position ─────────────────
    homeX:     s.homeX + pileOffX,  // letter center + pile offset
    homeY:     s.homeY + pileOffY,

    // ── Which letter does this digit belong to? ─
    letterIdx: letterIdx,    // index into chars[] array
    digit:     numStr[di],   // the character to display: '1','4','3','2'

    // ── Movement (updated every frame) ────────────
    angle:     random(TWO_PI),   // current heading in radians
    speed:     random(60, 180),  // units per second

    // ── Visual ────────────────────────────────────
    hue:       s.hue,        // colour inherited from the parent letter
    size:      random(14, 22),
    alpha:     100,

    // ── State flag ────────────────────────────────
    state:     'flying'      // 'flying' | 'homing' | 'piled'
}
Why separate x/y from homeX/homeY?
The particle needs to remember where it started (or wherever it currently is) and where it needs to end up. Every frame we compare these two positions to figure out how far we still have to go and which direction to turn.

Where Is "Home"?

Each letter in the word has a centre position (homeX, homeY) calculated in buildWord() so the letters are evenly spaced across the canvas. But a letter like T might have four digits (1432) — they can't all land on the exact same pixel. So each digit gets its own pile offset.

The Pile Offset

The digits for one letter are arranged in a small circle around the letter's centre. Imagine the face of a clock: digit 0 goes at 12 o'clock, digit 1 goes at 3 o'clock, digit 2 at 6 o'clock, etc.

// From buildDigitsForEncrypt() / buildDigitsForDecrypt()
// di  = which digit we're placing (0, 1, 2, ...)
// nDigits = total digits for this letter

// Step 1: calculate an angle for this digit's spot in the cluster
const angle = (di / nDigits) * TWO_PI + random(0.4); // evenly spaced + small random jitter

// Step 2: a radius that grows with digit count (capped at PILE_RADIUS_U = 8 user units)
const pileRadius = min(PILE_RADIUS_U, PILE_RADIUS_U * (nDigits / 4));

// Step 3: convert polar (angle, radius) → cartesian (dx, dy) offset
const pileOffX = pileRadius * cos(angle) * (nDigits > 1 ? 1 : 0);
const pileOffY = pileRadius * sin(angle) * (nDigits > 1 ? 1 : 0);

// Step 4: the particle's home is the letter centre PLUS this offset
homeX = s.homeX + pileOffX;
homeY = s.homeY + pileOffY;
Polar → Cartesian conversion
Any point on a circle of radius r at angle θ has coordinates:
x = r · cos(θ)    y = r · sin(θ)
This is one of the most useful formulas in graphics programming. We use it here to spread digits evenly around the letter centre.

For a 4-digit number like 1432, the four digits end up in a little ring ~8 user units from the letter centre. The more digits a number has, the bigger the ring (capped at 8 units).

The Steering Algorithm — Frame by Frame

The function updateHomingDigits(dt) runs every single animation frame while animState === 'homing'. dt is the delta-time — the number of seconds since the last frame (approximately 1/30 ≈ 0.033 s at 30 fps).

function updateHomingDigits(dt) {

    for (const p of digitPtcls) {
        if (p.state === 'piled') continue;  // already home — skip it

        p.state = 'homing';

        // ── Step 1: find the vector pointing from particle → home ──────────
        const dx   = p.homeX - p.x;
        const dy   = p.homeY - p.y;
        const dist = Math.sqrt(dx * dx + dy * dy);   // straight-line distance

        // ── Step 2: work out the TARGET angle we'd need to face ───────────
        const targetAngle = Math.atan2(dy, dx);

        // ── Step 3: blend current angle toward the target (gradual turn) ──
        let diff = targetAngle - p.angle;
        while (diff >  Math.PI) diff -= TWO_PI;   // keep diff in [-π, π]
        while (diff < -Math.PI) diff += TWO_PI;
        p.angle += diff * HOME_STEER;             // HOME_STEER = 0.12

        // ── Step 4: speed is proportional to distance ─────────────────────
        const spd = Math.max(HOME_SPEED_MIN, dist * 1.8);

        // ── Step 5: move the particle ──────────────────────────────────────
        p.x += spd * Math.cos(p.angle) * dt;
        p.y += spd * Math.sin(p.angle) * dt;

        // ── Step 6: snap to exact home when close enough ──────────────────
        if (dist < HOME_THRESHOLD) {   // HOME_THRESHOLD = 6 user units
            p.x     = p.homeX;
            p.y     = p.homeY;
            p.state = 'piled';
            p.isHome = true;
        }
    }
}

Step 2: Math.atan2(dy, dx) — What Angle Do I Need?

Math.atan2(y, x) returns the angle (in radians) of the vector (x, y). Think of it as: "if I need to travel dx right and dy up — what compass heading is that?"

What is a radian?
A full circle = 2π radians ≈ 6.28.
0 rad = facing right (East).
π/2 rad ≈ 1.57 = facing down (South) in canvas coords.
π rad ≈ 3.14 = facing left (West).
We use radians because sin() and cos() work in radians.
Why atan2 and not atan?
Regular atan(dy/dx) can't tell the difference between pointing to the upper-right and pointing to the lower-left — both give the same fraction. atan2 takes both components separately so it always returns the correct quadrant.

Step 3: Gradual Turning — the "Angle Blend"

If we simply set p.angle = targetAngle the particle would instantly snap to face home. That looks mechanical and boring. Instead we only move 12% of the way toward the target angle each frame:

p.angle += diff * HOME_STEER;  // HOME_STEER = 0.12

This is called linear interpolation (lerp) on an angle. It creates the curving, organic paths you see — particles sweep around corners rather than making sharp 90° turns.

The wrapping problem
Angles wrap around. The difference between 5° and 355° is only 10°, but naïve subtraction gives 350°! The two while loops keep diff in the range [−π, +π] so the particle always turns the short way around:
while (diff >  Math.PI) diff -= TWO_PI;
while (diff < -Math.PI) diff += TWO_PI;

Step 4: Distance-Proportional Speed

The further away a particle is, the faster it moves. As it gets closer, it slows down naturally — like a car easing into a parking space:

const spd = Math.max(HOME_SPEED_MIN, dist * 1.8);
//                    └─ minimum 18 u/s     └─ speed scales with distance
//
// Example: dist = 200 units  →  spd = 360 u/s  (racing in from far away)
// Example: dist =  30 units  →  spd =  54 u/s  (slowing as it approaches)
// Example: dist =   8 units  →  spd =  18 u/s  (minimum speed floor)
Why multiply by dt?
Speed is measured in user units per second. Multiplying by dt (seconds elapsed since last frame) converts it to user units per frame.
distance_moved_this_frame = speed × dt
This keeps the animation identical whether the browser runs at 60 fps or 30 fps — a technique called frame-rate independent movement.

Step 6: Snap-to-Home

Mathematically, a particle getting closer and closer to its target might never quite reach zero distance (like Zeno's paradox). So we add a threshold check: once the particle is within HOME_THRESHOLD = 6 user units, we snap it to the exact target and mark it as 'piled':

if (dist < HOME_THRESHOLD) {   // "close enough"
    p.x      = p.homeX;        // snap to exact position
    p.y      = p.homeY;
    p.state  = 'piled';
    p.isHome = true;
}

The snap is so fast (happening over one frame ≈ 33ms) that it is invisible to a human eye. What you do notice is the satisfying "click" as each digit settles into place.

The State Machine

The sketch uses a variable animState to keep track of what phase the animation is in. Only certain actions are allowed in each state. This is called a finite state machine (FSM) — a classic CS concept.

assembled raining pre- explode exploded homing re- vealing Encode all piled timeout Assemble all home done cascade

The key transitions used in the assemble flow are:

State What's happening on canvas? How we leave it
'exploded' Digit particles flying randomly (torus wrap) User clicks Assemble (or auto-trigger after Decode)
'homing' Every particle steering toward its homeX/homeY checkAllHomed() — when every particle is 'piled'
'revealing' Letters materialising one by one with pop-burst revealNextLetter() cascades, then sets 'done'
'done' All letters shown, gently breathing User clicks Encode or Reset
In code, the draw() function uses a switch statement on animState to decide what to run each frame. Nothing expensive is called when it's not needed:
switch (animState) {
    case 'assembled':   drawLetters(dt);                        break;
    case 'raining':     updateRain(dt); drawDigits(dt);         break;
    case 'exploded':    updateFlyingDigits(dt); drawDigits(dt); break;
    case 'homing':      updateHomingDigits(dt); drawDigits(dt);
                        checkAllHomed();                        break;
    case 'revealing':   drawLetters(dt); drawDigitsFading(dt);  break;
    case 'done':        drawLetters(dt);                        break;
}

Detecting When a Letter Is Complete

Individual particles don't know when their sibling digits have also arrived. Instead, checkAllHomed() checks all particles at once after every frame:

function checkAllHomed() {
    if (animState !== 'homing') return;

    // Array.every() returns true only if ALL particles satisfy the condition
    if (!digitPtcls.every(p => p.state === 'piled')) return;

    // ✅ Every single digit is home — start the letter reveal cascade
    animState = 'revealing';
    revealNextLetter(0);
}

Array.every() is a built-in JavaScript method that returns true if every element in an array passes the test function. The moment even one particle is still 'homing', the function exits immediately without doing anything.

Why check the whole array instead of counting?
We could keep a counter (e.g., piledCount++) and compare to digitPtcls.length. But .every() is cleaner and less error-prone — there's no counter to forget to reset when the user starts a new encode.

The Reveal Cascade — setTimeout + Recursion

Once all digits are piled, each letter pops in one at a time rather than all at once. This uses a clever combination of recursion and setTimeout:

function revealNextLetter(startIdx) {
    // Walk forward through chars[] looking for the next unrevealed letter
    for (let i = startIdx; i < chars.length; i++) {
        const s = chars[i];
        if (s.origLetter === ' ')   continue;  // skip spaces
        if (s.cipherNum  === null)  continue;  // skip unencoded letters
        if (s.revealed)             continue;  // already materialised

        // ── Materialise this letter ───────────────────────────────────────
        s.alpha    = 100;           // make it visible
        s.revealed = true;
        s.popTimer = POP_DUR;       // 0.45 s scale-pop countdown
        s.popScale = POP_SCALE_PEAK; // 2.2 × oversize peak

        // Turn on the gentle breathing animation
        s.char.setShouldBreathe(true);
        s.char.setBreatheAmount(18);
        s.char.setBreatheSpeed(0.12);

        // ── Glow the matching ticker word in cyan ─────────────────────────
        spotlightWord(s.cipherNum, 185);

        // ── Schedule next letter 220 ms later (recursive call via timer) ──
        setTimeout(() => revealNextLetter(i + 1), 220);
        return;  // ← stop here; the next call will continue from i + 1
    }

    // No more letters to reveal — animation complete
    setTimeout(() => {
        animState = 'done';
    }, 400);
}

How the Recursion Works

1
revealNextLetter(0) is called — it finds the first unrevealed letter (index 0), materialises it, then calls setTimeout( () => revealNextLetter(1), 220 ) and returns.
2
220 ms later the browser fires the timer and calls revealNextLetter(1) — which materialises the second letter and schedules the third.
3
This continues until all letters are revealed, at which point the loop falls off the end of chars[] and the 'done' state is set.
Why not use a for loop with sleep()?
JavaScript is single-threaded — there is no sleep(). If you blocked the thread, the browser would freeze entirely. setTimeout schedules a callback for later without blocking, so the draw() loop keeps running at 30 fps the whole time — the canvas stays alive and responsive.

The Pop-Scale Effect

When a letter is revealed, its popTimer starts counting down from 0.45 s. Inside drawLetters(), every frame it updates the size to overshoot and then shrink back smoothly:

// Inside drawLetters(dt) — runs every frame for each letter
if (s.popTimer > 0) {
    s.popTimer -= dt;
    const t = Math.max(0, s.popTimer / POP_DUR);   // t goes from 1 → 0
    s.popScale = 1.0 + (POP_SCALE_PEAK - 1.0) * t * t;  // quadratic ease-out

    const base = 58;   // normal letter size in user units
    s.char.setBaseSize(base * s.popScale);
}

The t * t term makes the scale ease out — it starts big and shrinks quickly at first, then slows down as it approaches normal size. This is called a quadratic ease-out and it mimics how real objects decelerate.

Putting It All Together — One Full Decode

Here is the complete sequence of events when a user types "TIME MACHINE", clicks Decode, and watches the message appear:

  1. Decode button pressedencodeMessage() / decodeNumbers() looks up each letter in the Beale key-text index to find a matching word number (e.g., T → word #1432).
  2. buildWord("TIME MACHINE") — creates 11 SWCharacter objects for the letters, all with alpha = 0 (invisible).
  3. buildDigitsForDecrypt() — splits each cipher number into digit characters, creates one particle object per digit (~40 total for an 11-letter message), assigns each its homeX/homeY pile offset, and places them at random positions across the canvas. State = 'flying'.
  4. 700 ms auto-triggeranimState switches from 'exploded' to 'homing'.
  5. Every frame (×30/s)updateHomingDigits(dt) steers each particle toward its home; checkAllHomed() watches for all particles reaching 'piled'.
  6. All piledanimState = 'revealing'; revealNextLetter(0) is called.
  7. Every 220 ms — the next letter appears with a 2.2× scale-pop burst, the cyan ticker glow fires for the key-text word, and breathing animation begins.
  8. All letters revealedanimState = 'done'. The word sits on the canvas, gently breathing.
The role of SWCharacter and SWBug
The actual letter glyphs are instances of SWCharacter (from shapeClasses/swCharacter.js). Each one carries a SWBug that holds its x/y position in user-coordinate space. The digit particles however are plain JavaScript objects — no SWCharacter needed, because they are drawn directly with p5.js's text() call inside drawDigits(). This keeps the particle system lightweight: no object overhead for potentially 60–70 particles at once.

Try It & Extend It

These are great experiments to try in the code yourself:

In swBealeCipher3Sketch.js, find the line:
const HOME_STEER = 0.12;
Change it to 0.02. The particles will arc in wide sweeping curves before finding their targets — much more dramatic. Set it to 1.0 for instant snapping.

Find: const HOME_THRESHOLD = 6;
Increase it to 30. Now particles snap from much further away — the animation will look "choppy" at the end because the smooth approach is cut short. Decrease it to 1 and they have to get nearly pixel-perfect before snapping.

Find: setTimeout(() => revealNextLetter(i + 1), 220);
Change 220 to 800 for a slow, dramatic word-by-word reveal. Set it to 50 for a rapid-fire cascade where all letters seem to appear almost simultaneously.

Find: const POP_SCALE_PEAK = 2.2;
Change it to 5.0. Letters will burst huge when they materialise, then shrink back. Also try changing POP_DUR = 0.45 to control how long the pop animation lasts.

Right now the reveal only starts after all digits are piled. A more advanced version would reveal each letter the moment its own digits arrived, even while others are still flying.

To do this you would need to:

  1. Add a digitsHome counter to each entry in chars[], and a digitCount property set in buildDigitsForDecrypt().
  2. In the snap block inside updateHomingDigits(), do:
    p.state  = 'piled';
    p.isHome = true;
    const slot = chars[p.letterIdx];
    slot.digitsHome++;
    if (slot.digitsHome >= slot.digitCount) {
        // All digits for this letter are home!
        slot.alpha    = 100;
        slot.revealed = true;
        slot.popTimer = POP_DUR;
        slot.popScale = POP_SCALE_PEAK;
        spotlightWord(slot.cipherNum, 185);
    }
  3. Remove the call to checkAllHomed() and the cascade entirely — letters reveal themselves as they complete.

Summary

The "Assemble" animation uses these CS concepts working together:

  • Angle arithmetic with Math.atan2 — gives the direction from any point to any other point.
  • Lerp (linear interpolation) on angles — creates gradual, organic turning instead of instant snapping.
  • Distance-proportional speed — particles slow down naturally as they approach their target.
  • Threshold snapping — solves the "never quite arrives" mathematical problem.
  • Finite State MachineanimState controls exactly what code runs each frame and prevents conflicting actions.
  • Frame-rate independent movement — multiplying by dt keeps motion consistent regardless of frame rate.
  • Array.every() — elegantly detects when all particles have completed.
  • setTimeout + recursive callbacks — schedules a cascade of events without blocking the animation loop.
  • Polar → Cartesian coordinates — spreads digits in a ring around each letter's home using sin/cos.