AI Weather Symphony - Dynamic Soundscape Control the weather and create unique soundscapes! Drag to

This sketch turns the browser window into a living weather system where dragging paints wind, clicking spawns rain or snow, holding the mouse charges up lightning, and pressing N flips between day and night. Every weather event is mirrored by generative Tone.js audio layers, and a rotating AI-style text suggestion nudges you toward things to try next.

🧪 Try This!

Experiment with the code by making these changes:

  1. Make lightning easier to trigger — Lowering the hold-time threshold means even a quick click-and-hold will unleash a lightning bolt instead of rain.
  2. Supercharge precipitation bursts — Increasing the particle count per click makes every rain or snow burst feel like an instant downpour or blizzard.
  3. Repaint the daytime sky — Changing the top color of the clear-day gradient instantly recolors the whole sky whenever it's daytime and calm.
Prefer the full editor? Open it there →

📖 About This Sketch

AI Weather Symphony is a full interactive weather sandbox built with p5.js and Tone.js: you drag to create wind currents, click to burst rain, shift-click to burst snow, and hold the mouse to charge and release a lightning bolt, all while a day/night toggle and storminess level blend the sky's gradient, cloud color, and even an aurora. Under the hood it leans on p5.Vector for wind and particle motion, noise() for the shimmering aurora bands, lerpColor() for smooth sky and cloud blending, and classes to manage hundreds of rain, snow, wind and lightning particles at once.

The code is organized around four small classes (PrecipParticle, WindParticle, LightningBolt, Cloud) that each know how to update and draw themselves, plus a draw() loop that calls a sequence of well-named helper functions - drawBackgroundGradient, drawClouds, updateAndDrawWind, updateAndDrawPrecipitation, updateAndDrawLightning, drawGroundAccumulation - in a clear pipeline. Studying it teaches you how to structure a larger p5.js project into classes and helper functions, how mouse-drag gestures can drive a physics-like wind vector, how simple per-column height arrays can simulate accumulating rain and snow, and how to wire visual state (like stormFactor) into live audio parameters with Tone.js gain nodes.

⚙️ How It Works

  1. When the sketch loads, setup() creates a full-window canvas, resets the global wind vector, initializes flat arrays for ground water/snow height, builds a ring of parallax clouds, and wires up all the Tone.js noise generators, filters and synths (though audio only actually starts after your first click, due to browser autoplay rules).
  2. Every frame, draw() first recalculates weather intensities (rain, snow, wind, lightning, and a combined stormFactor) from how many particles exist, then paints a vertical sky gradient that blends day/night colors toward storm-purple based on stormFactor.
  3. It then conditionally draws a noise-based aurora on clear nights, updates and draws the parallax clouds, and loops through every wind particle, raindrop, snowflake and lightning bolt to move and render them, removing any that have expired.
  4. Precipitation particles fall under gravity, drift with the wind, and once they reach the ground curve (getGroundSurfaceY) they add height to a per-column water or snow array, which drawGroundAccumulation renders as growing colored bars and updateAccumulation slowly decays and smooths over time.
  5. Mouse events drive the interaction: mouseDragged blends the wind vector toward your drag direction and spawns short-lived WindParticles, mousePressed starts the audio context and timer, and mouseReleased decides - based on how long you held and how far you moved - whether to spawn a lightning bolt (with screen flash and delayed, distance-based thunder) or a burst of rain/snow particles.
  6. In parallel, updateWeatherState() continuously ramps Tone.js gain nodes toward the current wind/rain/snow/sun levels and periodically rewrites the on-screen AI suggestion text by checking which weather variables are currently dominant.

🎓 Concepts You'll Learn

Classes and object-oriented particle systemsp5.Vector for velocity and wind simulationnoise() for organic aurora motionlerpColor() for day/night and storm blendingTone.js generative audio tied to visual stateMouse gesture detection (drag vs click vs hold)Array-based height maps for ground accumulation

📝 Code Breakdown

class PrecipParticle

This class is the core particle used for both rain and snow, sharing one constructor and update loop but branching on this.type - a common pattern for keeping similar objects in one class instead of duplicating code.

🔬 This is the gravity that pulls rain down 4x faster than snow. What happens if you make the snow gravity (0.06) negative, so flakes rise instead of fall?

    const gravity = this.type === "rain" ? 0.25 : 0.06;
    this.vel.y += gravity;
    this.vel.x += globalWind.x * 0.03;
class PrecipParticle {
  constructor(x, y, type) {
    this.type = type; // 'rain' or 'snow'
    this.pos = createVector(x, y);

    if (type === "rain") {
      const baseVy = random(4, 7);
      this.vel = createVector(globalWind.x * 0.4, baseVy);
      this.size = random(6, 10);
      this.mass = 1.0;
    } else {
      const baseVy = random(1, 3);
      this.vel = createVector(globalWind.x * 0.3, baseVy);
      this.size = random(3, 6);
      this.mass = 0.7;
      this.phase = random(TWO_PI); // side-to-side drift
    }

    this.dead = false;
  }

  update() {
    if (this.dead) return;

    // Gravity + wind influence
    const gravity = this.type === "rain" ? 0.25 : 0.06;
    this.vel.y += gravity;
    this.vel.x += globalWind.x * 0.03;

    if (this.type === "snow") {
      // soft side drift using sin
      this.pos.x += sin(this.phase + this.pos.y * 0.03) * 0.3;
    }

    this.pos.add(this.vel);

    // Ground collision / accumulation
    const groundY = getGroundSurfaceY(this.pos.x);
    if (this.pos.y + this.size * 0.5 >= groundY) {
      this.landAtGround();
    }
  }

  landAtGround() {
    const idx = groundIndexForX(this.pos.x);
    if (idx >= 0 && idx < GROUND_SEGMENTS) {
      if (this.type === "rain") {
        waterHeights[idx] += this.mass * 1.2;
      } else {
        snowHeights[idx] += this.mass * 1.5;
      }
    }
    this.dead = true;
  }

  isDead() {
    return (
      this.dead ||
      this.pos.y > height + 50 ||
      this.pos.x < -50 ||
      this.pos.x > width + 50
    );
  }

  draw() {
    if (this.type === "rain") {
      const len = this.size;
      stroke(200, 230, 255, 230);
      strokeWeight(2);
      line(
        this.pos.x,
        this.pos.y - len * 0.5,
        this.pos.x,
        this.pos.y + len * 0.5
      );
    } else {
      noStroke();
      fill(245, 250, 255, 235);
      ellipse(this.pos.x, this.pos.y, this.size, this.size);
    }
  }
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

conditional Rain vs Snow Setup if (type === "rain") { ... } else { ... }

Gives rain drops faster fall speed and larger size than snowflakes, and gives snow an extra drift phase

conditional Ground Collision Check if (this.pos.y + this.size * 0.5 >= groundY) { this.landAtGround(); }

Detects when a particle reaches the ground curve and converts it into accumulated height

this.pos = createVector(x, y);
Stores the particle's position as a p5.Vector so x/y can be moved together with .add()
this.vel = createVector(globalWind.x * 0.4, baseVy);
Rain starts already nudged sideways by 40% of the current wind, plus a random downward speed
this.phase = random(TWO_PI); // side-to-side drift
Snowflakes get a random starting angle so their sine-wave drift doesn't all sync up
const gravity = this.type === "rain" ? 0.25 : 0.06;
Rain accelerates downward much faster than the lighter, floatier snow
this.pos.x += sin(this.phase + this.pos.y * 0.03) * 0.3;
Adds a gentle side-to-side wobble to snow based on its height and personal phase offset
const groundY = getGroundSurfaceY(this.pos.x);
Looks up the current top of the water+snow pile beneath this particle's x position
waterHeights[idx] += this.mass * 1.2;
When a raindrop lands, it raises the ground's water height array at that column by an amount based on its mass

class WindParticle

WindParticle is a short-lived visual streak spawned by mouseDragged() to make invisible wind forces visible, fading out over its randomized lifespan.

🔬 What happens if you change the friction multiplier 0.98 to something lower like 0.9? Try it and watch how quickly the wind streaks slow to a stop.

    this.pos.add(this.vel);
    // wind influences stream over time
    this.vel.add(p5.Vector.mult(globalWind, 0.02));
    this.vel.mult(0.98);
class WindParticle {
  constructor(x, y, vel) {
    this.pos = createVector(x, y);
    this.vel = vel.copy();
    this.life = int(random(40, 80));
    this.age = 0;
  }

  update() {
    this.pos.add(this.vel);
    // wind influences stream over time
    this.vel.add(p5.Vector.mult(globalWind, 0.02));
    this.vel.mult(0.98);
    this.age++;
  }

  isDead() {
    return (
      this.age > this.life ||
      this.pos.x < -50 ||
      this.pos.x > width + 50 ||
      this.pos.y < -50 ||
      this.pos.y > height + 50
    );
  }

  draw() {
    const alpha = map(this.age, 0, this.life, 220, 0);
    stroke(200, 240, 255, alpha);
    strokeWeight(1);
    line(
      this.pos.x,
      this.pos.y,
      this.pos.x - this.vel.x * 2,
      this.pos.y - this.vel.y * 2
    );
  }
}
Line-by-line explanation (5 lines)
this.life = int(random(40, 80));
Each streak gets a random lifespan between 40 and 80 frames before it disappears
this.vel.add(p5.Vector.mult(globalWind, 0.02));
The streak's velocity is nudged toward the current global wind every frame, so old streaks curve with new wind changes
this.vel.mult(0.98);
Applies gentle friction so streaks slow down slightly over their lifetime
const alpha = map(this.age, 0, this.life, 220, 0);
Fades the streak's opacity from 220 down to 0 as it ages, creating a trail-like fade-out
this.pos.x - this.vel.x * 2,
Draws the line backward from the particle's position using its velocity, so the streak points in the direction it came from

class LightningBolt

LightningBolt procedurally generates a jagged path once in buildPath(), then simply fades it out over time in draw() - a common technique for one-shot generative effects that don't need to be recalculated every frame.

🔬 energy ranges from 0.1 to 1. What happens if you multiply maxSpread by a much bigger number, like 40 -> 150, making even weak charges produce wildly zigzagging bolts?

    const segments = int(10 + this.energy * 14);
    const maxSpread = 30 + this.energy * 40;
class LightningBolt {
  constructor(x, energy) {
    this.energy = constrain(energy, 0.1, 1); // 0..1
    this.life = 40; // frames
    this.age = 0;
    this.points = [];
    this.buildPath(x);
  }

  buildPath(startX) {
    let x = startX;
    let y = 0;
    this.points.push(createVector(x, y));

    const segments = int(10 + this.energy * 14);
    const maxSpread = 30 + this.energy * 40;

    for (let i = 0; i < segments; i++) {
      y += height / segments + random(-10, 10);
      x += random(-maxSpread, maxSpread);
      this.points.push(createVector(x, y));
    }
  }

  update() {
    this.age++;
  }

  isDead() {
    return this.age > this.life;
  }

  draw() {
    const alpha = map(this.age, 0, this.life, 255, 0);
    const coreWeight = 2 + this.energy * 3;
    const glowWeight = 6 + this.energy * 6;

    // Glow
    stroke(180, 200, 255, alpha * 0.6);
    strokeWeight(glowWeight);
    noFill();
    beginShape();
    for (let p of this.points) vertex(p.x, p.y);
    endShape();

    // Core bolt
    stroke(255, 255, 255, alpha);
    strokeWeight(coreWeight);
    beginShape();
    for (let p of this.points) vertex(p.x, p.y);
    endShape();
  }
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

for-loop Zigzag Path Builder for (let i = 0; i < segments; i++) { ... }

Generates a jagged path of points from top to bottom by randomly offsetting x and y each step

calculation Glow + Core Rendering beginShape(); for (let p of this.points) vertex(p.x, p.y); endShape();

Draws the same zigzag path twice - once thick and dim for glow, once thin and bright for the core

this.energy = constrain(energy, 0.1, 1); // 0..1
Stores how 'charged' the bolt is (0.1 minimum so even quick clicks make a visible bolt), based on how long the mouse was held
const segments = int(10 + this.energy * 14);
More energetic bolts get more zigzag segments, making them look longer and more detailed
y += height / segments + random(-10, 10);
Steps the bolt downward by an even fraction of the canvas height, with a little randomness so segments aren't perfectly uniform
const alpha = map(this.age, 0, this.life, 255, 0);
Fades the bolt from fully opaque to invisible over its 40-frame lifetime

class Cloud

Cloud demonstrates parallax scrolling: a single 'layer' value (0 to 1) controls size, speed, wind sensitivity and color all at once, giving a convincing sense of depth with very little code.

🔬 This is how wind pushes clouds. What happens if you increase the 0.04 multiplier to 0.2, making clouds react much more strongly to your mouse drags?

    const windInfluence = globalWind.x * 0.04 * (0.4 + this.layer);
    this.x += (this.speedBase + windInfluence);
class Cloud {
  constructor(layer) {
    // layer: 0 (far) to 1 (near)
    this.layer = layer;
    this.reset(random(width), random(height * 0.05, height * 0.45));
  }

  reset(x, y) {
    this.x = x;
    this.baseY = y;
    this.y = y;
    this.scale = lerp(0.6, 1.6, this.layer);
    this.w = random(140, 260) * this.scale;
    this.h = random(50, 100) * this.scale;
    this.speedBase = lerp(0.15, 0.5, this.layer);
    this.noiseOffset = random(1000);
  }

  update() {
    // Drift horizontally, affected by wind
    const windInfluence = globalWind.x * 0.04 * (0.4 + this.layer);
    this.x += (this.speedBase + windInfluence);

    // Soft vertical bobbing
    this.y =
      this.baseY +
      sin(frameCount * 0.01 + this.noiseOffset) * 6 * this.layer;

    // Let storminess pull clouds lower
    const stormDrop = lerp(0, height * 0.12, stormFactor) * this.layer;
    this.y += stormDrop;

    const margin = this.w * 1.3;
    if (this.x > width + margin) {
      this.reset(-margin, random(height * 0.05, height * 0.45));
    } else if (this.x < -margin) {
      this.reset(width + margin, random(height * 0.05, height * 0.45));
    }
  }

  draw() {
    // Cloud color depends on time of day and storminess
    const clearDay = color(255, 255, 255);
    const storm = color(120, 120, 140);
    const clearNight = color(210, 220, 255);
    const darkNight = color(70, 80, 120);

    let base =
      isNight
        ? lerpColor(clearNight, darkNight, stormFactor)
        : lerpColor(clearDay, storm, stormFactor);

    const alpha = lerp(80, 210, 0.3 + 0.5 * this.layer * (0.4 + stormFactor));
    const c = color(
      red(base),
      green(base),
      blue(base),
      alpha
    );

    noStroke();
    fill(c);

    // Multi-lobed cloud made of overlapping ellipses
    const segments = 6;
    for (let i = 0; i < segments; i++) {
      const t = map(i, 0, segments - 1, -1, 1);
      const ex = this.x + t * this.w * 0.4;
      const ey = this.y + sin(t * PI) * this.h * 0.15;
      const ew = this.w * random(0.5, 0.8);
      const eh = this.h * random(0.6, 1.0);
      ellipse(ex, ey, ew, eh);
    }
  }
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

conditional Screen Wrap Reset if (this.x > width + margin) { ... } else if (this.x < -margin) { ... }

Recycles a cloud to the opposite edge once it drifts fully off screen

for-loop Multi-Lobe Ellipse Drawing for (let i = 0; i < segments; i++) { ... ellipse(ex, ey, ew, eh); }

Draws 6 overlapping ellipses spread across the cloud's width to fake a fluffy, irregular cloud shape

this.scale = lerp(0.6, 1.6, this.layer);
Clouds in the near layer (layer close to 1) are drawn bigger than distant background clouds, creating a parallax depth illusion
const windInfluence = globalWind.x * 0.04 * (0.4 + this.layer);
Near-layer clouds are pushed more strongly by wind than far-layer clouds, reinforcing the depth effect
const stormDrop = lerp(0, height * 0.12, stormFactor) * this.layer;
As stormFactor rises, clouds sink lower on screen, looking heavier and more ominous
let base = isNight ? lerpColor(clearNight, darkNight, stormFactor) : lerpColor(clearDay, storm, stormFactor);
Picks a base cloud color by blending between two colors based on both day/night state and how stormy it currently is

setup()

setup() runs once when the page loads. Here it prepares all the shared global arrays and objects the rest of the sketch depends on, and kicks off audio setup (though sound itself waits for a user gesture).

function setup() {
  createCanvas(windowWidth, windowHeight);
  globalWind = createVector(0, 0);

  waterHeights = new Array(GROUND_SEGMENTS).fill(0);
  snowHeights = new Array(GROUND_SEGMENTS).fill(0);

  initClouds();

  textFont("sans-serif");
  setupAudio();
}
Line-by-line explanation (5 lines)
createCanvas(windowWidth, windowHeight);
Creates a canvas that fills the entire browser window
globalWind = createVector(0, 0);
Starts with no wind at all - it will only build up once you start dragging
waterHeights = new Array(GROUND_SEGMENTS).fill(0);
Creates an array of 80 zeros, one per ground column, to track how much water has accumulated there
initClouds();
Builds the initial set of parallax clouds before the first frame is drawn
setupAudio();
Builds every Tone.js noise generator, filter, synth and loop up front so they're ready the moment audio is allowed to start

initClouds()

This helper both builds the initial clouds in setup() and rebuilds them in windowResized(), avoiding duplicated code.

function initClouds() {
  clouds = [];
  for (let i = 0; i < NUM_CLOUDS; i++) {
    const layer = map(i, 0, NUM_CLOUDS - 1, 0.1, 1);
    clouds.push(new Cloud(layer));
  }
}
Line-by-line explanation (2 lines)

🔧 Subcomponents:

for-loop Layer Assignment Loop for (let i = 0; i < NUM_CLOUDS; i++) { ... }

Creates NUM_CLOUDS clouds, spreading their 'layer' (depth) value evenly from 0.1 (far) to 1 (near)

clouds = [];
Empties the clouds array first, which matters because windowResized() calls this again
const layer = map(i, 0, NUM_CLOUDS - 1, 0.1, 1);
Distributes each cloud's depth evenly, so the first cloud is far (0.1) and the last is near (1.0)

setupAudio()

This function builds the entire audio graph once at startup: noise sources feed into filters, filters feed into gain nodes, and gain nodes are what updateWeatherState() ramps up and down to match the visuals - separating 'build the instruments' from 'play the instruments'.

function setupAudio() {
  if (typeof Tone === "undefined") {
    console.warn("Tone.js not loaded; audio will be disabled.");
    return;
  }

  // Ambient noises
  windNoise = new Tone.Noise("pink");
  rainNoise = new Tone.Noise("white");
  thunderNoise = new Tone.Noise("brown");

  windGain = new Tone.Gain(0).toDestination();
  rainGain = new Tone.Gain(0).toDestination();
  thunderGain = new Tone.Gain(0).toDestination();
  sunGain = new Tone.Gain(0).toDestination();
  snowGain = new Tone.Gain(0).toDestination();

  // Wind: gently sweeping filter
  const windFilter = new Tone.AutoFilter({
    frequency: 0.15,
    depth: 0.9,
    baseFrequency: 200,
    octaves: 1.5
  }).start();
  windNoise.connect(windFilter);
  windFilter.connect(windGain);

  // Rain: bright high‑passed noise
  const rainFilter = new Tone.Filter(1500, "highpass");
  rainNoise.connect(rainFilter);
  rainFilter.connect(rainGain);

  // Thunder: deep low‑passed noise
  const thunderFilter = new Tone.Filter(120, "lowpass");
  thunderNoise.connect(thunderFilter);
  thunderFilter.connect(thunderGain);

  // Reverb for melodic layers
  const reverb = new Tone.Reverb({
    decay: 6,
    wet: 0.4
  }).toDestination();

  // Sunny pad / melody
  sunSynth = new Tone.PolySynth(Tone.Synth);
  sunSynth.set({
    oscillator: { type: "sine" },
    envelope: { attack: 0.5, decay: 0.3, sustain: 0.6, release: 2.5 }
  });
  sunSynth.connect(sunGain);
  sunGain.connect(reverb);

  // Snowy chimes / pads
  snowSynth = new Tone.PolySynth(Tone.Synth);
  snowSynth.set({
    oscillator: { type: "triangle" },
    envelope: { attack: 1.0, decay: 0.5, sustain: 0.7, release: 4.0 }
  });
  snowSynth.connect(snowGain);
  snowGain.connect(reverb);

  // Clear-weather melody loop
  melodyLoop = new Tone.Loop(time => {
    const scale = ["C4", "E4", "G4", "B4", "D5"];
    const note = scale[int(random(scale.length))];
    sunSynth.triggerAttackRelease(note, "4n", time, 0.5);
  }, "2n");

  // Snowy sparkle loop
  snowLoop = new Tone.Loop(time => {
    const scale = ["A3", "C4", "D4", "E4", "G4"];
    const note = scale[int(random(scale.length))];
    snowSynth.triggerAttackRelease(note, "8n", time, 0.35);
  }, "4n");
}
Line-by-line explanation (4 lines)
if (typeof Tone === "undefined") { ... return; }
Safety check so the sketch doesn't crash if Tone.js failed to load - it just disables audio
windNoise = new Tone.Noise("pink");
Creates a pink-noise generator (softer, more natural hiss) that becomes the base of the wind sound
const windFilter = new Tone.AutoFilter({ frequency: 0.15, depth: 0.9, baseFrequency: 200, octaves: 1.5 }).start();
An auto-sweeping filter that rhythmically opens and closes to make plain noise sound like gusting wind
melodyLoop = new Tone.Loop(time => { ... }, "2n");
Defines a repeating musical loop that picks a random note from a pentatonic scale every half note, played by the sunny synth

startAudioIfNeeded()

Browsers block audio from playing until a user interacts with the page, so this function is called from mousePressed() to satisfy that requirement exactly once.

async function startAudioIfNeeded() {
  if (audioStarted || typeof Tone === "undefined") return;
  try {
    await Tone.start();
    windNoise.start();
    rainNoise.start();
    Tone.Transport.start();
    melodyLoop.start();
    snowLoop.start();
    audioStarted = true;
    console.log("Audio started");
  } catch (e) {
    console.error("Audio init failed", e);
  }
}
Line-by-line explanation (3 lines)
if (audioStarted || typeof Tone === "undefined") return;
Prevents starting audio more than once, and bails out safely if Tone.js isn't available
await Tone.start();
Unlocks the browser's audio context, which by policy can only happen in response to a real user click
Tone.Transport.start();
Starts Tone's global clock, which drives the melodyLoop and snowLoop timing

draw()

draw() is deliberately kept short and readable - it's a checklist of 'update state, then draw layers back-to-front', which is a great pattern to copy in your own multi-layered sketches.

function draw() {
  updateWeatherState();
  drawBackgroundGradient();

  if (shouldDrawAurora()) {
    drawAurora();
  }

  drawClouds();

  updateAndDrawWind();
  updateAndDrawPrecipitation();
  updateAndDrawLightning();
  drawGroundAccumulation();

  applyLightningFlash();

  // Lightning charging indicator (on top of scene)
  if (mouseIsPressed && !mouseHasDraggedFar) {
    drawLightningChargeIndicator();
  }

  drawUI();
}
Line-by-line explanation (6 lines)

🔧 Subcomponents:

conditional Aurora Gate if (shouldDrawAurora()) { drawAurora(); }

Only draws the aurora bands on clear nights, keeping it hidden during storms or daytime

conditional Lightning Charge Indicator Gate if (mouseIsPressed && !mouseHasDraggedFar) { drawLightningChargeIndicator(); }

Shows the charging rings only while the mouse is held down and hasn't turned into a wind drag

updateWeatherState();
Recalculates all weather intensity numbers and audio gain levels before anything is drawn this frame
drawBackgroundGradient();
Paints the full-screen sky gradient, effectively clearing the previous frame
drawClouds();
Moves and renders every cloud in the parallax layer system
drawGroundAccumulation();
Draws the growing water and snow bars along the bottom of the screen
applyLightningFlash();
Overlays a fading white rectangle if lightning recently struck, simulating a screen flash
drawUI();
Draws the instructions box and the AI suggestion box on top of everything else

updateWeatherState()

This function is the bridge between raw particle data and both visuals (stormFactor drives sky color) and audio (gain levels) - a good example of deriving a few clean 'summary' variables from messy underlying arrays.

🔬 lightningIntensity is weighted 1.2x here. What happens visually and sonically if you weight it much higher, like 3, so a single lightning bolt makes the whole sky look stormy?

  stormFactor = constrain(
    rainIntensity + snowIntensity + lightningIntensity * 1.2,
    0,
    1
  );
function updateWeatherState() {
  rainIntensity = constrain(rainDrops.length / 300, 0, 1);
  snowIntensity = constrain(snowflakes.length / 300, 0, 1);
  windIntensity = constrain(globalWind.mag() / 20, 0, 1);

  let energy = 0;
  for (let b of lightningBolts) {
    const lifeFactor = 1 - b.age / b.life;
    energy += b.energy * max(0, lifeFactor);
  }
  lightningIntensity = constrain(energy, 0, 1);

  stormFactor = constrain(
    rainIntensity + snowIntensity + lightningIntensity * 1.2,
    0,
    1
  );

  // Tie audio layers to weather
  if (audioStarted && windGain) {
    windGain.gain.rampTo(windIntensity * 0.7, 0.5);
    rainGain.gain.rampTo(rainIntensity * 0.9, 0.5);
    snowGain.gain.rampTo(snowIntensity * 0.7, 1.0);

    const clearAmount = 1 - stormFactor;
    const sunLevel = isNight ? clearAmount * 0.15 : clearAmount * 0.5;
    sunGain.gain.rampTo(sunLevel, 1.0);
  }

  updateAccumulation();
  updateAISuggestionIfNeeded();
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

for-loop Active Lightning Energy Sum for (let b of lightningBolts) { ... }

Adds up the remaining 'charge' of every still-visible lightning bolt to compute a combined lightningIntensity value

conditional Audio Gain Ramping if (audioStarted && windGain) { ... }

Only touches Tone.js gain nodes once audio has actually been unlocked, avoiding errors on undefined objects

rainIntensity = constrain(rainDrops.length / 300, 0, 1);
Converts a raw particle count into a normalized 0-1 intensity, capping out once there are 300+ raindrops
const lifeFactor = 1 - b.age / b.life;
A bolt contributes full energy when freshly spawned (age 0) and fades to zero contribution as it approaches its life limit
stormFactor = constrain(rainIntensity + snowIntensity + lightningIntensity * 1.2, 0, 1);
Combines all weather types into one 0-1 'how stormy is it' number, weighting lightning slightly heavier
windGain.gain.rampTo(windIntensity * 0.7, 0.5);
Smoothly glides the wind sound's volume toward a target level over 0.5 seconds rather than jumping abruptly

updateAISuggestionIfNeeded()

This is a simple rule-based 'AI' - a cascading if/else chain checking weather variables in priority order, showing that you don't need machine learning to create the illusion of intelligent commentary.

function updateAISuggestionIfNeeded() {
  const now = millis();
  if (now - lastSuggestionChange < SUGGESTION_INTERVAL) return;
  lastSuggestionChange = now;

  const totalAccum = averageArray(waterHeights) + averageArray(snowHeights);

  if (stormFactor < 0.1) {
    currentSuggestion =
      "Skies are calm. Drag to paint wind streams or click to start a gentle rain.";
  } else if (rainIntensity > 0.5 && windIntensity < 0.3) {
    currentSuggestion =
      "Heavy rain, but the air is still. Drag across the sky to let the wind steer the storm.";
  } else if (windIntensity > 0.5 && rainIntensity + snowIntensity < 0.3) {
    currentSuggestion =
      "Strong winds over dry ground. Add a rain click or Shift+Click for snow to ride the gusts.";
  } else if (snowIntensity > 0.4 && !isNight) {
    currentSuggestion =
      "Snowy daylight. Press N for night and see how the flakes glow against a darker sky.";
  } else if (snowIntensity > 0.4 && isNight && stormFactor < 0.5) {
    currentSuggestion =
      "Quiet snowy night. Keep things clear to let the aurora shimmer, or add lightning for drama.";
  } else if (lightningIntensity > 0.3 && rainIntensity < 0.3) {
    currentSuggestion =
      "A dry electrical storm. Click to add rain and turn the flashes into a full thunderstorm.";
  } else if (totalAccum > 40 && rainIntensity > snowIntensity) {
    currentSuggestion =
      "The ground is soaked. Try switching to snow (Shift+Click) for softer accumulation.";
  } else if (totalAccum > 40 && snowIntensity > rainIntensity) {
    currentSuggestion =
      "Snow is piling up. Use wind drags to sculpt drifting patterns and carve shapes in the snow.";
  } else {
    currentSuggestion =
      "Keep playing with the balance of wind, rain, snow, and lightning to shape the symphony.";
  }
}
Line-by-line explanation (2 lines)

🔧 Subcomponents:

conditional Interval Gate if (now - lastSuggestionChange < SUGGESTION_INTERVAL) return;

Limits suggestion text changes to once every SUGGESTION_INTERVAL milliseconds so it doesn't flicker every frame

conditional Weather Rule Cascade if (stormFactor < 0.1) { ... } else if (...) { ... }

Checks weather variables in priority order to pick the single most relevant tip to display

if (now - lastSuggestionChange < SUGGESTION_INTERVAL) return;
Exits early unless enough time has passed, so this only runs its expensive checks occasionally
const totalAccum = averageArray(waterHeights) + averageArray(snowHeights);
Averages the ground height arrays to get one number representing how much has accumulated overall

drawBackgroundGradient()

p5.js has no built-in gradient function, so this manually blends colors row by row with lerpColor() - a technique you can reuse any time you need a smooth color transition.

🔬 This loop runs once per pixel row. What happens if you change the step to skip rows, like 'for (let y = 0; y < height; y += 4)', drawing thicker bands instead?

  for (let y = 0; y < height; y++) {
    const t = y / max(1, height - 1);
    const c = lerpColor(top, bottom, t);
    stroke(c);
    line(0, y, width, y);
  }
function drawBackgroundGradient() {
  // Day / night base colors
  const clearDayTop = color(120, 190, 255);
  const clearDayBottom = color(255, 220, 150); // golden
  const nightTop = color(10, 15, 40);
  const nightBottom = color(5, 5, 15);

  // Storm purple overlay
  const stormTop = color(40, 20, 70);
  const stormBottom = color(80, 40, 90);

  const baseTop = isNight ? nightTop : clearDayTop;
  const baseBottom = isNight ? nightBottom : clearDayBottom;

  const top = lerpColor(baseTop, stormTop, stormFactor);
  const bottom = lerpColor(baseBottom, stormBottom, stormFactor);

  // Vertical gradient using lines
  for (let y = 0; y < height; y++) {
    const t = y / max(1, height - 1);
    const c = lerpColor(top, bottom, t);
    stroke(c);
    line(0, y, width, y);
  }
}
Line-by-line explanation (3 lines)

🔧 Subcomponents:

for-loop Per-Row Gradient Line for (let y = 0; y < height; y++) { ... line(0, y, width, y); }

Draws one horizontal line per pixel row, each blended slightly further toward the bottom color, creating a smooth vertical gradient

const top = lerpColor(baseTop, stormTop, stormFactor);
Blends the clear-sky top color toward a stormy purple, based on how stormy it currently is
const t = y / max(1, height - 1);
Converts the current row number into a 0-to-1 fraction of the way down the screen
const c = lerpColor(top, bottom, t);
Picks the exact color for this row by blending between the top and bottom colors using that fraction

shouldDrawAurora()

Pulling this condition into its own named function makes draw() read like plain English ('if should draw aurora, draw aurora') instead of burying the logic inline.

function shouldDrawAurora() {
  // Clear night with relatively low storminess
  return isNight && stormFactor < 0.35;
}
Line-by-line explanation (1 lines)
return isNight && stormFactor < 0.35;
The aurora only shows up when it's night AND the weather is relatively calm - both conditions must be true

drawAurora()

This function is a great introduction to using noise() for organic motion: instead of random() which jumps unpredictably, noise() produces smooth, wave-like values perfect for natural-looking animation.

🔬 The '3' here controls how many noise 'waves' fit across the screen width. What happens if you change nx * 3 to nx * 10 - do the aurora bands get calmer or more chaotic?

    for (let x = 0; x <= width; x += 30) {
      const nx = x / width;
      const n = noise(nx * 3 + b * 10, t + b * 50);
function drawAurora() {
  noStroke();
  const t = frameCount * 0.01;
  const bands = 3;

  for (let b = 0; b < bands; b++) {
    const alpha = 70 - b * 15;
    const col =
      b % 2 === 0
        ? color(120, 255, 200, alpha)
        : color(180, 190, 255, alpha);
    fill(col);
    beginShape();
    for (let x = 0; x <= width; x += 30) {
      const nx = x / width;
      const n = noise(nx * 3 + b * 10, t + b * 50);
      const y = map(
        n,
        0,
        1,
        height * 0.02 + b * 10,
        height * 0.18 + b * 15
      );
      vertex(x, y);
    }
    vertex(width, 0);
    vertex(0, 0);
    endShape(CLOSE);
  }
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

for-loop Aurora Bands Loop for (let b = 0; b < bands; b++) { ... }

Draws 3 overlapping ribbon shapes in alternating colors to build up the layered aurora effect

for-loop Noise-Driven Vertex Loop for (let x = 0; x <= width; x += 30) { ... vertex(x, y); }

Samples Perlin noise across the width of the screen to generate a smoothly wavy top edge for each aurora band

const t = frameCount * 0.01;
A slowly increasing time value used to animate the noise field over time, making the aurora shimmer and shift
const n = noise(nx * 3 + b * 10, t + b * 50);
Samples 2D Perlin noise using x-position and time as inputs, offset per band so each band moves differently
const y = map(n, 0, 1, height * 0.02 + b * 10, height * 0.18 + b * 15);
Converts the 0-1 noise value into a y-position near the top of the screen, so the band wavers up and down smoothly
endShape(CLOSE);
Closes the shape back to its starting point, filling in the ribbon between the wavy top edge and the top of the screen

drawClouds()

This tiny function shows the classic 'update then draw' pattern applied to a whole array of objects - the same shape repeats for wind particles, rain, snow, and lightning elsewhere in the sketch.

function drawClouds() {
  for (let c of clouds) {
    c.update();
    c.draw();
  }
}
Line-by-line explanation (3 lines)

🔧 Subcomponents:

for-loop Update-and-Draw Loop for (let c of clouds) { c.update(); c.draw(); }

Moves and renders every cloud object each frame

for (let c of clouds) {
Loops through every Cloud object stored in the global clouds array
c.update();
Moves this cloud according to its own speed, wind influence, and storm drop
c.draw();
Renders this cloud's overlapping ellipses at its current position

updateAndDrawWind()

The backward-for-loop-with-splice pattern used here is the standard, safe way to remove items from an array while iterating over it in JavaScript.

function updateAndDrawWind() {
  // Wind gradually decays
  globalWind.mult(0.98);

  for (let i = windParticles.length - 1; i >= 0; i--) {
    const p = windParticles[i];
    p.update();
    p.draw();
    if (p.isDead()) {
      windParticles.splice(i, 1);
    }
  }
}
Line-by-line explanation (3 lines)

🔧 Subcomponents:

for-loop Backward Cleanup Loop for (let i = windParticles.length - 1; i >= 0; i--) { ... }

Updates and draws every wind particle, removing dead ones - looping backward so splice() doesn't skip elements

globalWind.mult(0.98);
Slowly decays the wind strength every frame so it settles back to calm if you stop dragging
for (let i = windParticles.length - 1; i >= 0; i--) {
Iterates backward through the array - essential because splice() shifts later indices, which would break a forward loop
if (p.isDead()) { windParticles.splice(i, 1); }
Removes this particle from the array once its lifespan is over, keeping the array from growing forever

updateAndDrawPrecipitation()

This function mirrors updateAndDrawWind() but for two separate arrays, showing how the same particle-management pattern scales to multiple particle types sharing one class (PrecipParticle).

function updateAndDrawPrecipitation() {
  // Rain
  for (let i = rainDrops.length - 1; i >= 0; i--) {
    const d = rainDrops[i];
    d.update();
    d.draw();
    if (d.isDead()) {
      rainDrops.splice(i, 1);
    }
  }

  // Snow
  for (let i = snowflakes.length - 1; i >= 0; i--) {
    const s = snowflakes[i];
    s.update();
    s.draw();
    if (s.isDead()) {
      snowflakes.splice(i, 1);
    }
  }
}
Line-by-line explanation (3 lines)

🔧 Subcomponents:

for-loop Rain Update Loop for (let i = rainDrops.length - 1; i >= 0; i--) { ... }

Updates, draws, and removes finished raindrop particles

for-loop Snow Update Loop for (let i = snowflakes.length - 1; i >= 0; i--) { ... }

Updates, draws, and removes finished snowflake particles

const d = rainDrops[i];
Grabs a reference to the raindrop at this index so we can call its methods
d.update();
Applies gravity, wind, and ground-collision logic to move this raindrop
if (d.isDead()) { rainDrops.splice(i, 1); }
Cleans up raindrops that have landed or drifted off-screen

updateAndDrawLightning()

Because lightning bolts are capped at MAX_LIGHTNING and auto-expire after 40 frames, this function keeps the array small and cheap to render even during a busy storm.

function updateAndDrawLightning() {
  for (let i = lightningBolts.length - 1; i >= 0; i--) {
    const b = lightningBolts[i];
    b.update();
    b.draw();
    if (b.isDead()) {
      lightningBolts.splice(i, 1);
    }
  }
}
Line-by-line explanation (2 lines)

🔧 Subcomponents:

for-loop Lightning Cleanup Loop for (let i = lightningBolts.length - 1; i >= 0; i--) { ... }

Ages, draws, and removes expired lightning bolts

b.update();
Increments the bolt's age by one frame, moving it closer to its 40-frame lifespan
if (b.isDead()) { lightningBolts.splice(i, 1); }
Removes the bolt once it has fully faded out

drawGroundAccumulation()

This turns two simple number arrays (waterHeights, snowHeights) into a visual bar chart along the bottom of the screen - a useful technique any time you want to visualize accumulating data as growing shapes.

function drawGroundAccumulation() {
  const segW = width / GROUND_SEGMENTS;
  noStroke();

  // Base ground strip, darkened slightly by storm
  const dryGround = color(50, 60, 40);
  const wetGround = color(30, 35, 45);
  const groundCol = lerpColor(dryGround, wetGround, stormFactor);
  fill(groundCol);
  rect(0, height - 18, width, 18);

  for (let i = 0; i < GROUND_SEGMENTS; i++) {
    const waterH = waterHeights[i];
    const snowH = snowHeights[i];

    if (waterH > 0.1) {
      fill(80, 140, 210, 190);
      rect(i * segW, height - 18 - waterH, segW + 1, waterH);
    }
    if (snowH > 0.1) {
      fill(245, 250, 255, 230);
      rect(i * segW, height - 18 - waterH - snowH, segW + 1, snowH);
    }
  }
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

for-loop Per-Column Height Bars for (let i = 0; i < GROUND_SEGMENTS; i++) { ... }

Draws one water bar and one snow bar per ground column, stacking snow on top of water

const segW = width / GROUND_SEGMENTS;
Calculates how wide each ground column should be so all segments together span the full canvas width
rect(0, height - 18, width, 18);
Draws a plain base strip along the very bottom of the screen representing bare ground
rect(i * segW, height - 18 - waterH, segW + 1, waterH);
Draws a blue rectangle whose height equals the accumulated water at this column, growing upward from the ground
rect(i * segW, height - 18 - waterH - snowH, segW + 1, snowH);
Draws the snow bar stacked on top of the water bar so snow visually piles up above any standing water

groundIndexForX()

This helper converts any x-coordinate on screen into 'which ground column is this above', used both when particles land and when checking ground height beneath them.

function groundIndexForX(x) {
  const clampedX = constrain(x, 0, width - 1);
  const idx = floor(map(clampedX, 0, width, 0, GROUND_SEGMENTS));
  return constrain(idx, 0, GROUND_SEGMENTS - 1);
}
Line-by-line explanation (2 lines)
const clampedX = constrain(x, 0, width - 1);
Makes sure the x coordinate can't go outside the canvas before converting it to an index
const idx = floor(map(clampedX, 0, width, 0, GROUND_SEGMENTS));
Maps the x position from pixel space (0 to width) into column space (0 to GROUND_SEGMENTS), then rounds down to a whole index

getGroundSurfaceY()

This is what PrecipParticle.update() checks against to decide when a falling particle should 'land' - the ground surface effectively rises as more particles accumulate.

function getGroundSurfaceY(x) {
  const idx = groundIndexForX(x);
  const h = waterHeights[idx] + snowHeights[idx];
  const baseY = height - 18; // top of ground strip
  return baseY - h;
}
Line-by-line explanation (2 lines)
const h = waterHeights[idx] + snowHeights[idx];
Combines water and snow height at this column into one total accumulation height
return baseY - h;
Subtracts the accumulated height from the ground's base y-position, since higher piles push the surface upward (smaller y value)

updateAccumulation()

This function shows exponential decay (multiplying by a value slightly less than 1 every frame) combined with spatial smoothing - two common techniques for making simulated data look natural over time.

🔬 These numbers are all very close to 1 - that's intentional so decay is gradual. What happens if you drop waterDamping to something like 0.9 so puddles vanish almost instantly?

  const waterDamping = isNight ? 0.9995 : 0.998;
  const snowDamping = isNight ? 0.9998 : 0.9993;
function updateAccumulation() {
  // Slight decay (evaporation/melting) + lateral smoothing
  const waterDamping = isNight ? 0.9995 : 0.998;
  const snowDamping = isNight ? 0.9998 : 0.9993;

  for (let i = 0; i < GROUND_SEGMENTS; i++) {
    waterHeights[i] *= waterDamping;
    snowHeights[i] *= snowDamping;
  }

  smoothArray(waterHeights, 0.3);
  smoothArray(snowHeights, 0.2);
}
Line-by-line explanation (3 lines)

🔧 Subcomponents:

for-loop Decay Loop for (let i = 0; i < GROUND_SEGMENTS; i++) { ... }

Slightly shrinks every column's water and snow height each frame, simulating evaporation or melting

const waterDamping = isNight ? 0.9995 : 0.998;
Water evaporates a bit slower at night (0.9995 is closer to 1, meaning less decay) than during the day
waterHeights[i] *= waterDamping;
Multiplying by a number just under 1 each frame causes slow, exponential decay over time
smoothArray(waterHeights, 0.3);
Blends each column slightly toward its neighbors' heights, softening sharp spikes from concentrated rain bursts

smoothArray()

This is a simple 1D blur filter - the same averaging idea used in image blurring, applied here to a height array instead of pixels, to make rain and snow piles look like continuous drifts rather than jagged individual columns.

🔬 amt controls how strongly values blend toward their neighbors' average. What happens to the ground shape if smoothArray(waterHeights, 0.3) is called with amt = 1 (full smoothing) instead?

    const avg = (left + center + right) / 3;
    arr[i] = lerp(center, avg, amt);
function smoothArray(arr, amt) {
  const tmp = arr.slice();
  for (let i = 0; i < arr.length; i++) {
    const left = tmp[max(0, i - 1)];
    const center = tmp[i];
    const right = tmp[min(arr.length - 1, i + 1)];
    const avg = (left + center + right) / 3;
    arr[i] = lerp(center, avg, amt);
  }
}
Line-by-line explanation (3 lines)

🔧 Subcomponents:

for-loop Neighbor Averaging Loop for (let i = 0; i < arr.length; i++) { ... }

Blends each array element toward the average of itself and its two neighbors, smoothing out sharp bumps

const tmp = arr.slice();
Makes a copy of the array first, so smoothing one element doesn't affect the calculation for its neighbor in the same pass
const left = tmp[max(0, i - 1)];
Reads the neighbor to the left, clamping to index 0 so the very first element doesn't read out of bounds
arr[i] = lerp(center, avg, amt);
Moves this element partway toward the 3-point average, where 'amt' controls how strong the smoothing effect is

averageArray()

A small, reusable math utility used by updateAISuggestionIfNeeded() to summarize an entire height array into a single 'how much has accumulated overall' number.

function averageArray(arr) {
  if (arr.length === 0) return 0;
  let sum = 0;
  for (let v of arr) sum += v;
  return sum / arr.length;
}
Line-by-line explanation (3 lines)
if (arr.length === 0) return 0;
Guards against dividing by zero if the array happened to be empty
for (let v of arr) sum += v;
Adds up every value in the array into a running total
return sum / arr.length;
Divides the total by how many items there are to get the mean value

spawnLightning()

spawnLightning() ties together the visual bolt, the screen flash, and the audio thunder into one call - called from mouseReleased() once a long enough hold is detected.

function spawnLightning(x, chargeRatio) {
  const bolt = new LightningBolt(x, chargeRatio);
  lightningBolts.push(bolt);
  if (lightningBolts.length > MAX_LIGHTNING) {
    lightningBolts.shift();
  }

  // Screen flash intensity
  lightningFlash = max(lightningFlash, chargeRatio * 1.3);

  triggerThunder(x, chargeRatio);
}
Line-by-line explanation (3 lines)

🔧 Subcomponents:

conditional Array Cap Check if (lightningBolts.length > MAX_LIGHTNING) { lightningBolts.shift(); }

Removes the oldest bolt once too many exist at once, preventing unbounded array growth

const bolt = new LightningBolt(x, chargeRatio);
Creates a new bolt at the given x position, with its jaggedness and brightness based on how charged the click was
lightningFlash = max(lightningFlash, chargeRatio * 1.3);
Boosts the screen flash brightness, but only if this new strike is brighter than any flash already fading out
triggerThunder(x, chargeRatio);
Schedules a matching thunder sound, delayed based on the bolt's distance from the center of the screen

triggerThunder()

This function demonstrates scheduling sound precisely in the future using Tone.js's time-based API (Tone.now(), linearRampToValueAtTime) rather than triggering sounds immediately - key for realistic audio timing effects like distant thunder.

function triggerThunder(strikeX, strength) {
  if (!audioStarted || typeof Tone === "undefined" || !thunderNoise) return;

  const listenerX = width / 2;
  const dx = abs(strikeX - listenerX);
  const distanceNorm = constrain(dx / (width / 2), 0, 1);

  const delaySeconds = lerp(0.2, 2.8, distanceNorm); // farther = later
  const duration = lerp(1.8, 3.5, strength);
  const peakGain =
    lerp(0.4, 1.0, strength) * lerp(1.0, 0.5, distanceNorm);

  const now = Tone.now();
  const t = now + delaySeconds;

  thunderGain.gain.setValueAtTime(0, t);
  thunderGain.gain.linearRampToValueAtTime(peakGain, t + 0.3);
  thunderGain.gain.linearRampToValueAtTime(0, t + duration);

  thunderNoise.start(t);
  thunderNoise.stop(t + duration);
}
Line-by-line explanation (4 lines)
const distanceNorm = constrain(dx / (width / 2), 0, 1);
Converts how far the strike was from the center of the screen into a 0-1 'how far away' value, treating the listener as centered
const delaySeconds = lerp(0.2, 2.8, distanceNorm); // farther = later
Mimics the real-world delay between seeing lightning and hearing thunder - farther strikes are heard later
thunderGain.gain.linearRampToValueAtTime(peakGain, t + 0.3);
Ramps the thunder's volume up quickly to its peak 0.3 seconds after it starts, like a rumble building up
thunderNoise.start(t); thunderNoise.stop(t + duration);
Schedules the actual noise source to play only during this specific window of time, rather than starting immediately

applyLightningFlash()

A full-screen semi-transparent white rectangle drawn on top of everything else is a simple, effective way to simulate a camera-flash-style lightning strike.

function applyLightningFlash() {
  if (lightningFlash <= 0.01) return;

  noStroke();
  fill(255, 255, 255, lightningFlash * 120);
  rect(0, 0, width, height);

  lightningFlash *= 0.85;
}
Line-by-line explanation (3 lines)
if (lightningFlash <= 0.01) return;
Skips drawing anything once the flash has faded down to nearly nothing, saving a bit of work
fill(255, 255, 255, lightningFlash * 120);
Uses the current flash strength to set how opaque the white overlay rectangle is
lightningFlash *= 0.85;
Shrinks the flash value by 15% every frame, creating a quick exponential fade-out

drawLightningChargeIndicator()

This gives the player real-time visual feedback while charging lightning, following the same ratio-based math used elsewhere (like thunder delay and volume) so the whole sketch stays consistent.

function drawLightningChargeIndicator() {
  const pressDuration = millis() - mousePressStartMillis;
  if (pressDuration < 50) return;

  const ratio = constrain(pressDuration / MAX_LIGHTNING_CHARGE, 0, 1);
  const r = map(ratio, 0, 1, 20, 80);

  noFill();
  stroke(255, 255, 255, 160);
  strokeWeight(2);
  ellipse(mouseX, mouseY, r * 2, r * 2);

  noStroke();
  fill(255, 255, 200, 80);
  ellipse(mouseX, mouseY, r * 1.2, r * 1.2);
}
Line-by-line explanation (3 lines)
if (pressDuration < 50) return;
Waits a brief moment before showing the indicator so quick clicks don't flash a ring
const ratio = constrain(pressDuration / MAX_LIGHTNING_CHARGE, 0, 1);
Converts how long the mouse has been held into a 0-1 'how charged up' ratio
const r = map(ratio, 0, 1, 20, 80);
Grows the ring's radius from 20 to 80 pixels as the charge builds up

drawUI()

drawUI() is called last in draw() so the text and boxes always render on top of the weather scene, demonstrating that draw order in p5.js directly controls visual stacking.

function drawUI() {
  // Controls box
  noStroke();
  fill(0, 0, 0, 130);
  rect(10, 10, 360, 120, 8);

  fill(255);
  textSize(14);
  textAlign(LEFT, TOP);
  text(
    "AI Weather Symphony\n" +
      "Drag: wind currents\n" +
      "Click: rain burst\n" +
      "Shift+Click: snow burst\n" +
      "Hold click (>0.4s): lightning\n" +
      "N: toggle night / day",
    18,
    16
  );

  // AI suggestion box
  const boxY = height - 90;
  fill(0, 0, 0, 140);
  rect(10, boxY, 470, 80, 8);

  fill(230);
  textSize(13);
  textAlign(LEFT, TOP);
  text("AI suggestion:\n" + currentSuggestion, 18, boxY + 10);
}
Line-by-line explanation (3 lines)
rect(10, 10, 360, 120, 8);
Draws a rounded semi-transparent black box (8px corner radius) in the top-left corner to hold the controls text
text("AI Weather Symphony\n" + ... , 18, 16);
Draws multi-line instructions using \n line breaks, positioned inside the controls box
const boxY = height - 90;
Anchors the AI suggestion box relative to the bottom of the screen so it stays in place even if the window is resized

mousePressed()

This function only records state - it doesn't yet know if this will become a click, a drag, or a long-press; that decision is made later in mouseReleased().

function mousePressed() {
  mousePressStartMillis = millis();
  mousePressStartPos = createVector(mouseX, mouseY);
  mouseHasDraggedFar = false;
  startAudioIfNeeded();
}
Line-by-line explanation (3 lines)
mousePressStartMillis = millis();
Records the exact timestamp the mouse went down, used later to measure how long it was held
mousePressStartPos = createVector(mouseX, mouseY);
Remembers where the click started so mouseReleased() can measure how far the mouse traveled
startAudioIfNeeded();
Uses this user gesture (a click) to satisfy the browser's requirement for starting audio

mouseDragged()

mouseDragged() is the heart of the wind interaction: it turns raw per-frame mouse movement into both a persistent global force (globalWind) and a burst of short-lived visual particles, all in one function.

🔬 The 0.3 controls how quickly wind catches up to your drag direction. What happens if you raise it to 1.0, making the wind snap instantly instead of smoothly blending?

    const adj = dragVec.copy().mult(0.25);
    globalWind = p5.Vector.lerp(globalWind, adj, 0.3);
function mouseDragged() {
  if (mousePressStartPos) {
    const d = dist(
      mouseX,
      mouseY,
      mousePressStartPos.x,
      mousePressStartPos.y
    );
    if (d > DRAG_DISTANCE_THRESHOLD) mouseHasDraggedFar = true;
  }

  const dx = mouseX - pmouseX;
  const dy = mouseY - pmouseY;
  const dragVec = createVector(dx, dy);

  if (dragVec.mag() > 0.5) {
    // Blend global wind toward drag direction
    const adj = dragVec.copy().mult(0.25);
    globalWind = p5.Vector.lerp(globalWind, adj, 0.3);

    // Visual wind particles along drag path
    for (let i = 0; i < 5; i++) {
      const jitter = p5.Vector.random2D().mult(0.5);
      const v = dragVec.copy().add(jitter).mult(random(0.1, 0.3));
      const px = lerp(pmouseX, mouseX, random());
      const py = lerp(pmouseY, mouseY, random());
      windParticles.push(new WindParticle(px, py, v));
    }

    if (windParticles.length > MAX_WIND_PARTICLES) {
      windParticles.splice(
        0,
        windParticles.length - MAX_WIND_PARTICLES
      );
    }
  }
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

conditional Far-Drag Flag if (d > DRAG_DISTANCE_THRESHOLD) mouseHasDraggedFar = true;

Marks this gesture as a real drag (not a click) once it travels far enough from where the mouse first went down

for-loop Wind Particle Spawner for (let i = 0; i < 5; i++) { ... windParticles.push(new WindParticle(px, py, v)); }

Spawns 5 new wind streak particles along the current drag segment every time the mouse moves

const dx = mouseX - pmouseX;
Calculates how far the mouse moved horizontally since the very last frame, using p5's built-in pmouseX
const adj = dragVec.copy().mult(0.25);
Scales down the raw per-frame drag movement into something suitable to use as a wind adjustment
globalWind = p5.Vector.lerp(globalWind, adj, 0.3);
Smoothly blends the current wind 30% of the way toward the new drag direction, rather than snapping to it instantly
const px = lerp(pmouseX, mouseX, random());
Scatters each spawned particle at a random point along the line between the previous and current mouse position, rather than stacking them all in one spot

mouseReleased()

This function is the decision point for the whole gesture system: it uses both duration and distance to distinguish three different intents (drag, click, hold) from the exact same mouse-down/mouse-up events.

function mouseReleased() {
  const pressDuration = millis() - mousePressStartMillis;
  const movedDistance = mousePressStartPos
    ? dist(mouseX, mouseY, mousePressStartPos.x, mousePressStartPos.y)
    : 0;

  // If drag moved far, treat purely as wind gesture
  if (mouseHasDraggedFar || movedDistance > DRAG_DISTANCE_THRESHOLD) {
    return;
  }

  if (pressDuration > LIGHTNING_CHARGE_THRESHOLD) {
    // Lightning
    const chargeRatio = constrain(
      pressDuration / MAX_LIGHTNING_CHARGE,
      0,
      1
    );
    spawnLightning(mouseX, chargeRatio);
  } else {
    // Short click: precipitation
    const type = keyIsDown(SHIFT) ? "snow" : "rain";
    spawnPrecipitationBurst(mouseX, mouseY, type);
  }
}
Line-by-line explanation (3 lines)

🔧 Subcomponents:

conditional Drag-Cancels-Click Check if (mouseHasDraggedFar || movedDistance > DRAG_DISTANCE_THRESHOLD) { return; }

If the gesture moved too far, it's treated purely as a wind drag and no rain/snow/lightning is spawned

conditional Long-Hold vs Short-Click Branch if (pressDuration > LIGHTNING_CHARGE_THRESHOLD) { ... } else { ... }

Decides whether this release triggers lightning (long hold) or a precipitation burst (short click)

const pressDuration = millis() - mousePressStartMillis;
Calculates exactly how many milliseconds passed between mouse down and mouse up
if (mouseHasDraggedFar || movedDistance > DRAG_DISTANCE_THRESHOLD) { return; }
Cancels any click/lightning behavior if this turned into a real drag, so dragging doesn't accidentally also spawn rain
const type = keyIsDown(SHIFT) ? "snow" : "rain";
Checks if Shift is currently held down to decide between spawning snow or rain

spawnPrecipitationBurst()

This function shows how a single click event can spawn a whole cluster of particles at once, and how array caps (MAX_RAIN, MAX_SNOW) keep long play sessions from slowing down the browser.

🔬 The spread here is +/-30px horizontally and +/-10px vertically. What happens if you widen the horizontal spread to +/-150, making each click cover a much wider swath of sky?

  for (let i = 0; i < count; i++) {
    const offsetX = random(-30, 30);
    const offsetY = random(-10, 10);
function spawnPrecipitationBurst(x, y, type) {
  const count = type === "rain" ? 80 : 45;
  for (let i = 0; i < count; i++) {
    const offsetX = random(-30, 30);
    const offsetY = random(-10, 10);
    const px = x + offsetX;
    const py = y + offsetY;
    if (type === "rain") {
      rainDrops.push(new PrecipParticle(px, py, "rain"));
    } else {
      snowflakes.push(new PrecipParticle(px, py, "snow"));
    }
  }

  if (rainDrops.length > MAX_RAIN) {
    rainDrops.splice(0, rainDrops.length - MAX_RAIN);
  }
  if (snowflakes.length > MAX_SNOW) {
    snowflakes.splice(0, snowflakes.length - MAX_SNOW);
  }
}
Line-by-line explanation (2 lines)

🔧 Subcomponents:

for-loop Burst Spawn Loop for (let i = 0; i < count; i++) { ... }

Creates a cluster of rain or snow particles scattered randomly around the click point

conditional Array Size Caps if (rainDrops.length > MAX_RAIN) { rainDrops.splice(0, rainDrops.length - MAX_RAIN); }

Trims the oldest particles off the front of the array if the total exceeds the maximum allowed

const count = type === "rain" ? 80 : 45;
Each rain click spawns more particles (80) than a snow click (45), since snow reads as visually 'thicker' per flake
const offsetX = random(-30, 30);
Scatters each particle randomly within 30 pixels left/right of the click point so the burst looks like a cluster, not a single stacked point

keyPressed()

keyPressed() runs once per key press (not continuously like keyIsDown), making it perfect for toggle-style controls like this day/night switch.

function keyPressed() {
  if (key === "n" || key === "N") {
    isNight = !isNight;
  }
}
Line-by-line explanation (2 lines)

🔧 Subcomponents:

conditional Night Toggle Check if (key === "n" || key === "N") { isNight = !isNight; }

Flips the isNight boolean whenever the N key (upper or lower case) is pressed

if (key === "n" || key === "N") {
Checks for either lowercase or uppercase N, since p5's 'key' variable reflects the actual character typed
isNight = !isNight;
Flips the boolean between true and false each time N is pressed, toggling day/night

windowResized()

windowResized() is a built-in p5.js callback that fires automatically on browser resize, letting full-window sketches like this one stay responsive without any extra event listener setup.

function windowResized() {
  resizeCanvas(windowWidth, windowHeight);
  initClouds();
}
Line-by-line explanation (2 lines)
resizeCanvas(windowWidth, windowHeight);
Resizes the p5 canvas to match the browser window whenever it changes size
initClouds();
Rebuilds the cloud layer so clouds are repositioned sensibly for the new canvas dimensions

📦 Key Variables

rainDrops array

Holds all active PrecipParticle objects of type 'rain' currently falling on screen

let rainDrops = [];
snowflakes array

Holds all active PrecipParticle objects of type 'snow' currently falling on screen

let snowflakes = [];
windParticles array

Holds short-lived WindParticle streaks spawned while dragging

let windParticles = [];
lightningBolts array

Holds active LightningBolt objects that are still fading out

let lightningBolts = [];
clouds array

Holds all Cloud objects used for the parallax sky layer

let clouds = [];
waterHeights array

One number per ground column tracking how much water has accumulated there

let waterHeights = [];
snowHeights array

One number per ground column tracking how much snow has accumulated there

let snowHeights = [];
globalWind object

A p5.Vector representing the current wind direction and strength, driven by mouse drags and decaying over time

let globalWind;
mousePressStartMillis number

Timestamp of when the mouse was pressed down, used to measure hold duration for lightning charging

let mousePressStartMillis = 0;
mousePressStartPos object

A p5.Vector storing where the mouse first went down, used to measure how far the user has dragged

let mousePressStartPos = null;
mouseHasDraggedFar boolean

Flags whether the current mouse gesture has moved far enough to count as a drag rather than a click

let mouseHasDraggedFar = false;
isNight boolean

Tracks whether the sky is currently in night mode, toggled by pressing N

let isNight = false;
rainIntensity number

A normalized 0-1 value representing how heavy the rain currently is, based on particle count

let rainIntensity = 0;
snowIntensity number

A normalized 0-1 value representing how heavy the snowfall currently is

let snowIntensity = 0;
windIntensity number

A normalized 0-1 value representing how strong the wind currently is

let windIntensity = 0;
lightningIntensity number

A normalized 0-1 value representing how much active lightning energy exists right now

let lightningIntensity = 0;
stormFactor number

A combined 0-1 measure of overall storminess, blending rain, snow and lightning, used to drive sky color and cloud darkness

let stormFactor = 0;
lightningFlash number

Controls the brightness of the fading white screen-flash overlay after a lightning strike

let lightningFlash = 0;
currentSuggestion string

The text currently shown in the AI suggestion box, updated periodically based on weather state

let currentSuggestion = "Calm skies...";
lastSuggestionChange number

Timestamp of the last time the AI suggestion text was updated, used to throttle how often it changes

let lastSuggestionChange = 0;
audioStarted boolean

Tracks whether the Tone.js audio context has been successfully unlocked and started

let audioStarted = false;

🔧 Potential Improvements (4)

Here are some ways this code could be enhanced:

PERFORMANCE drawBackgroundGradient()

The sky gradient is drawn by calling line() once per vertical pixel (up to 1000+ calls per frame on tall windows), which is expensive compared to GPU-friendly approaches.

💡 Pre-render the gradient into an offscreen createGraphics() buffer once per stormFactor/isNight change and simply image() it each frame, or use a single quad with vertex colors.

BUG Cloud.draw()

Each ellipse's width/height uses random(0.5, 0.8) and random(0.6, 1.0) recalculated every single frame, so cloud shapes flicker and subtly reshape themselves instead of staying stable.

💡 Compute these random size ratios once in reset() and store them per-lobe, then reuse the stored values in draw().

FEATURE Global interaction handlers

The sketch only listens for mouse events (mousePressed, mouseDragged, mouseReleased), so it won't respond to touch on phones/tablets.

💡 Add touchStarted, touchMoved and touchEnded functions that call the same logic (p5.js maps touch coordinates to mouseX/mouseY automatically, but the built-in mouse callbacks aren't triggered by touch on all browsers).

STYLE Throughout (e.g. gravity, damping, threshold values)

Many important tuning numbers (0.25, 0.06, 0.9995, 30, 45, 400, etc.) are hard-coded inline inside functions rather than grouped as named constants.

💡 Pull frequently-tuned magic numbers into a single CONFIG object or into the top-level const declarations, making the whole simulation easier to tweak and document in one place.

🔄 Code Flow

Code flow showing precipparticle, windparticle, lightningbolt, cloud, setup, initclouds, setupaudio, startaudioifneeded, draw, updateweatherstate, updateaisuggestionifneeded, drawbackgroundgradient, shoulddrawaurora, drawaurora, drawclouds, updateanddrawwind, updateanddrawprecipitation, updateanddrawlightning, drawgroundaccumulation, groundindexforx, getgroundsurfacey, updateaccumulation, smootharray, averagearray, spawnlightning, triggerthunder, applylightningflash, drawlightningchargeindicator, drawui, mousepressed, mousedragged, mousereleased, spawnprecipitationburst, keypressed, windowresized

💡 Click on function names in the diagram to jump to their code

graph TD start[Start] --> setup[setup] setup --> draw[draw loop] draw --> updateweatherstate[updateweatherstate] draw --> drawbackgroundgradient[drawbackgroundgradient] draw --> shoulddrawaurora[shoulddrawaurora] draw --> drawclouds[drawclouds] draw --> updateanddrawwind[updateanddrawwind] draw --> updateanddrawprecipitation[updateanddrawprecipitation] draw --> updateanddrawlightning[updateanddrawlightning] draw --> drawgroundaccumulation[drawgroundaccumulation] draw --> drawui[drawui] click setup href "#fn-setup" click draw href "#fn-draw" click updateweatherstate href "#fn-updateweatherstate" click drawbackgroundgradient href "#fn-drawbackgroundgradient" click shoulddrawaurora href "#fn-shoulddrawaurora" click drawclouds href "#fn-drawclouds" click updateanddrawwind href "#fn-updateanddrawwind" click updateanddrawprecipitation href "#fn-updateanddrawprecipitation" click updateanddrawlightning href "#fn-updateanddrawlightning" click drawgroundaccumulation href "#fn-drawgroundaccumulation" click drawui href "#fn-drawui" %% Subcomponents inside updateweatherstate updateweatherstate --> precip-type-branch[precip-type-branch] updateweatherstate --> averagearray[averagearray] updateweatherstate --> audio-ramp-block[audio-ramp-block] click precip-type-branch href "#sub-precip-type-branch" click averagearray href "#sub-averagearray" click audio-ramp-block href "#sub-audio-ramp-block" %% Subcomponents inside drawbackgroundgradient drawbackgroundgradient --> gradient-loop[gradient-loop] click gradient-loop href "#sub-gradient-loop" %% Subcomponents inside shoulddrawaurora shoulddrawaurora --> draw-aurora-check[draw-aurora-check] click draw-aurora-check href "#sub-draw-aurora-check" %% Subcomponents inside drawclouds drawclouds --> clouds-loop[clouds-loop] drawclouds --> cloud-wrap[cloud-wrap] drawclouds --> cloud-lobes-loop[cloud-lobes-loop] drawclouds --> cloud-layer-loop[cloud-layer-loop] click clouds-loop href "#sub-clouds-loop" click cloud-wrap href "#sub-cloud-wrap" click cloud-lobes-loop href "#sub-cloud-lobes-loop" click cloud-layer-loop href "#sub-cloud-layer-loop" %% Subcomponents inside updateanddrawwind updateanddrawwind --> wind-cleanup-loop[wind-cleanup-loop] click wind-cleanup-loop href "#sub-wind-cleanup-loop" %% Subcomponents inside updateanddrawprecipitation updateanddrawprecipitation --> rain-loop[rain-loop] updateanddrawprecipitation --> snow-loop[snow-loop] click rain-loop href "#sub-rain-loop" click snow-loop href "#sub-snow-loop" %% Subcomponents inside updateanddrawlightning updateanddrawlightning --> lightning-loop[lightning-loop] updateanddrawlightning --> lightning-cap[lightning-cap] click lightning-loop href "#sub-lightning-loop" click lightning-cap href "#sub-lightning-cap" %% Subcomponents inside drawgroundaccumulation drawgroundaccumulation --> ground-columns-loop[ground-columns-loop] drawgroundaccumulation --> damping-loop[damping-loop] click ground-columns-loop href "#sub-ground-columns-loop" click damping-loop href "#sub-damping-loop" %% Subcomponents inside drawui drawui --> draw-lightning-charge-indicator[draw-lightning-charge-indicator] click draw-lightning-charge-indicator href "#sub-draw-charge-indicator"

❓ Frequently Asked Questions

What kind of visual experience does the AI Weather Symphony sketch offer?

The AI Weather Symphony creates a dynamic weather simulation featuring visual elements like rain, snow, wind currents, and lightning, all influenced by user interactions.

How can users engage with the AI Weather Symphony sketch?

Users can drag to create wind currents, click to spawn rain bursts, shift + click for snow bursts, and hold the mouse to charge lightning, allowing for a highly interactive experience.

What creative coding concepts are showcased in the AI Weather Symphony sketch?

This sketch demonstrates the use of particle systems for weather effects, user interaction techniques, and audio synthesis with Tone.js to create an immersive soundscape.

Preview

AI Weather Symphony - Dynamic Soundscape Control the weather and create unique soundscapes! Drag to - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of AI Weather Symphony - Dynamic Soundscape Control the weather and create unique soundscapes! Drag to - Code flow showing precipparticle, windparticle, lightningbolt, cloud, setup, initclouds, setupaudio, startaudioifneeded, draw, updateweatherstate, updateaisuggestionifneeded, drawbackgroundgradient, shoulddrawaurora, drawaurora, drawclouds, updateanddrawwind, updateanddrawprecipitation, updateanddrawlightning, drawgroundaccumulation, groundindexforx, getgroundsurfacey, updateaccumulation, smootharray, averagearray, spawnlightning, triggerthunder, applylightningflash, drawlightningchargeindicator, drawui, mousepressed, mousedragged, mousereleased, spawnprecipitationburst, keypressed, windowresized
Code Flow Diagram