The Rain Algorithm

How cipher digits fall from the sky and land in perfect piles — explained for CS students

Rain Queue Staggered Release Steered Falling Letter Fading Explosion

Overview — What Does the Rain Do?

When you click Encode in Beale Cipher 3, the program converts each letter into a cipher number from the key text. For example, the letter T might become 1432. That number has four digits: '1', '4', '3', '2'. Each digit becomes its own independent little particle that flies around the screen.

During rain phase, those digit particles appear one by one, sliding down from the top of the canvas and curving smoothly into a tight pile directly over their letter. It looks like the encoded numbers are literally raining out of the sky.

T I M
1
4
3
7
2
1
6
5
9

Live CSS demo — digits arrive one at a time, staggered by 0.055 s

After every single digit has landed, there is a brief dramatic pause — then the whole pile explodes outward into a chaotic cloud. That flying cloud is the starting point for the Assemble Algorithm.

Big Picture — three phases:
  1. Rain — digits fall one-by-one from the sky into piles.
  2. Explosion — the piles blast apart into a random flying cloud.
  3. Assemble — see Assemble Algorithm.

The Particle Object

Every digit particle is just a plain JavaScript object. It has fields for position, home position, movement, display, and a state flag that tracks which phase of the animation it is in.

// 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,        // near letter's X, just above canvas top
    y:          startY,        // grid.UL.y + random(10, 30)  ← above top edge

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

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

    // ── Movement ──────────────────────────────
    angle:      random(TWO_PI),    // current heading in radians
    speed:      random(60, 180),   // units per second (only used in flying phase)

    // ── Visual ────────────────────────────────
    hue:        s.hue,         // colour inherited from the parent letter
    size:       random(14, 22),
    alpha:      0,             // 0 = invisible until released

    // ── State flag ────────────────────────────
    //   'pending'  → not yet released from the queue
    //   'raining'  → falling toward home (released from queue)
    //   'piled'    → arrived at home, sitting still
    //   'flying'   → exploded, bouncing around the canvas
    //   'homing'   → steering back home (Assemble phase)
    state:      'pending'
}
Why start with state: 'pending'?
All of a word's digit particles are created at exactly the same moment (inside buildDigitsForEncrypt()), but they should not all start moving at once — that would just look like a simultaneous avalanche. Setting state: 'pending' is a way of saying "I exist, but my turn hasn't come yet." The rain queue decides when each individual particle wakes up.

The Rain Queue

A queue is a list where the first item added is the first item processed — like a line of people waiting. The rain queue stores a simple record for every particle: which particle and when it should be released.

// Structure of one item in rainQueue[]:
{ ptclIdx: 7, delay: 0.385 }
//  │                └─ release this particle 0.385 seconds after raining starts
//  └─ index into digitPtcls[]

The queue is pre-built all at once before the animation even starts, then processed in real-time as the animation runs.

How the Delays Are Calculated — globalDelay

Inside buildDigitsForEncrypt() we walk through every letter, then every digit of that letter's cipher number. A running counter called globalDelay increases by RAIN_INTERVAL (0.055 s) for each digit. The result: each successive digit gets released a tiny bit later than the previous one.

// Simplified excerpt from buildDigitsForEncrypt()
let globalDelay = 0;
const RAIN_INTERVAL = 0.055;   // seconds between successive digit arrivals

for (let li = 0; li < chars.length; li++) {      // loop over letters
    const numStr  = String(cipher[li]);           // e.g. "1432"
    const nDigits = numStr.length;                // e.g. 4

    for (let di = 0; di < nDigits; di++) {        // loop over digits
        // ... create the particle object ...

        const delay = globalDelay;
        globalDelay += RAIN_INTERVAL;             // next digit waits a bit longer

        rainQueue.push({ ptclIdx: digitPtcls.length, delay });
        digitPtcls.push(ptcl);
    }
}

// Final result:
//   digit 0 (letter 0, digit 0): delay = 0.000 s  ← released first
//   digit 1 (letter 0, digit 1): delay = 0.055 s
//   digit 2 (letter 0, digit 2): delay = 0.110 s
//   digit 3 (letter 0, digit 3): delay = 0.165 s
//   digit 4 (letter 1, digit 0): delay = 0.220 s
//   ...and so on for every digit of every letter
Quick maths: If the word TIME MACHINE encodes to cipher numbers that have a total of, say, 48 digits combined, then:

Total rain duration = 48 × 0.055 s = 2.64 seconds

Each digit gets its own moment in the spotlight before the next one appears.
Why not just release them all at once?
Having all digits appear simultaneously would create a confusing blob. The stagger makes the "this digit belongs to this letter" relationship visually clear — you can see each cipher number landing on its letter one digit at a time, like a typewriter.

Releasing Particles — updateRain(dt)

Every animation frame, updateRain(dt) is called with dt = the time elapsed since the last frame (typically 1/30 of a second ≈ 0.033 s at 30 fps). It does three things:

1
Advance the timer — add dt to rainTimer so we always know how many seconds of rain have elapsed.
2
Release any due particles — while the front of the queue has a delay ≤ rainTimer, pop it off and flip that particle from 'pending' to 'raining'.
3
Steer all raining particles toward home — each active particle curves toward its pile position using the same angle-blend technique as the Assemble Algorithm.
function updateRain(dt) {
    rainTimer += dt;           // ① advance clock

    // ② Release particles whose time has come
    while (rainQueue.length > 0 && rainQueue[0].delay <= rainTimer) {
        const { ptclIdx } = rainQueue.shift();   // remove from front of queue
        const p = digitPtcls[ptclIdx];
        p.state = 'raining';   // wake up!
        p.alpha = 100;         // make visible

        // Place just above the canvas top, centred near its home X
        p.x = p.homeX + random(-12, 12);   // small horizontal scatter
        p.y = grid.UL.y + random(10, 30);  // above the top edge
        p.vx = 0;
        p.vy = 0;
    }

    // ③ Steer every 'raining' particle toward its home position
    for (const p of digitPtcls) {
        if (p.state !== 'raining') continue;
        // ... steering code (see next section) ...
    }
}
Where does a particle actually start?
It is placed just above the top edge of the canvas at grid.UL.y + random(10, 30). In user coordinates, grid.UL.y is the maximum Y value (400 — the top of the viewport), so adding 10–30 extra units puts it a little above the visible area. The X position is the letter's home X, ± up to 12 units of scatter. This tiny horizontal offset is one of the reasons each particle follows a slightly different curved path on the way down.

The Curved Path — Steering to Home

Once a particle is 'raining', every frame it is steered toward its home position. The steering algorithm is almost identical to the one in the Assemble phase (see Assemble Algorithm) — the difference is just a slightly faster turn rate and speed multiplier, because falling looks better if it's slightly snappier than homing.

Step 1: Find the Target Angle with Math.atan2

First, we calculate a vector from the particle's current position to its home:

const dx = p.homeX - p.x;   // how far right/left is home?
const dy = p.homeY - p.y;   // how far up/down is home?
const dist = Math.sqrt(dx * dx + dy * dy);   // Pythagoras

// atan2(dy, dx) returns the angle (in radians) of the vector (dx, dy).
// 0 = pointing right, PI/2 = pointing up (in user coords), etc.
const targetAngle = Math.atan2(dy, dx);
Why atan2 instead of atan?
The regular atan(dy/dx) can't tell the difference between pointing upper-right and lower-left — both have a positive dy/dx ratio. atan2 takes both components separately, so it returns the correct angle for all 360°.
Radians reminder: A full circle = TWO_PI ≈ 6.28 radians. 0 rad = right, π/2 = up, π = left, 3π/2 = down. p5.js's TWO_PI constant equals 2 * Math.PI.

Step 2: Gradually Blend the Angle

If we just set p.angle = targetAngle directly, the particle would teleport its heading instantly — no curve, just a straight drop. Instead, we blend just a fraction of the gap each frame:

let diff = targetAngle - p.angle;   // gap between where we're going and where we want to go

// Normalise to (-PI, +PI] so we always turn the short way around the circle
while (diff >  Math.PI) diff -= TWO_PI;
while (diff < -Math.PI) diff += TWO_PI;

p.angle += diff * 0.15;   // 0.15 = turn rate — 15% of the remaining gap per frame
This is where the "quasi-random" curve comes from.
Each particle starts at a slightly different X position (±12 units of scatter), so its initial targetAngle is slightly different. The 15% per-frame blending means the heading converges smoothly but not instantly — so each digit traces a gently curved arc rather than a straight vertical line. No randomness is added during flight; the variety comes entirely from the individual start positions.
The short-way-around trick:
Without the normalisation lines, a particle that needs to turn from 350° to 10° would stupidly travel 340° the long way around instead of the 10° short-cut. The while loops clamp diff to the range (−π, +π], guaranteeing the shortest rotation.

Step 3: Distance-Proportional Speed

The speed scales with how far away home is — fast when distant, decelerating as it approaches:

const spd = Math.max(HOME_SPEED_MIN, dist * 2.5);
//                    └─ at least 18 u/s        └─ 2.5× distance (rain is snappier than homing)

// Apply movement using the current heading angle
p.x += spd * Math.cos(p.angle) * dt;
p.y += spd * Math.sin(p.angle) * dt;
Example: a particle starts 200 user-units above home.
  • Initial speed = 200 × 2.5 = 500 u/s — racing in.
  • At 30 u away: 30 × 2.5 = 75 u/s — gentle glide.
  • At 7 u away: minimum floor kicks in → 18 u/s — final approach.

Compare to Assemble (dist * 1.8): rain uses a bigger multiplier (2.5) so the initial descent is faster and more dramatic.

Step 4: Snap-to-Home

When the particle is within HOME_THRESHOLD (6 user-units) of its home position, it snaps exactly into place and changes state to 'piled':

if (dist < HOME_THRESHOLD) {   // close enough — snap!
    p.x     = p.homeX;
    p.y     = p.homeY;
    p.state = 'piled';
    p.isHome = true;
}
Why snap instead of letting it coast in?
Without the snap, a particle with proportional speed would get exponentially slower and never quite arrive — the classic "Zeno's Paradox" of animation. The threshold gives it a definitive landing moment.

Letter Fading as Digits Land

As digits pile up over each letter, the original plaintext letter gradually fades out — as if the digits are covering it up. This is handled by drawLettersFading(dt), called every frame during the 'raining' state.

function drawLettersFading(dt) {
    if (digitPtcls.length === 0) return;

    // How many digits have landed so far?
    const totalPiled = digitPtcls.filter(p => p.state === 'piled').length;

    // Fraction: 0 = none piled yet → letters fully visible
    //           1 = all piled      → letters fully invisible
    const fadeFrac = totalPiled / digitPtcls.length;

    for (const s of chars) {
        if (s.origLetter === ' ') continue;
        s.alpha = Math.round(100 * (1 - fadeFrac));   // 100 → 0
    }
    drawLetters(dt);   // draw letters at their current alpha
}
Key idea — proportional fade: This approach doesn't track individual letters separately. All letters fade at the same rate based on the overall progress of the rain. The result feels natural — as the rain nears completion, the letters almost disappear, and the last digit landing marks the moment the letters are gone entirely.
Example: 20 total digits, 8 have piled.
fadeFrac = 8 / 20 = 0.4
alpha = 100 × (1 − 0.4) = 60 — letters at 60% opacity.

Detecting When All Digits Have Landed

At the end of every updateRain(dt) call, the code checks whether the rain is completely finished. It uses a boolean flag (allPiled) that starts true and gets set to false the moment any particle is found that hasn't landed.

let allPiled = true;   // assume finished until proven otherwise

for (const p of digitPtcls) {
    if (p.state === 'pending') { allPiled = false; continue; }  // queue not empty yet
    if (p.state !== 'raining') continue;                         // already piled — skip

    allPiled = false;   // found a raining particle → not done yet
    // ... steering code ...
}

// All conditions for "done":
// • flag is still true (no pending or raining particles)
// • queue is empty (no more particles waiting)
// • there ARE particles (we didn't check an empty array)
// • state is still 'raining' (avoid double-triggering)
if (allPiled && rainQueue.length === 0 && digitPtcls.length > 0
        && animState === 'raining') {

    animState = 'pre-explode';                              // transitional state
    setTimeout(triggerExplosion, EXPLODE_DELAY * 1000);    // wait 0.5 s then BOOM
}
The pre-explode state is a brief pause between all digits landing and the explosion firing. It lets the user admire the completed piles for half a second before they shatter. During this state, draw() still runs (drawing the piled digits) but no movement updates occur.

The Explosion — triggerExplosion()

After the EXPLODE_DELAY pause, a setTimeout fires triggerExplosion(). This function sends every single digit blasting outward with a random direction and speed:

function triggerExplosion() {
    for (const p of digitPtcls) {
        const explodeAngle = random(TWO_PI);              // random direction (0–360°)
        const spd = random(FLY_SPEED_MIN * 1.5,           // 90 u/s minimum
                           FLY_SPEED_MAX * 1.8);          // 324 u/s maximum
        p.angle = explodeAngle;
        p.speed = spd;
        p.state = 'flying';    // leave the pile — go wild
        p.isHome = false;
    }

    // Hide all letters — only digits visible in the exploded state
    for (const s of chars) {
        s.alpha    = 0;
        s.revealed = false;
    }

    animState = 'exploded';
    if (assembleBtn) assembleBtn.disabled = false;  // ready to assemble!
}
Notice the speed range: Explosion speeds (90–324 u/s) are significantly faster than the rain speeds (18–500 u/s proportional). This is intentional — the explosion should look violent and dramatic, scattering particles far across the canvas almost instantly.

Flying Free — updateFlyingDigits(dt)

Once in the 'flying' state, each particle drifts in a gentle quasi-random walk. A small random nudge is added to the heading every frame, causing slow, organic meandering. The canvas is treated as a torus — particles that exit one edge immediately reappear on the opposite edge.

function updateFlyingDigits(dt) {
    const minX = grid.UL.x;   const maxX = grid.LR.x;
    const minY = grid.LR.y;   const maxY = grid.UL.y - TICKER_H_USER;

    for (const p of digitPtcls) {
        if (p.state !== 'flying') continue;

        p.angle += random(-0.15, 0.15);   // random heading nudge each frame

        let nx = p.x + p.speed * Math.cos(p.angle) * dt;
        let ny = p.y + p.speed * Math.sin(p.angle) * dt;

        // ── Torus wrap ────────────────────────────────────────────────────
        if      (nx < minX) nx += (maxX - minX);  // left edge → right edge
        else if (nx > maxX) nx -= (maxX - minX);  // right edge → left edge
        if      (ny < minY) ny += (maxY - minY);  // bottom → top
        else if (ny > maxY) ny -= (maxY - minY);  // top → bottom

        p.x = nx;
        p.y = ny;
    }
}
7
3
1
9
4

Live CSS demo — particles drift with gentle heading changes, wrapping at edges

Torus wrap vs. wall bounce: An alternative would be to reverse the heading when a particle hits a wall (like a billiard ball). Torus wrap was chosen instead because it keeps the density of particles uniform across the canvas — bouncing tends to cluster particles in corners. Torus wrap also feels more mysterious and "infinite" for the cipher theme.
Optional trail effect: The right-panel of BC3 has a "Show Trails" checkbox. When enabled, instead of clearing the background completely each frame, a semi-transparent rectangle is painted over everything. This leaves a faint smear behind each particle — a comet-tail effect. The alpha of the smear is user-adjustable.

The Full State Machine

The animation is controlled by a global string variable animState. draw() runs every frame and uses a switch to decide what to do this frame. The rain-to-explosion path uses the first four states:

switch (animState) {
    case 'assembled':   drawLetters(dt);                          break;
    case 'raining':     updateRain(dt);
                        drawLettersFading(dt);
                        drawDigits(dt);                           break;
    case 'pre-explode': drawDigits(dt);                           break; // brief pause
    case 'exploded':    updateFlyingDigits(dt); drawDigits(dt);   break;
    case 'homing':      updateHomingDigits(dt); drawDigits(dt);
                        checkAllHomed();                          break;
    case 'revealing':   drawLetters(dt); drawDigitsFading(dt);
                        checkAllRevealed();                       break;
    case 'done':        drawLetters(dt);                          break;
}
State What's happening How it ends
'assembled' Plaintext word shown; no particles User clicks Encode
'raining' Digits falling one-by-one; letters fading All digits land → 'pre-explode'
'pre-explode' All piled; brief dramatic pause (0.5 s) setTimeout'exploded'
'exploded' Particles flying freely (torus wrap) User clicks Assemble
'homing' Particles steering back to piles All home → 'revealing'
'revealing' Letters popping in; digits fading out All revealed → 'done'
'done' Final assembled word displayed User resets or encodes again

States highlighted in blue are the ones this page covers; see Assemble Algorithm for the homing and revealing states.

Experiments — Try It Yourself

Open sketches/swBealeCipher3Sketch.js and try these modifications. Each one changes something specific so you can see exactly what that part of the code controls.

Find this constant near the top of the sketch:

const RAIN_INTERVAL = 0.055;  // seconds between successive digit arrivals
  • Change it to 0.2 — digits arrive slowly, one every 5th of a second. Great for seeing the "one at a time" effect clearly.
  • Change it to 0.01 — digits pour in almost simultaneously. Looks more like a burst than a rain.
  • Change it to 0 — all digits appear at once. This is the degenerate case that shows why staggering matters.

In updateRain(dt), find the line that releases a particle:

p.x = p.homeX + random(-12, 12);   // ← horizontal scatter
  • Change random(-12, 12) to random(-60, 60) — digits now start much further from home and trace wide sweeping arcs.
  • Change it to 0 — digits fall in a perfectly straight vertical line. You'll see there's no curve at all without the scatter offset.

Also try changing the turn rate from 0.15 to 0.02 — particles turn very slowly and fly all over the canvas before eventually curving home.

Find the constant:

const EXPLODE_DELAY = 0.5;  // seconds between all piled and explosion
  • Change it to 2.0 — the completed piles sit there for two full seconds before exploding. This gives users a chance to read the cipher numbers.
  • Change it to 0 — the explosion fires immediately when the last digit lands. No pause at all.
  • Remove the setTimeout entirely and just call triggerExplosion() directly — same effect as delay = 0.

In updateFlyingDigits(dt) find:

p.angle += random(-0.15, 0.15);   // gentle random nudge each frame
  • Change to random(-0.8, 0.8) — particles spin and swerve chaotically, like frantic insects.
  • Change to 0 — particles travel in perfectly straight lines (the initial explosion angle, never changing). Looks like a firework.
  • Change to random(0, 0.1) (always positive) — all particles slowly spiral in the same clockwise direction.

What if instead of exploding after piling, the digits just sat there as cipher piles? You could then click Assemble to have them spiral back down from the piles to the final positions. To try this, change triggerExplosion() to set each particle to 'homing' instead of 'flying', and change the next state to 'homing' instead of 'exploded':

function triggerExplosion() {
    // Instead of blasting outward, immediately start homing
    for (const p of digitPtcls) {
        p.state  = 'homing';
        p.isHome = false;
    }
    for (const s of chars) { s.alpha = 0; s.revealed = false; }
    animState = 'homing';   // skip 'exploded' entirely
}

With this change, particles rain in, pile up, then immediately start the assemble reveal — no explosion. You lose the chaos, but gain a clean encode → reveal loop.