The Rain Algorithm
How cipher digits fall from the sky and land in perfect piles — explained for CS students
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.
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.
- Rain — digits fall one-by-one from the sky into piles.
- Explosion — the piles blast apart into a random flying cloud.
- 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'
}
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
Total rain duration = 48 × 0.055 s = 2.64 seconds
Each digit gets its own moment in the spotlight before the next one appears.
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:
dt to rainTimer
so we always know how many seconds of rain have elapsed.
rainTimer, pop it off and flip that particle
from 'pending' to 'raining'.
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) ...
}
}
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);
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°.
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
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.
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;
- 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;
}
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
}
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
}
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!
}
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;
}
}
Live CSS demo — particles drift with gentle heading changes, wrapping at edges
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)torandom(-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
setTimeoutentirely and just calltriggerExplosion()directly — same effect asdelay = 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.