function setup() {
createCanvas(windowWidth, windowHeight);
colorMode(HSL, 360, 100, 100, 1);
// Pre-render gradient background to a graphics buffer
bgLayer = createGraphics(width, height);
bgLayer.colorMode(HSL, 360, 100, 100, 1);
drawBackgroundGradient();
}
Line by Line:
createCanvas(windowWidth, windowHeight)
- Creates a canvas that fills the entire browser window, making the sketch responsive to screen size
colorMode(HSL, 360, 100, 100, 1)
- Switches color mode from RGB to HSL (Hue, Saturation, Lightness), with ranges 0-360 for hue, 0-100 for saturation/lightness, and 0-1 for alpha. This makes it easier to create color gradients
bgLayer = createGraphics(width, height)
- Creates an off-screen graphics buffer (like a hidden canvas) that will store the pre-rendered background gradient for better performance
bgLayer.colorMode(HSL, 360, 100, 100, 1)
- Applies the same HSL color mode to the background graphics buffer so colors match the main canvas
drawBackgroundGradient()
- Calls the function that renders the blue gradient once to the bgLayer buffer, avoiding the need to redraw it every frame
function drawBackgroundGradient() {
const topColor = color(215, 80, 20); // desaturated blue, lighter
const bottomColor = color(215, 80, 7); // deep navy blue
bgLayer.noFill();
for (let y = 0; y < height; y++) {
const t = y / height;
const c = lerpColor(topColor, bottomColor, t);
bgLayer.stroke(c);
bgLayer.line(0, y, width, y);
}
}
đź”§ Subcomponents:
for-loop
Gradient Line Loop
for (let y = 0; y < height; y++)
Iterates through every pixel row from top to bottom, drawing horizontal lines with interpolated colors to create a smooth gradient
Line by Line:
const topColor = color(215, 80, 20)
- Creates a light desaturated blue color in HSL: hue 215 (blue), saturation 80%, lightness 20% (dark but not black)
const bottomColor = color(215, 80, 7)
- Creates a deep navy blue color with the same hue and saturation but much lower lightness (7%), making it much darker
bgLayer.noFill()
- Tells bgLayer not to fill shapes, so only the stroke (outline) will be visible
for (let y = 0; y < height; y++)
- Loops through every pixel row from y=0 (top) to y=height (bottom)
const t = y / height
- Calculates a normalized value between 0 (top) and 1 (bottom), used to interpolate between colors
const c = lerpColor(topColor, bottomColor, t)
- Uses linear interpolation to blend between topColor and bottomColor based on t. At top (t=0) it's topColor, at bottom (t=1) it's bottomColor, and in between it's a smooth blend
bgLayer.stroke(c)
- Sets the stroke color to the interpolated color c for this row
bgLayer.line(0, y, width, y)
- Draws a horizontal line across the full width at row y, creating one thin stripe of the gradient
function draw() {
// Draw background
image(bgLayer, 0, 0, width, height);
drawWaterline();
// Spawn new raindrops based on intensity
// ~ rainIntensity * 0.08 drops per frame on average
spawnAccumulator += rainIntensity * 0.08;
while (spawnAccumulator >= 1) {
createRaindrop();
spawnAccumulator -= 1;
}
// Update and draw raindrops
for (let i = raindrops.length - 1; i >= 0; i--) {
const d = raindrops[i];
d.update();
d.draw();
if (d.y + d.length >= height) {
// Hit the bottom: ripple + splashes + note
spawnSplashEffects(d.x);
playRaindropNote();
raindrops.splice(i, 1);
} else if (d.y - d.length > height + 50) {
// Safety clean-up (should rarely happen)
raindrops.splice(i, 1);
}
}
// Update and draw ripples
for (let i = ripples.length - 1; i >= 0; i--) {
const r = ripples[i];
r.update();
r.draw();
if (r.isDone()) {
ripples.splice(i, 1);
}
}
// Update and draw splash particles
for (let i = splashes.length - 1; i >= 0; i--) {
const s = splashes[i];
s.update();
s.draw();
if (s.isDone()) {
splashes.splice(i, 1);
}
}
drawHUD();
}
đź”§ Subcomponents:
while-loop
Raindrop Spawn Control
while (spawnAccumulator >= 1) { createRaindrop(); spawnAccumulator -= 1; }
Smoothly controls raindrop spawn rate by accumulating fractional values and creating drops when the accumulator reaches 1
for-loop
Raindrop Update Loop
for (let i = raindrops.length - 1; i >= 0; i--)
Iterates backward through all raindrops, updating and drawing each one, checking for collisions with the bottom
conditional
Bottom Collision Detection
if (d.y + d.length >= height)
Detects when a raindrop reaches the bottom of the canvas by checking if its bottom edge (y + length) exceeds the canvas height
for-loop
Ripple Update Loop
for (let i = ripples.length - 1; i >= 0; i--)
Updates and draws all ripples, removing them when they're done expanding
for-loop
Splash Particle Update Loop
for (let i = splashes.length - 1; i >= 0; i--)
Updates and draws all splash particles, removing them when they fade out or fall off screen
Line by Line:
image(bgLayer, 0, 0, width, height)
- Draws the pre-rendered gradient background from bgLayer onto the main canvas, filling the entire screen
drawWaterline()
- Calls the function to draw a subtle horizontal line near the bottom representing the water surface
spawnAccumulator += rainIntensity * 0.08
- Adds a fractional amount to the accumulator based on rain intensity. With intensity 4, this adds ~0.32 per frame
while (spawnAccumulator >= 1)
- When the accumulator reaches 1 or more, create a raindrop and subtract 1 from the accumulator. This allows smooth fractional spawning
for (let i = raindrops.length - 1; i >= 0; i--)
- Loops backward through the raindrops array. Backward iteration is crucial because we're removing items with splice() during the loop—removing items while iterating forward would skip elements
d.update()
- Updates the raindrop's position and velocity (applies gravity)
d.draw()
- Draws the raindrop as a line on the canvas
if (d.y + d.length >= height)
- Checks if the raindrop's bottom edge has reached or passed the bottom of the canvas (collision detection)
spawnSplashEffects(d.x)
- Creates ripple and splash particle effects at the raindrop's x position
playRaindropNote()
- Plays a musical note triggered by the raindrop impact
raindrops.splice(i, 1)
- Removes the raindrop from the array since it has hit the bottom
else if (d.y - d.length > height + 50)
- Safety check: if a raindrop somehow goes far below the screen, remove it to prevent memory buildup
if (r.isDone())
- Checks if the ripple has finished expanding by calling its isDone() method
ripples.splice(i, 1)
- Removes the ripple from the array when it's done
if (s.isDone())
- Checks if the splash particle has faded out or fallen off screen
splashes.splice(i, 1)
- Removes the splash particle from the array when it's done
drawHUD()
- Draws the heads-up display with title, intensity level, and audio hint text
function drawHUD() {
noStroke();
fill(210, 30, 90, 0.85); // soft white/blue
textSize(14);
textAlign(LEFT, TOP);
const title = "Raindrop Symphony";
const hintIntensity = "Click to increase rain (" + rainIntensity + "/" + maxRainIntensity + ")";
const hintAudio = audioStarted ? "" : "Click once to start the sound.";
let textToShow = title + "\n" + hintIntensity;
if (hintAudio) {
textToShow += "\n" + hintAudio;
}
text(textToShow, 16, 16);
}
Line by Line:
noStroke()
- Disables stroke for text rendering so only the filled text appears
fill(210, 30, 90, 0.85)
- Sets text color to a soft light blue (hue 210, low saturation 30%, high lightness 90%) with 0.85 alpha for slight transparency
textSize(14)
- Sets the font size to 14 pixels
textAlign(LEFT, TOP)
- Aligns text to the left horizontally and top vertically, so text is positioned from its top-left corner
const title = "Raindrop Symphony"
- Creates the sketch title string
const hintIntensity = "Click to increase rain (" + rainIntensity + "/" + maxRainIntensity + ")"
- Creates a hint string that shows the current rain intensity (e.g., '4/12') by concatenating strings with variable values
const hintAudio = audioStarted ? "" : "Click once to start the sound."
- Uses a ternary operator to show the audio hint only if audio hasn't started yet (audioStarted is false)
let textToShow = title + "\n" + hintIntensity
- Combines the title and intensity hint with a newline character (\n) between them
if (hintAudio) { textToShow += "\n" + hintAudio; }
- If hintAudio is not empty, appends it to textToShow with another newline
text(textToShow, 16, 16)
- Draws the combined text at position (16, 16) pixels from the top-left corner
function createRaindrop() {
const x = random(width);
const y = random(-40, -10);
const speed = random(4, 9);
const length = random(10, 20);
const thickness = random(1, 2);
raindrops.push(new Raindrop(x, y, speed, length, thickness));
}
Line by Line:
const x = random(width)
- Generates a random x position anywhere across the canvas width, so raindrops spawn at different horizontal positions
const y = random(-40, -10)
- Generates a random y position between -40 and -10 pixels (above the top of the canvas), so raindrops start off-screen
const speed = random(4, 9)
- Generates a random initial falling speed between 4 and 9 pixels per frame, creating variation in raindrop speeds
const length = random(10, 20)
- Generates a random raindrop length between 10 and 20 pixels, making raindrops different sizes
const thickness = random(1, 2)
- Generates a random stroke thickness between 1 and 2 pixels for visual variety
raindrops.push(new Raindrop(x, y, speed, length, thickness))
- Creates a new Raindrop object with the random parameters and adds it to the raindrops array
function playRaindropNote() {
if (!audioStarted) return;
const baseFreq = 220; // A3
const scale = [0, 2, 4, 7, 9]; // Major pentatonic steps in semitones
const degree = random(scale);
const octave = random([0, 12]); // 0 or +1 octave in semitones
const semitoneOffset = degree + octave;
const freq = baseFreq * pow(2, semitoneOffset / 12);
const oscType = random(['sine', 'triangle']); // soft waveforms
const osc = new p5.Oscillator(oscType);
osc.freq(freq);
osc.start();
osc.amp(0); // start silent
const env = new p5.Envelope();
// attack, decay, sustainRatio, release (seconds)
env.setADSR(0.01, 0.18, 0.0, 0.25);
env.setRange(0.15, 0); // quiet, gentle
// Connect envelope to oscillator amplitude and play it
osc.amp(env);
env.play();
// Stop oscillator after envelope finishes (~0.46s)
const totalDuration = 0.01 + 0.18 + 0.25 + 0.02;
setTimeout(() => {
osc.stop();
}, totalDuration * 1000);
}
đź”§ Subcomponents:
calculation
Musical Note Frequency Calculation
const freq = baseFreq * pow(2, semitoneOffset / 12)
Converts a semitone offset into a frequency using the equal temperament formula, ensuring musically correct intervals
Line by Line:
if (!audioStarted) return
- Early exit: if audio hasn't been started by user interaction, don't play anything and return from the function
const baseFreq = 220
- Sets the base frequency to 220 Hz, which is the musical note A3 (a common reference pitch)
const scale = [0, 2, 4, 7, 9]
- Defines a major pentatonic scale as semitone offsets from the base frequency. These intervals create a harmonious, pleasant-sounding scale
const degree = random(scale)
- Randomly picks one value from the scale array, ensuring the note is always in the pentatonic scale
const octave = random([0, 12])
- Randomly picks either 0 or 12 semitones, shifting the note up by one octave 50% of the time for variety
const semitoneOffset = degree + octave
- Combines the scale degree and octave shift to get the total semitone offset from the base frequency
const freq = baseFreq * pow(2, semitoneOffset / 12)
- Calculates the final frequency using the equal temperament formula: each semitone is 2^(1/12) times the previous frequency
const oscType = random(['sine', 'triangle'])
- Randomly chooses between sine and triangle waveforms. Sine waves are pure and smooth; triangle waves are slightly brighter but still soft
const osc = new p5.Oscillator(oscType)
- Creates a new oscillator object with the chosen waveform type
osc.freq(freq)
- Sets the oscillator's frequency to the calculated note frequency
osc.start()
- Starts the oscillator generating sound
osc.amp(0)
- Sets the oscillator's amplitude to 0 (silent) initially. The envelope will control the amplitude
const env = new p5.Envelope()
- Creates a new envelope object that will control how the sound's volume changes over time
env.setADSR(0.01, 0.18, 0.0, 0.25)
- Sets the ADSR envelope: Attack 0.01s (quick start), Decay 0.18s (fade to sustain), Sustain ratio 0.0 (no sustain), Release 0.25s (fade out)
env.setRange(0.15, 0)
- Sets the envelope's amplitude range: peak volume 0.15 (quiet), minimum 0 (silent)
osc.amp(env)
- Connects the envelope to the oscillator's amplitude, so the envelope controls the volume
env.play()
- Triggers the envelope to start its ADSR cycle, gradually changing the oscillator's volume
const totalDuration = 0.01 + 0.18 + 0.25 + 0.02
- Calculates total duration of the sound: attack + decay + release + small buffer (0.46 seconds)
setTimeout(() => { osc.stop(); }, totalDuration * 1000)
- Schedules the oscillator to stop after totalDuration milliseconds, preventing audio from playing indefinitely
class Raindrop {
constructor(x, y, speed, length, thickness) {
this.x = x;
this.y = y;
this.speed = speed;
this.length = length;
this.thickness = thickness;
}
update() {
this.y += this.speed;
this.speed += 0.12; // gravity
}
draw() {
stroke(195, 90, 70, 0.9); // bright aqua
strokeWeight(this.thickness);
line(this.x, this.y, this.x, this.y + this.length);
}
}
đź”§ Subcomponents:
calculation
Raindrop Constructor
constructor(x, y, speed, length, thickness)
Initializes a raindrop with position, speed, and visual properties
calculation
Raindrop Physics Update
this.y += this.speed; this.speed += 0.12
Updates position and applies gravity acceleration to create realistic falling motion
Line by Line:
constructor(x, y, speed, length, thickness)
- Constructor method that runs when a new Raindrop is created, accepting position, speed, and visual parameters
this.x = x; this.y = y; this.speed = speed; this.length = length; this.thickness = thickness
- Stores all parameters as properties of the raindrop object so they can be accessed and modified later
this.y += this.speed
- Moves the raindrop down by adding its speed to its y position (larger y = lower on screen)
this.speed += 0.12
- Increases the raindrop's speed by 0.12 each frame, simulating gravity acceleration (raindrops fall faster as they fall)
stroke(195, 90, 70, 0.9)
- Sets the stroke color to bright aqua/cyan (hue 195, saturation 90%, lightness 70%) with 0.9 alpha (mostly opaque)
strokeWeight(this.thickness)
- Sets the line thickness to this raindrop's thickness value (1-2 pixels)
line(this.x, this.y, this.x, this.y + this.length)
- Draws a vertical line from (x, y) to (x, y + length), representing the raindrop
class Ripple {
constructor(x, y) {
this.x = x;
this.y = y;
this.radius = 2;
this.maxRadius = random(20, 60);
}
update() {
this.radius += 1.5;
}
draw() {
const alpha = map(this.radius, 0, this.maxRadius, 0.7, 0);
noFill();
stroke(200, 80, 80, alpha);
strokeWeight(1.5);
// Slightly flattened ellipse to hint at perspective
ellipse(this.x, this.y, this.radius * 2, this.radius * 0.7);
}
isDone() {
return this.radius > this.maxRadius;
}
}
đź”§ Subcomponents:
calculation
Ripple Fade Mapping
const alpha = map(this.radius, 0, this.maxRadius, 0.7, 0)
Maps the ripple's expanding radius to a fading alpha value, so ripples fade out as they expand
Line by Line:
constructor(x, y)
- Constructor that creates a ripple at position (x, y)
this.x = x; this.y = y
- Stores the ripple's center position
this.radius = 2
- Initializes the ripple with a small starting radius of 2 pixels
this.maxRadius = random(20, 60)
- Randomly sets the maximum radius the ripple will expand to (20-60 pixels), creating variation in ripple sizes
this.radius += 1.5
- Increases the ripple's radius by 1.5 pixels each frame, making it expand outward
const alpha = map(this.radius, 0, this.maxRadius, 0.7, 0)
- Uses map() to convert the radius (0 to maxRadius) to an alpha value (0.7 to 0), so the ripple fades as it expands
noFill()
- Disables fill so only the stroke (outline) of the ellipse is drawn
stroke(200, 80, 80, alpha)
- Sets stroke color to light cyan (hue 200) with the calculated alpha, creating a fading effect
strokeWeight(1.5)
- Sets the line thickness to 1.5 pixels
ellipse(this.x, this.y, this.radius * 2, this.radius * 0.7)
- Draws an ellipse (oval) at the ripple's center. The width is radius*2 (diameter), and height is radius*0.7 (flattened), creating a perspective effect
return this.radius > this.maxRadius
- Returns true if the ripple has expanded beyond its maximum radius, indicating it's done
class SplashParticle {
constructor(x, y) {
this.x = x;
this.y = y;
this.vx = random(-1.5, 1.5);
this.vy = random(-3.5, -1.5);
this.alpha = 0.9;
this.radius = random(1.2, 3);
}
update() {
this.x += this.vx;
this.y += this.vy;
this.vy += 0.18; // gravity
this.alpha -= 0.04;
}
draw() {
noStroke();
fill(195, 90, 80, this.alpha);
circle(this.x, this.y, this.radius * 2);
}
isDone() {
return this.alpha <= 0 || this.y > height + 10;
}
}
đź”§ Subcomponents:
calculation
Splash Particle Physics
this.x += this.vx; this.y += this.vy; this.vy += 0.18
Updates particle position with velocity and applies gravity acceleration
Line by Line:
constructor(x, y)
- Constructor that creates a splash particle at position (x, y)
this.x = x; this.y = y
- Stores the particle's starting position
this.vx = random(-1.5, 1.5)
- Sets random horizontal velocity between -1.5 and 1.5 pixels/frame, making particles fly in different horizontal directions
this.vy = random(-3.5, -1.5)
- Sets random vertical velocity between -3.5 and -1.5 pixels/frame (negative = upward), making particles fly upward initially
this.alpha = 0.9
- Initializes the particle with 0.9 alpha (mostly opaque)
this.radius = random(1.2, 3)
- Randomly sets the particle's radius between 1.2 and 3 pixels, creating size variation
this.x += this.vx
- Updates horizontal position by adding horizontal velocity
this.y += this.vy
- Updates vertical position by adding vertical velocity
this.vy += 0.18
- Increases downward velocity by 0.18 each frame, simulating gravity pulling the particle down
this.alpha -= 0.04
- Decreases alpha by 0.04 each frame, making the particle gradually fade out
noStroke()
- Disables stroke so only the filled circle is drawn
fill(195, 90, 80, this.alpha)
- Sets fill color to bright aqua (hue 195) with the particle's current alpha, creating a fading effect
circle(this.x, this.y, this.radius * 2)
- Draws a circle at the particle's current position with diameter radius*2
return this.alpha <= 0 || this.y > height + 10
- Returns true if the particle has faded out (alpha <= 0) or fallen off the bottom of the screen (y > height + 10)