The Assemble Algorithm
How flying digit particles find their way home — explained for CS students
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
Each digit knows which letter group it belongs to. Purple digits → T, cyan digits → I (cipher numbers 1432 and 489 for TIME MACHINE).
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'
}
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;
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?"
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.
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.
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)
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 × dtThis 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.
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 |
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.
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
revealNextLetter(0) is called — it finds the first
unrevealed letter (index 0), materialises it, then calls
setTimeout( () => revealNextLetter(1), 220 ) and
returns.
revealNextLetter(1) — which materialises the second
letter and schedules the third.
chars[] and the
'done' state is set.
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:
-
Decode button pressed —
encodeMessage()/decodeNumbers()looks up each letter in the Beale key-text index to find a matching word number (e.g., T → word #1432). -
buildWord("TIME MACHINE")— creates 11SWCharacterobjects for the letters, all withalpha = 0(invisible). -
buildDigitsForDecrypt()— splits each cipher number into digit characters, creates one particle object per digit (~40 total for an 11-letter message), assigns each itshomeX/homeYpile offset, and places them at random positions across the canvas. State ='flying'. -
700 ms auto-trigger —
animStateswitches from'exploded'to'homing'. -
Every frame (×30/s) —
updateHomingDigits(dt)steers each particle toward its home;checkAllHomed()watches for all particles reaching'piled'. -
All piled —
animState = 'revealing';revealNextLetter(0)is called. - 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.
-
All letters revealed —
animState = 'done'. The word sits on the canvas, gently breathing.
SWCharacter and SWBugThe 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:
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.
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.
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.
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:
- Add a
digitsHomecounter to each entry inchars[], and adigitCountproperty set inbuildDigitsForDecrypt(). - 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); } - 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 Machine —
animStatecontrols exactly what code runs each frame and prevents conflicting actions. - Frame-rate independent movement — multiplying by
dtkeeps 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.