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

This sketch creates an interactive weather simulation with dynamic soundscapes where users can control wind, rain, snow, and lightning through mouse gestures. Different weather patterns generate unique audio layers using Tone.js, while visual effects like particle systems, aurora, and ground accumulation respond to user input and environmental state.

๐ŸŽ“ Concepts You'll Learn

Particle systemsVector math and physics simulationAudio synthesis with Tone.jsInteractive input handlingState management and weather metricsPerlin noise for procedural generationColor interpolation and gradientsCanvas transformations and layering

๐Ÿ”„ Code Flow

Code flow showing 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, precipparticle, windparticle, lightningbolt, cloud

๐Ÿ’ก Click on function names in the diagram to jump to their code

graph TD start[Start] --> setup[setup] setup --> canvas-creation[Canvas Creation] setup --> wind-vector-init[Wind Vector Initialization] setup --> ground-arrays-init[Ground Arrays Initialization] setup --> cloud-array-reset[Cloud Array Reset] setup --> initclouds[initclouds] setup --> setupaudio[setupaudio] setup --> startaudioifneeded[startaudioifneeded] setup --> draw[draw loop] click setup href "#fn-setup" click canvas-creation href "#sub-canvas-creation" click wind-vector-init href "#sub-wind-vector-init" click ground-arrays-init href "#sub-ground-arrays-init" click cloud-array-reset href "#sub-cloud-array-reset" click initclouds href "#fn-initclouds" click setupaudio href "#fn-setupaudio" click startaudioifneeded href "#fn-startaudioifneeded" draw --> updateweatherstate[updateweatherstate] draw --> drawbackgroundgradient[drawbackgroundgradient] draw --> shoulddrawaurora[shoulddrawaurora] draw --> drawaurora[drawaurora] draw --> drawclouds[drawclouds] draw --> updateanddrawwind[updateanddrawwind] draw --> updateanddrawprecipitation[updateanddrawprecipitation] draw --> updateanddrawlightning[updateanddrawlightning] draw --> drawgroundaccumulation[drawgroundaccumulation] draw --> drawui[drawui] click draw href "#fn-draw" click updateweatherstate href "#fn-updateweatherstate" click drawbackgroundgradient href "#fn-drawbackgroundgradient" click shoulddrawaurora href "#fn-shoulddrawaurora" click drawaurora href "#fn-drawaurora" 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" updateweatherstate --> intensity-calculations[Intensity Calculations] updateweatherstate --> storm-factor-calc[Storm Factor Calculation] updateweatherstate --> audio-control[Audio Control] click intensity-calculations href "#sub-intensity-calculations" click storm-factor-calc href "#sub-storm-factor-calc" click audio-control href "#sub-audio-control" drawbackgroundgradient --> time-of-day-select[Time of Day Select] drawbackgroundgradient --> storm-interpolation[Storm Interpolation] drawbackgroundgradient --> gradient-loop[Gradient Loop] click time-of-day-select href "#sub-time-of-day-select" click storm-interpolation href "#sub-storm-interpolation" click gradient-loop href "#sub-gradient-loop" shoulddrawaurora --> aurora-conditional[Aurora Conditional] click aurora-conditional href "#sub-aurora-conditional" drawaurora --> time-variable[Time Variable] drawaurora --> band-loop[Band Loop] drawaurora --> color-selection[Color Selection] drawaurora --> vertex-loop[Vertex Loop] drawaurora --> noise-mapping[Noise Mapping] click time-variable href "#sub-time-variable" click band-loop href "#sub-band-loop" click color-selection href "#sub-color-selection" click vertex-loop href "#sub-vertex-loop" click noise-mapping href "#sub-noise-mapping" drawclouds --> cloud-loop[Cloud Loop] click cloud-loop href "#sub-cloud-loop" updateanddrawwind --> wind-decay[Wind Decay] updateanddrawwind --> particle-loop[Particle Loop] click wind-decay href "#sub-wind-decay" click particle-loop href "#sub-particle-loop" updateanddrawprecipitation --> rain-loop[Rain Loop] updateanddrawprecipitation --> snow-loop[Snow Loop] click rain-loop href "#sub-rain-loop" click snow-loop href "#sub-snow-loop" updateanddrawlightning --> lightning-loop[Lightning Loop] click lightning-loop href "#sub-lightning-loop" drawgroundaccumulation --> segment-width-calc[Segment Width Calculation] drawgroundaccumulation --> ground-base[Ground Base] drawgroundaccumulation --> accumulation-loop[Accumulation Loop] click segment-width-calc href "#sub-segment-width-calc" click ground-base href "#sub-ground-base" click accumulation-loop href "#sub-accumulation-loop" drawui --> controls-box[Controls Box] drawui --> suggestion-box[Suggestion Box] click controls-box href "#sub-controls-box" click suggestion-box href "#sub-suggestion-box"

๐Ÿ“ Code Breakdown

setup()

setup() runs once when the sketch starts. It initializes the canvas, creates data structures for tracking weather state, and sets up the audio system. This is where you prepare everything the sketch needs to run.

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();
}

๐Ÿ”ง Subcomponents:

function-call Canvas initialization createCanvas(windowWidth, windowHeight)

Creates a full-window canvas that fills the entire browser viewport

initialization Global wind vector setup globalWind = createVector(0, 0)

Initializes the global wind vector that influences all particles and precipitation

initialization Ground accumulation arrays waterHeights = new Array(GROUND_SEGMENTS).fill(0); snowHeights = new Array(GROUND_SEGMENTS).fill(0)

Creates arrays to track water and snow height at each ground segment for visual accumulation

Line by Line:

createCanvas(windowWidth, windowHeight)
Creates a canvas that matches the full window size, allowing the sketch to use the entire screen
globalWind = createVector(0, 0)
Initializes a vector to store the current wind direction and magnitude, starting at zero (no wind)
waterHeights = new Array(GROUND_SEGMENTS).fill(0)
Creates an array with 80 elements (GROUND_SEGMENTS), each starting at 0 to track water accumulation height at each ground position
snowHeights = new Array(GROUND_SEGMENTS).fill(0)
Creates an array with 80 elements to track snow accumulation height independently from water
initClouds()
Calls the function to create and initialize all cloud objects for the scene
textFont("sans-serif")
Sets the font for all text rendering to a sans-serif typeface
setupAudio()
Initializes all Tone.js audio nodes, synthesizers, and effects for the soundscape

initClouds()

This function creates a layered cloud system where clouds at different depths move at different speeds and scales, creating a parallax effect that makes the scene feel three-dimensional.

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));
  }
}

๐Ÿ”ง Subcomponents:

initialization Cloud array reset clouds = []

Clears the clouds array to start fresh

for-loop Cloud creation loop for (let i = 0; i < NUM_CLOUDS; i++)

Iterates 20 times to create clouds at different depth layers

calculation Layer depth calculation const layer = map(i, 0, NUM_CLOUDS - 1, 0.1, 1)

Maps cloud index to a depth value from 0.1 (far) to 1 (near) for parallax effect

Line by Line:

clouds = []
Empties the clouds array, useful when reinitializing (like on window resize)
for (let i = 0; i < NUM_CLOUDS; i++)
Loops 20 times (NUM_CLOUDS = 20) to create multiple clouds
const layer = map(i, 0, NUM_CLOUDS - 1, 0.1, 1)
Maps the loop counter to a layer value: first cloud gets 0.1 (far background), last gets 1 (near foreground), creating depth
clouds.push(new Cloud(layer))
Creates a new Cloud object with the calculated layer depth and adds it to the clouds array

setupAudio()

This function builds a complete audio synthesis system using Tone.js. It creates noise sources (wind, rain, thunder) that are filtered and controlled, plus two melodic synthesizers that play generative music. The audio is organized in chains: each noise goes through a filter, then a gain node that controls volume. This architecture lets updateWeatherState() easily adjust volumes based on weather conditions.

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");
}

๐Ÿ”ง Subcomponents:

conditional Tone.js availability check if (typeof Tone === "undefined") { console.warn(...); return; }

Safely handles case where Tone.js library fails to load

initialization Noise generators windNoise = new Tone.Noise("pink"); rainNoise = new Tone.Noise("white"); thunderNoise = new Tone.Noise("brown")

Creates three noise sources with different color profiles for different weather sounds

initialization Gain control nodes windGain = new Tone.Gain(0).toDestination(); ... snowGain = new Tone.Gain(0).toDestination()

Creates volume control nodes for each audio layer, all starting at 0 (silent)

audio-chain Wind audio processing windNoise.connect(windFilter); windFilter.connect(windGain)

Chains wind noise through an auto-filter for sweeping effect, then to volume control

audio-chain Rain audio processing rainNoise.connect(rainFilter); rainFilter.connect(rainGain)

Chains rain noise through a high-pass filter for bright sound, then to volume control

audio-chain Thunder audio processing thunderNoise.connect(thunderFilter); thunderFilter.connect(thunderGain)

Chains thunder noise through a low-pass filter for deep rumbling sound

initialization Synthesizer configuration sunSynth = new Tone.PolySynth(Tone.Synth); sunSynth.set({...}); snowSynth = new Tone.PolySynth(Tone.Synth); snowSynth.set({...})

Creates two melodic synthesizers with different envelope shapes for sunny and snowy moods

initialization Generative melody loops melodyLoop = new Tone.Loop(...); snowLoop = new Tone.Loop(...)

Creates two repeating loops that generate random notes from musical scales

Line by Line:

if (typeof Tone === "undefined") { console.warn(...); return; }
Checks if Tone.js library is loaded; if not, logs a warning and exits the function to prevent errors
windNoise = new Tone.Noise("pink")
Creates a pink noise source (has more bass than white noise) for wind sounds
rainNoise = new Tone.Noise("white")
Creates a white noise source (equal energy at all frequencies) for rain sounds
thunderNoise = new Tone.Noise("brown")
Creates a brown noise source (more bass, deeper rumble) for thunder sounds
windGain = new Tone.Gain(0).toDestination()
Creates a volume control node for wind, starting at 0 (silent), connected to speakers
const windFilter = new Tone.AutoFilter({...}).start()
Creates an auto-filter that sweeps across frequencies at 0.15 Hz, giving wind a wooshing effect
windNoise.connect(windFilter); windFilter.connect(windGain)
Chains the wind noise through the filter, then to the volume control (audio signal flow)
const rainFilter = new Tone.Filter(1500, "highpass")
Creates a high-pass filter at 1500 Hz that removes low frequencies, making rain sound bright and crisp
const thunderFilter = new Tone.Filter(120, "lowpass")
Creates a low-pass filter at 120 Hz that removes high frequencies, making thunder sound deep and rumbling
const reverb = new Tone.Reverb({decay: 6, wet: 0.4}).toDestination()
Creates a reverb effect with 6-second decay time, 40% wet signal, connected to speakers for spacious sound
sunSynth = new Tone.PolySynth(Tone.Synth)
Creates a synthesizer that can play multiple notes simultaneously (polyphonic) using sine wave oscillators
sunSynth.set({oscillator: {type: "sine"}, envelope: {...}})
Configures the sun synth with a sine wave and envelope that has slow attack (0.5s) and long release (2.5s) for smooth pads
snowSynth.set({oscillator: {type: "triangle"}, envelope: {...}})
Configures the snow synth with a triangle wave and even longer envelope (4s release) for ethereal chime sounds
melodyLoop = new Tone.Loop(time => {...}, "2n")
Creates a loop that runs every half note, randomly picking notes from a major scale and playing them on the sun synth
snowLoop = new Tone.Loop(time => {...}, "4n")
Creates a loop that runs every quarter note, randomly picking notes from a different scale for the snow synth

startAudioIfNeeded()

This function is called on first mouse interaction (in mousePressed()). Modern browsers require user interaction before playing audio, so we wait until the user clicks to start the audio system. The 'async' and 'await' keywords let us wait for Tone.js to initialize before starting the noise generators and loops.

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);
  }
}

๐Ÿ”ง Subcomponents:

conditional Audio already started check if (audioStarted || typeof Tone === "undefined") return

Prevents audio from being started twice and handles missing Tone.js

async-call Tone.js initialization await Tone.start()

Initializes the Tone.js audio context (required by browser security)

function-call Noise generator startup windNoise.start(); rainNoise.start()

Starts the continuous noise generators

function-call Transport and loop startup Tone.Transport.start(); melodyLoop.start(); snowLoop.start()

Starts the Tone.js transport (timing system) and the generative melody loops

Line by Line:

async function startAudioIfNeeded()
Declares an async function that can use 'await' to wait for Tone.js initialization
if (audioStarted || typeof Tone === "undefined") return
Exits early if audio is already started or if Tone.js isn't loaded, preventing errors
await Tone.start()
Waits for Tone.js to initialize the audio context (required by modern browsers for security)
windNoise.start(); rainNoise.start()
Starts the wind and rain noise generators so they're ready to be controlled by gain nodes
Tone.Transport.start()
Starts Tone.js's transport (the timing system that controls when notes and loops play)
melodyLoop.start(); snowLoop.start()
Starts both generative melody loops so they begin playing random notes
audioStarted = true
Sets the flag to true so this function won't try to start audio again
console.log("Audio started")
Logs a message to the browser console for debugging

draw()

draw() is the main animation loop that runs 60 times per second. It follows a clear order: update state, draw background, draw weather effects from back to front (clouds, then particles), then draw UI on top. This layering ensures nothing gets hidden behind later drawings.

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();
}

๐Ÿ”ง Subcomponents:

function-call Weather state calculation updateWeatherState()

Recalculates weather metrics and updates audio based on current particle counts

function-call Background gradient rendering drawBackgroundGradient()

Draws the sky gradient that changes based on day/night and storm intensity

conditional Aurora visibility check if (shouldDrawAurora()) { drawAurora(); }

Only draws aurora on clear nights

function-call Particle system updates updateAndDrawWind(); updateAndDrawPrecipitation(); updateAndDrawLightning()

Updates positions and draws all wind, rain, snow, and lightning particles

function-call Ground accumulation rendering drawGroundAccumulation()

Draws water and snow that has accumulated on the ground

function-call Lightning flash overlay applyLightningFlash()

Applies a white flash that fades when lightning strikes

conditional Lightning charge indicator if (mouseIsPressed && !mouseHasDraggedFar) { drawLightningChargeIndicator(); }

Shows a circle around the mouse while charging lightning

Line by Line:

updateWeatherState()
Recalculates all weather metrics (rain intensity, wind intensity, etc.) based on current particle counts and updates audio volumes
drawBackgroundGradient()
Draws the sky as a vertical gradient from top to bottom, changing colors based on day/night and storm intensity
if (shouldDrawAurora()) { drawAurora(); }
Checks if conditions are right for aurora (clear night), and if so, draws the animated aurora borealis
drawClouds()
Updates and draws all cloud objects
updateAndDrawWind()
Updates all wind particles (moves them, applies decay) and draws them as streaks
updateAndDrawPrecipitation()
Updates and draws all rain drops and snowflakes, applying gravity and wind influence
updateAndDrawLightning()
Updates and draws all lightning bolts, aging them until they disappear
drawGroundAccumulation()
Draws the ground strip and renders water/snow heights at each ground segment
applyLightningFlash()
If a lightning strike just happened, draws a white overlay that fades out quickly
if (mouseIsPressed && !mouseHasDraggedFar) { drawLightningChargeIndicator(); }
While the mouse is held down and hasn't moved far (indicating charging), draws a circle showing charge level
drawUI()
Draws the control instructions and AI suggestion text at the bottom of the screen

updateWeatherState()

This function is the bridge between visual state and audio. It converts particle counts into intensity metrics (0-1 values), combines them into an overall storm factor, and uses those values to control audio volumes. The rampTo() calls make volume changes smooth rather than abrupt, which sounds more natural.

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();
}

๐Ÿ”ง Subcomponents:

calculation Weather intensity metrics rainIntensity = constrain(rainDrops.length / 300, 0, 1); snowIntensity = constrain(snowflakes.length / 300, 0, 1); windIntensity = constrain(globalWind.mag() / 20, 0, 1)

Converts particle counts and wind magnitude into 0-1 intensity values

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

Sums up the energy of all active lightning bolts, weighted by how recently they appeared

calculation Overall storm intensity stormFactor = constrain(rainIntensity + snowIntensity + lightningIntensity * 1.2, 0, 1)

Combines all weather metrics into one value that affects sky color and cloud behavior

conditional-and-calls Audio gain ramping if (audioStarted && windGain) { windGain.gain.rampTo(...); ... }

Smoothly adjusts audio volumes based on weather conditions

Line by Line:

rainIntensity = constrain(rainDrops.length / 300, 0, 1)
Divides the number of rain drops by 300 to get a 0-1 value; constrain keeps it in that range
snowIntensity = constrain(snowflakes.length / 300, 0, 1)
Same as rain, but for snowflakes
windIntensity = constrain(globalWind.mag() / 20, 0, 1)
Gets the magnitude (length) of the wind vector, divides by 20 to normalize to 0-1 range
let energy = 0; for (let b of lightningBolts) { ... energy += b.energy * max(0, lifeFactor); }
Loops through all lightning bolts, adding their energy weighted by how fresh they are (older bolts contribute less)
const lifeFactor = 1 - b.age / b.life
Calculates how alive a bolt is: 1 when brand new, 0 when dead
lightningIntensity = constrain(energy, 0, 1)
Converts the total lightning energy to a 0-1 intensity value
stormFactor = constrain(rainIntensity + snowIntensity + lightningIntensity * 1.2, 0, 1)
Combines all intensities to get overall storminess; lightning is weighted 1.2x to make it more dramatic
if (audioStarted && windGain) { ... }
Only adjusts audio if audio has been started and the gain nodes exist
windGain.gain.rampTo(windIntensity * 0.7, 0.5)
Smoothly changes wind volume to 70% of wind intensity over 0.5 seconds
rainGain.gain.rampTo(rainIntensity * 0.9, 0.5)
Smoothly changes rain volume to 90% of rain intensity over 0.5 seconds
const clearAmount = 1 - stormFactor; const sunLevel = isNight ? clearAmount * 0.15 : clearAmount * 0.5
Calculates how clear the sky is; sunny melody plays more during clear daytime, less at night
sunGain.gain.rampTo(sunLevel, 1.0)
Smoothly changes sunny synth volume based on clearness and time of day
updateAccumulation(); updateAISuggestionIfNeeded()
Calls helper functions to decay accumulated water/snow and update the AI suggestion text

updateAISuggestionIfNeeded()

This function provides contextual feedback to guide the user. It only updates every 7 seconds to avoid overwhelming the display. Each suggestion is tailored to the current weather state, encouraging experimentation with different combinations of wind, rain, snow, and lightning.

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.";
  }
}

๐Ÿ”ง Subcomponents:

conditional Suggestion update throttle const now = millis(); if (now - lastSuggestionChange < SUGGESTION_INTERVAL) return

Only updates suggestion every 7 seconds to avoid constant text changes

calculation Total ground accumulation const totalAccum = averageArray(waterHeights) + averageArray(snowHeights)

Calculates average water and snow depth for context-aware suggestions

switch-case Contextual suggestion selection if (stormFactor < 0.1) { ... } else if (rainIntensity > 0.5 && windIntensity < 0.3) { ... } ... else { ... }

Selects different suggestions based on current weather conditions

Line by Line:

const now = millis()
Gets the current time in milliseconds since the sketch started
if (now - lastSuggestionChange < SUGGESTION_INTERVAL) return
If less than 7000ms (7 seconds) have passed since the last suggestion change, exit early
lastSuggestionChange = now
Updates the timestamp so we know when the next suggestion can change
const totalAccum = averageArray(waterHeights) + averageArray(snowHeights)
Calculates the average height of water and snow across all ground segments
if (stormFactor < 0.1) { currentSuggestion = "Skies are calm..."; }
If weather is very calm (less than 10% storm intensity), suggests dragging wind or clicking for rain
else if (rainIntensity > 0.5 && windIntensity < 0.3) { ... }
If it's raining heavily but winds are calm, suggests adding wind to create drama
else if (windIntensity > 0.5 && rainIntensity + snowIntensity < 0.3) { ... }
If winds are strong but no precipitation, suggests adding rain or snow
else if (snowIntensity > 0.4 && !isNight) { ... }
If it's snowing during the day, suggests switching to night mode to see the effect better
else if (snowIntensity > 0.4 && isNight && stormFactor < 0.5) { ... }
If it's a quiet snowy night, suggests keeping it clear for aurora or adding lightning
else if (lightningIntensity > 0.3 && rainIntensity < 0.3) { ... }
If there's lightning but no rain, suggests adding rain to complete the thunderstorm
else if (totalAccum > 40 && rainIntensity > snowIntensity) { ... }
If water is accumulating heavily, suggests switching to snow for variety
else if (totalAccum > 40 && snowIntensity > rainIntensity) { ... }
If snow is piling up, suggests using wind to create drifting patterns
else { currentSuggestion = "Keep playing with the balance..."; }
Default suggestion for states that don't match other conditions

drawBackgroundGradient()

This function creates a smooth vertical gradient that changes based on time of day and weather. Instead of using p5's built-in gradient, it draws horizontal lines, each with a slightly different color. This approach is simple and gives precise control over the color transitions.

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);
  }
}

๐Ÿ”ง Subcomponents:

initialization Color palette setup const clearDayTop = color(120, 190, 255); ... const stormBottom = color(80, 40, 90)

Defines base colors for day/night and storm conditions

conditional Day/night color selection const baseTop = isNight ? nightTop : clearDayTop; const baseBottom = isNight ? nightBottom : clearDayBottom

Chooses base colors based on whether it's day or night

calculation Storm color blending const top = lerpColor(baseTop, stormTop, stormFactor); const bottom = lerpColor(baseBottom, stormBottom, stormFactor)

Blends base colors toward storm colors based on storm intensity

for-loop Vertical gradient rendering for (let y = 0; y < height; y++) { ... }

Draws horizontal lines from top to bottom, each with a slightly different color

Line by Line:

const clearDayTop = color(120, 190, 255)
Defines the top color for a clear day: a light blue
const clearDayBottom = color(255, 220, 150)
Defines the bottom color for a clear day: a golden/warm color
const nightTop = color(10, 15, 40)
Defines the top color for night: very dark blue
const nightBottom = color(5, 5, 15)
Defines the bottom color for night: almost black
const stormTop = color(40, 20, 70)
Defines the top color during a storm: dark purple
const stormBottom = color(80, 40, 90)
Defines the bottom color during a storm: darker purple
const baseTop = isNight ? nightTop : clearDayTop
Selects either night or day top color based on the isNight flag
const top = lerpColor(baseTop, stormTop, stormFactor)
Blends from base color toward storm color; stormFactor of 0 gives base color, 1 gives storm color
for (let y = 0; y < height; y++)
Loops through every pixel row from top (y=0) to bottom (y=height)
const t = y / max(1, height - 1)
Calculates a 0-1 value representing the vertical position (0 at top, 1 at bottom)
const c = lerpColor(top, bottom, t)
Interpolates between top and bottom colors based on vertical position
stroke(c); line(0, y, width, y)
Sets the stroke color and draws a horizontal line across the full width at this y position

shouldDrawAurora()

This simple function encapsulates the logic for when aurora should appear. It's a good practice to extract conditions like this into separate functions to keep draw() clean and make the logic easy to understand.

function shouldDrawAurora() {
  // Clear night with relatively low storminess
  return isNight && stormFactor < 0.35;
}

Line by Line:

return isNight && stormFactor < 0.35
Returns true only if it's night AND the storm intensity is below 35%, allowing aurora to appear in calm nights

drawAurora()

This function creates an animated aurora borealis using Perlin noise. Perlin noise creates smooth, organic-looking waves that animate naturally. By using different noise offsets for each band (b * 10 and b * 50), each band has its own unique wave pattern, creating depth and visual interest.

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);
  }
}

๐Ÿ”ง Subcomponents:

calculation Animation time const t = frameCount * 0.01

Creates a slowly changing value based on frame count for smooth animation

for-loop Aurora band rendering for (let b = 0; b < bands; b++)

Draws 3 overlapping bands of aurora with different colors and opacities

conditional Aurora color alternation const col = b % 2 === 0 ? color(120, 255, 200, alpha) : color(180, 190, 255, alpha)

Alternates between cyan-green and blue colors for each band

for-loop Wavy edge generation for (let x = 0; x <= width; x += 30)

Creates vertices along the top edge of each aurora band using Perlin noise

calculation Perlin noise to position 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)

Uses Perlin noise to create smooth, organic wavy shapes that animate over time

Line by Line:

noStroke()
Disables stroke so only filled shapes are drawn
const t = frameCount * 0.01
Creates a time variable that increases slowly (0.01 per frame), used for animation
const bands = 3
Sets the number of aurora bands to draw (3 layers)
for (let b = 0; b < bands; b++)
Loops 3 times, once for each aurora band
const alpha = 70 - b * 15
Each band is progressively more transparent: band 0 has alpha 70, band 1 has 55, band 2 has 40
const col = b % 2 === 0 ? color(120, 255, 200, alpha) : color(180, 190, 255, alpha)
Alternates colors: even bands are cyan-green, odd bands are blue
fill(col)
Sets the fill color for this band
beginShape()
Starts defining a polygon shape
for (let x = 0; x <= width; x += 30)
Loops across the width in 30-pixel steps, creating vertices for the wavy top edge
const nx = x / width
Normalizes x to a 0-1 value for use in the noise function
const n = noise(nx * 3 + b * 10, t + b * 50)
Gets a Perlin noise value using normalized x position and time; different bands use different noise offsets
const y = map(n, 0, 1, height * 0.02 + b * 10, height * 0.18 + b * 15)
Maps the noise value (0-1) to a y position in the upper portion of the screen, with each band at a different height
vertex(x, y)
Adds a vertex at the calculated position
vertex(width, 0); vertex(0, 0); endShape(CLOSE)
Closes the shape by adding vertices at the top corners, creating a filled band

drawClouds()

This is a simple wrapper function that updates and draws all clouds. It's a good example of object-oriented design: each Cloud object knows how to update and draw itself, so the main loop just needs to call those methods.

function drawClouds() {
  for (let c of clouds) {
    c.update();
    c.draw();
  }
}

๐Ÿ”ง Subcomponents:

for-loop Cloud update and render for (let c of clouds)

Iterates through all cloud objects

Line by Line:

for (let c of clouds)
Loops through each cloud in the clouds array
c.update()
Updates the cloud's position based on wind and time
c.draw()
Draws the cloud at its current position

updateAndDrawWind()

Wind particles are visual streaks that show wind direction. They decay naturally and are removed when dead. The global wind vector decays each frame, so wind effects gradually fade unless the user drags again to add more wind.

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);
    }
  }
}

๐Ÿ”ง Subcomponents:

calculation Global wind decay globalWind.mult(0.98)

Gradually reduces wind strength each frame, making wind effects fade naturally

for-loop Wind particle update and removal for (let i = windParticles.length - 1; i >= 0; i--)

Loops backward through particles so removal doesn't skip items

Line by Line:

globalWind.mult(0.98)
Multiplies the wind vector by 0.98, reducing its magnitude by 2% each frame, creating a natural decay
for (let i = windParticles.length - 1; i >= 0; i--)
Loops backward through the array (from last to first) so that removing items doesn't affect the loop
const p = windParticles[i]; p.update(); p.draw()
Gets the particle, updates its position, and draws it
if (p.isDead()) { windParticles.splice(i, 1); }
If the particle is dead (too old or off-screen), removes it from the array

updateAndDrawPrecipitation()

This function handles both rain and snow using the same PrecipParticle class with a type parameter. Both are updated and drawn the same way, but they have different physics (rain falls faster, snow drifts sideways).

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);
    }
  }
}

๐Ÿ”ง Subcomponents:

for-loop Rain drop processing for (let i = rainDrops.length - 1; i >= 0; i--)

Updates and draws all rain drops, removing dead ones

for-loop Snowflake processing for (let i = snowflakes.length - 1; i >= 0; i--)

Updates and draws all snowflakes, removing dead ones

Line by Line:

for (let i = rainDrops.length - 1; i >= 0; i--)
Loops backward through rain drops
d.update(); d.draw()
Updates the drop's position (applying gravity and wind) and draws it
if (d.isDead()) { rainDrops.splice(i, 1) }
Removes the drop if it's dead (hit ground or off-screen)
for (let i = snowflakes.length - 1; i >= 0; i--)
Same pattern for snowflakes

updateAndDrawLightning()

Lightning bolts are short-lived visual effects. Each bolt ages and fades out, then is removed from the array. This keeps the scene clean and prevents memory from filling up with dead bolts.

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);
    }
  }
}

๐Ÿ”ง Subcomponents:

for-loop Lightning bolt processing for (let i = lightningBolts.length - 1; i >= 0; i--)

Updates and draws all lightning bolts, removing dead ones

Line by Line:

for (let i = lightningBolts.length - 1; i >= 0; i--)
Loops backward through lightning bolts
b.update(); b.draw()
Updates the bolt's age and draws it
if (b.isDead()) { lightningBolts.splice(i, 1) }
Removes the bolt when it's finished (aged beyond its life)

drawGroundAccumulation()

This function visualizes accumulation by drawing rectangles that grow taller as water and snow pile up. The ground itself changes color from dry (greenish) to wet (grayish) based on storm intensity. Snow is drawn on top of water, so you can see both layers.

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);
    }
  }
}

๐Ÿ”ง Subcomponents:

calculation Ground segment width const segW = width / GROUND_SEGMENTS

Calculates the width of each ground segment (80 segments across the screen)

drawing Base ground rectangle fill(groundCol); rect(0, height - 18, width, 18)

Draws the base ground strip at the bottom of the screen

for-loop Water and snow rendering for (let i = 0; i < GROUND_SEGMENTS; i++)

Draws water and snow accumulation for each ground segment

Line by Line:

const segW = width / GROUND_SEGMENTS
Calculates the width of each ground segment by dividing screen width by 80
noStroke()
Disables stroke for clean-looking rectangles
const dryGround = color(50, 60, 40); const wetGround = color(30, 35, 45)
Defines colors for dry and wet ground
const groundCol = lerpColor(dryGround, wetGround, stormFactor)
Blends between dry and wet colors based on how stormy it is
fill(groundCol); rect(0, height - 18, width, 18)
Draws the base ground strip 18 pixels tall at the bottom of the screen
for (let i = 0; i < GROUND_SEGMENTS; i++)
Loops through each of the 80 ground segments
const waterH = waterHeights[i]; const snowH = snowHeights[i]
Gets the water and snow heights for this segment
if (waterH > 0.1) { fill(80, 140, 210, 190); rect(i * segW, height - 18 - waterH, segW + 1, waterH); }
If water height is significant, draws a blue rectangle above the ground representing accumulated water
if (snowH > 0.1) { fill(245, 250, 255, 230); rect(i * segW, height - 18 - waterH - snowH, segW + 1, snowH); }
If snow height is significant, draws a white rectangle above the water representing accumulated snow

groundIndexForX(x)

This helper function converts a screen x-coordinate to a ground segment index. It's used when particles land to determine which segment should accumulate water or snow.

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);
}

๐Ÿ”ง Subcomponents:

calculation X position clamping const clampedX = constrain(x, 0, width - 1)

Ensures x is within screen bounds

calculation Position to segment index const idx = floor(map(clampedX, 0, width, 0, GROUND_SEGMENTS))

Converts screen x position to ground segment index (0-79)

Line by Line:

const clampedX = constrain(x, 0, width - 1)
Ensures x is between 0 and width-1, preventing out-of-bounds errors
const idx = floor(map(clampedX, 0, width, 0, GROUND_SEGMENTS))
Maps x from screen coordinates (0 to width) to segment index (0 to 80), then floors to get an integer
return constrain(idx, 0, GROUND_SEGMENTS - 1)
Returns the index, ensuring it's between 0 and 79

getGroundSurfaceY(x)

This function calculates where the ground surface is at a given x position, accounting for accumulated water and snow. Particles use this to know when they've landed.

function getGroundSurfaceY(x) {
  const idx = groundIndexForX(x);
  const h = waterHeights[idx] + snowHeights[idx];
  const baseY = height - 18; // top of ground strip
  return baseY - h;
}

๐Ÿ”ง Subcomponents:

function-call Get segment index const idx = groundIndexForX(x)

Converts x position to ground segment index

calculation Total accumulation height const h = waterHeights[idx] + snowHeights[idx]

Gets the combined water and snow height at this segment

Line by Line:

const idx = groundIndexForX(x)
Gets the ground segment index for this x position
const h = waterHeights[idx] + snowHeights[idx]
Adds water and snow heights to get total accumulation
const baseY = height - 18
The ground surface is 18 pixels from the bottom
return baseY - h
Returns the y position of the surface (ground top minus accumulation height)

updateAccumulation()

This function simulates realistic accumulation behavior: water and snow gradually disappear (evaporation/melting), and piles spread laterally to neighboring segments. Day/night differences make the simulation feel more alive.

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);
}

๐Ÿ”ง Subcomponents:

calculation Day/night damping rates const waterDamping = isNight ? 0.9995 : 0.998; const snowDamping = isNight ? 0.9998 : 0.9993

Sets different decay rates for day and night (water evaporates faster in day, snow melts slower at night)

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

Reduces all accumulation values each frame, simulating evaporation and melting

function-call Lateral smoothing smoothArray(waterHeights, 0.3); smoothArray(snowHeights, 0.2)

Smooths accumulation across segments so piles spread naturally

Line by Line:

const waterDamping = isNight ? 0.9995 : 0.998
Water decays slower at night (0.9995) than during day (0.998), simulating less evaporation
const snowDamping = isNight ? 0.9998 : 0.9993
Snow decays slower at night (0.9998) than during day (0.9993), simulating slower melting at night
for (let i = 0; i < GROUND_SEGMENTS; i++) { waterHeights[i] *= waterDamping; snowHeights[i] *= snowDamping; }
Multiplies each segment's height by the damping factor, gradually reducing it
smoothArray(waterHeights, 0.3); smoothArray(snowHeights, 0.2)
Smooths the heights so adjacent segments influence each other, creating natural-looking piles

smoothArray(arr, amt)

This function implements a simple smoothing algorithm that makes sharp peaks in the accumulation array blend with their neighbors. It's used to make water and snow piles look more natural and spread gradually.

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);
  }
}

๐Ÿ”ง Subcomponents:

initialization Temporary array copy const tmp = arr.slice()

Creates a copy so we read original values while writing new ones

for-loop Smoothing calculation for (let i = 0; i < arr.length; i++)

Processes each element, blending it with its neighbors

Line by Line:

const tmp = arr.slice()
Creates a shallow copy of the array so we can read original values while modifying the original
for (let i = 0; i < arr.length; i++)
Loops through each element
const left = tmp[max(0, i - 1)]; const center = tmp[i]; const right = tmp[min(arr.length - 1, i + 1)]
Gets the left neighbor, center, and right neighbor, clamping to array bounds
const avg = (left + center + right) / 3
Calculates the average of the three values
arr[i] = lerp(center, avg, amt)
Blends the original center value with the average; amt controls how much smoothing (0 = no change, 1 = full average)

averageArray(arr)

This simple utility function calculates the average of an array. It's used to get the overall accumulation level for the AI suggestions.

function averageArray(arr) {
  if (arr.length === 0) return 0;
  let sum = 0;
  for (let v of arr) sum += v;
  return sum / arr.length;
}

๐Ÿ”ง Subcomponents:

conditional Empty array check if (arr.length === 0) return 0

Prevents division by zero

for-loop Sum calculation for (let v of arr) sum += v

Adds all values together

Line by Line:

if (arr.length === 0) return 0
Returns 0 if the array is empty to avoid division by zero
let sum = 0
Initializes a sum variable
for (let v of arr) sum += v
Loops through each value and adds it to the sum
return sum / arr.length
Returns the average (sum divided by number of elements)

spawnLightning(x, chargeRatio)

This function handles creating a lightning bolt and its effects. It creates the visual bolt, limits the number of bolts on screen, triggers a screen flash, and plays thunder audio. The chargeRatio (0-1) determines the intensity of all these effects.

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);
}

๐Ÿ”ง Subcomponents:

initialization Lightning bolt instantiation const bolt = new LightningBolt(x, chargeRatio); lightningBolts.push(bolt)

Creates a new lightning bolt and adds it to the array

conditional Lightning count limit if (lightningBolts.length > MAX_LIGHTNING) { lightningBolts.shift(); }

Removes the oldest bolt if there are more than 8

calculation Screen flash effect lightningFlash = max(lightningFlash, chargeRatio * 1.3)

Sets the flash intensity based on how much the lightning was charged

Line by Line:

const bolt = new LightningBolt(x, chargeRatio)
Creates a new lightning bolt at x position with energy based on charge ratio
lightningBolts.push(bolt)
Adds the bolt to the array so it will be updated and drawn
if (lightningBolts.length > MAX_LIGHTNING) { lightningBolts.shift(); }
If there are more than 8 bolts, removes the oldest one (first in array)
lightningFlash = max(lightningFlash, chargeRatio * 1.3)
Sets the flash intensity to 130% of the charge ratio, or keeps the current flash if it's higher
triggerThunder(x, chargeRatio)
Calls the function to play thunder sound

triggerThunder(strikeX, strength)

This function creates realistic thunder audio: strikes closer to the center play immediately and loudly, while distant strikes have a delay (simulating the speed of sound) and are quieter. Stronger charges produce longer, louder thunder. This creates a convincing spatial audio effect.

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);
}

๐Ÿ”ง Subcomponents:

conditional Audio system check if (!audioStarted || typeof Tone === "undefined" || !thunderNoise) return

Exits if audio isn't available

calculation Distance-based delay 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)

Calculates how far the strike is from center, then maps to a delay (farther = later sound)

calculation Thunder duration and volume const duration = lerp(1.8, 3.5, strength); const peakGain = lerp(0.4, 1.0, strength) * lerp(1.0, 0.5, distanceNorm)

Sets how long thunder lasts and how loud it is based on strength and distance

audio-control Gain envelope automation thunderGain.gain.setValueAtTime(0, t); thunderGain.gain.linearRampToValueAtTime(peakGain, t + 0.3); thunderGain.gain.linearRampToValueAtTime(0, t + duration)

Schedules the volume to start at 0, ramp up to peak in 0.3s, then fade out

Line by Line:

if (!audioStarted || typeof Tone === "undefined" || !thunderNoise) return
Exits early if audio isn't ready
const listenerX = width / 2
Assumes the listener is at the center of the screen
const dx = abs(strikeX - listenerX); const distanceNorm = constrain(dx / (width / 2), 0, 1)
Calculates how far the strike is from center as a 0-1 value
const delaySeconds = lerp(0.2, 2.8, distanceNorm)
Maps distance to delay: strikes at center play immediately (0.2s), strikes at edges play 2.8s later
const duration = lerp(1.8, 3.5, strength)
Weak strikes are 1.8s long, strong strikes are 3.5s long
const peakGain = lerp(0.4, 1.0, strength) * lerp(1.0, 0.5, distanceNorm)
Calculates peak volume: stronger strikes are louder, distant strikes are quieter
const now = Tone.now(); const t = now + delaySeconds
Gets the current audio time and calculates when the thunder should start
thunderGain.gain.setValueAtTime(0, t)
Schedules the volume to be 0 at the start time
thunderGain.gain.linearRampToValueAtTime(peakGain, t + 0.3)
Schedules a linear ramp from 0 to peak volume over 0.3 seconds
thunderGain.gain.linearRampToValueAtTime(0, t + duration)
Schedules a linear ramp from peak volume to 0 over the duration
thunderNoise.start(t); thunderNoise.stop(t + duration)
Starts the noise at time t and stops it after the duration

applyLightningFlash()

This function creates the visual flash effect when lightning strikes. It draws a white overlay that fades quickly, giving the impression of a bright flash. The flash is drawn on top of everything else in draw().

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

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

  lightningFlash *= 0.85;
}

๐Ÿ”ง Subcomponents:

conditional Flash intensity check if (lightningFlash <= 0.01) return

Exits early if flash is nearly invisible

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

Draws a white rectangle covering the entire screen

calculation Flash fade out lightningFlash *= 0.85

Reduces flash intensity each frame so it fades quickly

Line by Line:

if (lightningFlash <= 0.01) return
Exits early if the flash is too faint to see, saving processing
noStroke()
Disables stroke for the overlay
fill(255, 255, 255, lightningFlash * 120)
Sets fill to white with alpha based on flash intensity (0-1 becomes 0-120 alpha)
rect(0, 0, width, height)
Draws a white rectangle covering the entire screen
lightningFlash *= 0.85
Reduces the flash by 15% each frame, creating a quick fade

drawLightningChargeIndicator()

This function provides visual feedback while the user is charging lightning. The circles grow larger as the charge builds up, giving clear feedback about how much longer to hold for maximum power.

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);
}

๐Ÿ”ง Subcomponents:

calculation Press duration calculation const pressDuration = millis() - mousePressStartMillis

Calculates how long the mouse has been held down

conditional Minimum duration check if (pressDuration < 50) return

Doesn't show indicator for very short presses

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

Maps press duration to a radius from 20 to 80 pixels

drawing Charge circle rendering ellipse(mouseX, mouseY, r * 2, r * 2); ellipse(mouseX, mouseY, r * 1.2, r * 1.2)

Draws two circles: outer white outline and inner yellow fill

Line by Line:

const pressDuration = millis() - mousePressStartMillis
Calculates milliseconds since mouse was pressed
if (pressDuration < 50) return
Doesn't show indicator for presses shorter than 50ms
const ratio = constrain(pressDuration / MAX_LIGHTNING_CHARGE, 0, 1)
Divides press duration by 2000ms (MAX_LIGHTNING_CHARGE) and clamps to 0-1
const r = map(ratio, 0, 1, 20, 80)
Maps the ratio to a radius: 0 ratio = 20px, 1 ratio = 80px
noFill(); stroke(255, 255, 255, 160); strokeWeight(2); ellipse(mouseX, mouseY, r * 2, r * 2)
Draws a white circle outline at the mouse position with diameter r*2
noStroke(); fill(255, 255, 200, 80); ellipse(mouseX, mouseY, r * 1.2, r * 1.2)
Draws a yellow-tinted filled circle inside, slightly smaller

drawUI()

This function draws the user interface: two semi-transparent boxes with text. The controls box is fixed at the top, and the suggestion box is at the bottom. Both use rounded corners for a polished look.

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);
}

๐Ÿ”ง Subcomponents:

drawing Controls information box fill(0, 0, 0, 130); rect(10, 10, 360, 120, 8); ... text(...)

Draws a semi-transparent box with control instructions

drawing AI suggestion box fill(0, 0, 0, 140); rect(10, boxY, 470, 80, 8); ... text(...)

Draws a semi-transparent box with the current AI suggestion

Line by Line:

noStroke(); fill(0, 0, 0, 130); rect(10, 10, 360, 120, 8)
Draws a semi-transparent black box (130 alpha) at top-left with rounded corners (8px radius)
fill(255); textSize(14); textAlign(LEFT, TOP)
Sets text color to white, size to 14, and alignment to top-left
text("AI Weather Symphony\n...", 18, 16)
Draws the control instructions text at position (18, 16)
const boxY = height - 90
Calculates the y position for the suggestion box (90 pixels from bottom)
fill(0, 0, 0, 140); rect(10, boxY, 470, 80, 8)
Draws the suggestion box at the bottom with rounded corners
fill(230); textSize(13); textAlign(LEFT, TOP)
Sets text color to light gray and size to 13
text("AI suggestion:\n" + currentSuggestion, 18, boxY + 10)
Draws the suggestion text inside the box

mousePressed()

This function is called when the mouse is pressed. It records the press time and position, which are used later to determine whether the user is dragging (wind), clicking (rain/snow), or charging (lightning).

function mousePressed() {
  mousePressStartMillis = millis();
  mousePressStartPos = createVector(mouseX, mouseY);
  mouseHasDraggedFar = false;
  startAudioIfNeeded();
}

๐Ÿ”ง Subcomponents:

initialization Press time recording mousePressStartMillis = millis()

Records when the mouse was pressed

initialization Press position recording mousePressStartPos = createVector(mouseX, mouseY)

initialization Drag flag reset mouseHasDraggedFar = false

Resets the flag that tracks if the mouse has moved far

Line by Line:

mousePressStartMillis = millis()
Records the current time in milliseconds
mousePressStartPos = createVector(mouseX, mouseY)
Records the mouse position as a vector
mouseHasDraggedFar = false
Resets the flag to false; it will be set to true in mouseDragged() if the mouse moves far
startAudioIfNeeded()
Starts the audio system on first user interaction (required by browsers)

mouseDragged()

This function handles dragging. It detects when the user has moved the mouse far enough to count as a drag (rather than a click), updates the global wind vector, and spawns visual wind particles along the drag path. The particles have randomness added so they don't look too uniform.

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
      );
    }
  }
}

๐Ÿ”ง Subcomponents:

conditional Drag distance detection if (mousePressStartPos) { const d = dist(...); if (d > DRAG_DISTANCE_THRESHOLD) mouseHasDraggedFar = true; }

Checks if the mouse has moved more than 20 pixels from the press point

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

Calculates the movement vector from the previous frame

calculation Global wind update const adj = dragVec.copy().mult(0.25); globalWind = p5.Vector.lerp(globalWind, adj, 0.3)

Blends the global wind toward the drag direction

for-loop Wind particle creation for (let i = 0; i < 5; i++) { ... windParticles.push(...) }

Creates 5 wind particles along the drag path with random jitter

conditional Wind particle count limit if (windParticles.length > MAX_WIND_PARTICLES) { windParticles.splice(...) }

Removes oldest particles if count exceeds 900

Line by Line:

if (mousePressStartPos) { const d = dist(mouseX, mouseY, mousePressStartPos.x, mousePressStartPos.y); if (d > DRAG_DISTANCE_THRESHOLD) mouseHasDraggedFar = true; }
Calculates distance from press point to current position; if greater than 20px, sets the drag flag to true
const dx = mouseX - pmouseX; const dy = mouseY - pmouseY
Calculates how far the mouse moved since the last frame (pmouseX and pmouseY are p5's previous mouse position variables)
const dragVec = createVector(dx, dy)
Creates a vector from the frame-to-frame movement
if (dragVec.mag() > 0.5)
Only processes if the movement is significant (more than 0.5 pixels)
const adj = dragVec.copy().mult(0.25); globalWind = p5.Vector.lerp(globalWind, adj, 0.3)
Scales the drag vector by 0.25 and blends it with the current wind, so wind gradually changes direction
for (let i = 0; i < 5; i++) { ... }
Creates 5 wind particles per frame
const jitter = p5.Vector.random2D().mult(0.5)
Creates a random direction vector and scales it by 0.5 for randomness
const v = dragVec.copy().add(jitter).mult(random(0.1, 0.3))
Creates particle velocity by combining drag direction with jitter and random scaling
const px = lerp(pmouseX, mouseX, random()); const py = lerp(pmouseY, mouseY, random())
Randomly places particles along the drag path from previous to current mouse position
windParticles.push(new WindParticle(px, py, v))
Creates and adds a new wind particle
if (windParticles.length > MAX_WIND_PARTICLES) { windParticles.splice(0, windParticles.length - MAX_WIND_PARTICLES); }
If there are more than 900 particles, removes the oldest ones from the beginning of the array

mouseReleased()

This function interprets the mouse release to determine what action the user intended. It checks the total movement distance and press duration to distinguish between three gestures: drag (wind), quick click (rain/snow), and long hold (lightning). The Shift key modifies the precipitation type.

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);
  }
}

๐Ÿ”ง Subcomponents:

calculation Press duration calculation const pressDuration = millis() - mousePressStartMillis

Calculates how long the mouse was held down

calculation Total movement distance const movedDistance = mousePressStartPos ? dist(mouseX, mouseY, mousePressStartPos.x, mousePressStartPos.y) : 0

Calculates total distance from press point to release point

conditional Drag gesture detection if (mouseHasDraggedFar || movedDistance > DRAG_DISTANCE_THRESHOLD) { return; }

Exits early if this was a drag gesture (don't create rain/snow/lightning)

conditional Lightning charge detection if (pressDuration > LIGHTNING_CHARGE_THRESHOLD) { ... spawnLightning(...) }

If held for more than 400ms, creates lightning

conditional Precipitation type selection else { const type = keyIsDown(SHIFT) ? "snow" : "rain"; spawnPrecipitationBurst(...) }

If held for less than 400ms, creates rain or snow based on Shift key

Line by Line:

const pressDuration = millis() - mousePressStartMillis
Calculates how many milliseconds the mouse was held down
const movedDistance = mousePressStartPos ? dist(mouseX, mouseY, mousePressStartPos.x, mousePressStartPos.y) : 0
Calculates total distance from press to release; returns 0 if mousePressStartPos is undefined
if (mouseHasDraggedFar || movedDistance > DRAG_DISTANCE_THRESHOLD) { return; }
If the mouse moved far (either detected during drag or total distance > 20px), this was a wind gesture, so exit
if (pressDuration > LIGHTNING_CHARGE_THRESHOLD)
If held for more than 400ms, treat as lightning charge
const chargeRatio = constrain(pressDuration / MAX_LIGHTNING_CHARGE, 0, 1)
Calculates charge as a 0-1 value: 400ms = 0, 2000ms = 1, anything longer = 1
spawnLightning(mouseX, chargeRatio)
Creates a lightning bolt at the mouse position with the calculated charge
else { const type = keyIsDown(SHIFT) ? "snow" : "rain"; spawnPrecipitationBurst(mouseX, mouseY, type); }
If held for less than 400ms, check if Shift is held: if yes, create snow; if no, create rain

spawnPrecipitationBurst(x, y, type)

This function creates a burst of precipitation particles at the click location. Rain bursts are denser (80 particles) than snow bursts (45 particles). Both types are spread randomly around the click point. The arrays are kept under their maximum size by removing the oldest particles.

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);
  }
}

๐Ÿ”ง Subcomponents:

conditional Particle count based on type const count = type === "rain" ? 80 : 45

Rain bursts create 80 particles, snow bursts create 45

for-loop Particle creation loop for (let i = 0; i < count; i++)

Creates the specified number of particles with random offsets

conditional Array size limiting if (rainDrops.length > MAX_RAIN) { rainDrops.splice(...) } if (snowflakes.length > MAX_SNOW) { snowflakes.splice(...) }

Removes oldest particles if count exceeds limits (800 each)

Line by Line:

const count = type === "rain" ? 80 : 45
Rain bursts create 80 particles, snow bursts create 45 (rain is more dense)
for (let i = 0; i < count; i++)
Loops to create the specified number of particles
const offsetX = random(-30, 30); const offsetY = random(-10, 10)
Adds random offsets so particles don't all spawn at the exact same point
const px = x + offsetX; const py = y + offsetY
Calculates the actual spawn position by adding offsets to the click position
if (type === "rain") { rainDrops.push(...) } else { snowflakes.push(...) }
Creates a PrecipParticle with the appropriate type and adds it to the correct array
if (rainDrops.length > MAX_RAIN) { rainDrops.splice(0, rainDrops.length - MAX_RAIN); }
If rain exceeds 800 particles, removes the oldest ones from the beginning
if (snowflakes.length > MAX_SNOW) { snowflakes.splice(0, snowflakes.length - MAX_SNOW); }
If snow exceeds 800 particles, removes the oldest ones

keyPressed()

This simple function toggles between day and night mode. When night mode is on, the sky becomes dark, aurora can appear, and audio levels change. This single key press affects the entire visual and audio atmosphere.

function keyPressed() {
  if (key === "n" || key === "N") {
    isNight = !isNight;
  }
}

๐Ÿ”ง Subcomponents:

conditional Night/day toggle if (key === "n" || key === "N") { isNight = !isNight; }

Toggles between day and night mode

Line by Line:

if (key === "n" || key === "N")
Checks if the pressed key is 'n' or 'N' (case-insensitive)
isNight = !isNight
Toggles the isNight boolean (true becomes false, false becomes true)

windowResized()

This function is called automatically by p5.js when the browser window is resized. It ensures the canvas stays full-screen and reinitializes clouds so they're properly distributed for the new dimensions.

function windowResized() {
  resizeCanvas(windowWidth, windowHeight);
  initClouds();
}

๐Ÿ”ง Subcomponents:

function-call Canvas resizing resizeCanvas(windowWidth, windowHeight)

Resizes the canvas to match the new window size

function-call Cloud reinitialization initClouds()

Recreates clouds for the new screen size

Line by Line:

resizeCanvas(windowWidth, windowHeight)
p5.js function that resizes the canvas to match the current window dimensions
initClouds()
Recreates the cloud array so clouds are properly distributed for the new canvas size

class PrecipParticle

PrecipParticle is a class that represents both rain and snow. Rain falls fast and straight, while snow falls slowly and drifts side-to-side. Both accumulate on the ground when they land. The class encapsulates all the logic for a single particle: initialization, physics, collision, and rendering.

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);
    }
  }
}

๐Ÿ”ง Subcomponents:

method Constructor constructor(x, y, type) { ... }

Initializes a precipitation particle with type-specific properties

method Update method update() { ... }

Updates particle position each frame, applying gravity and wind

method Ground landing method landAtGround() { ... }

Handles accumulation when particle hits the ground

method Death check method isDead() { ... }

Returns true if particle should be removed

method Draw method draw() { ... }

Renders the particle as a line (rain) or circle (snow)

Line by Line:

constructor(x, y, type) { this.type = type; this.pos = createVector(x, y); ... }
Creates a new particle at position (x, y) with the specified type (rain or snow)
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; }
For rain: fast falling speed (4-7), affected by wind, larger size (6-10), heavier 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); }
For snow: slow falling speed (1-3), less wind effect, smaller size (3-6), lighter mass (0.7), plus a phase for side-to-side drift
update() { if (this.dead) return; ... }
Updates the particle; exits early if already dead
const gravity = this.type === "rain" ? 0.25 : 0.06; this.vel.y += gravity
Applies gravity: rain falls faster (0.25) than snow (0.06)
this.vel.x += globalWind.x * 0.03
Adds wind influence to horizontal velocity
if (this.type === "snow") { this.pos.x += sin(this.phase + this.pos.y * 0.03) * 0.3; }
For snow only: adds a sine-wave side drift that varies with vertical position for a floating effect
this.pos.add(this.vel)
Updates position by adding velocity (standard physics)
const groundY = getGroundSurfaceY(this.pos.x); if (this.pos.y + this.size * 0.5 >= groundY) { this.landAtGround(); }
Checks if the particle has reached the ground (accounting for particle size), and if so, lands it
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; }
Finds the ground segment at this particle's x position and adds to water or snow height; rain adds 1.2x mass, snow adds 1.5x mass
isDead() { return this.dead || this.pos.y > height + 50 || this.pos.x < -50 || this.pos.x > width + 50; }
Returns true if particle is marked dead, or has fallen below screen, or moved off-screen horizontally
draw() { if (this.type === "rain") { ... line(...); } else { ... ellipse(...); } }
Draws rain as a blue vertical line, snow as a white circle

class WindParticle

WindParticle represents a visual streak showing wind direction. It's created along the drag path and fades out over time. The line is drawn in the direction opposite to velocity, creating a trailing effect. Wind particles are purely visual and don't affect precipitation.

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
    );
  }
}

๐Ÿ”ง Subcomponents:

method Constructor constructor(x, y, vel) { ... }

Creates a wind particle with position, velocity, and random lifespan

method Update method update() { ... }

Moves particle and applies wind influence and drag

method Death check method isDead() { ... }

Returns true if particle should be removed

method Draw method draw() { ... }

Renders the particle as a fading line

Line by Line:

constructor(x, y, vel) { this.pos = createVector(x, y); this.vel = vel.copy(); this.life = int(random(40, 80)); this.age = 0; }
Creates a wind particle at (x, y) with the given velocity, random lifespan of 40-80 frames, and age 0
update() { this.pos.add(this.vel); this.vel.add(p5.Vector.mult(globalWind, 0.02)); this.vel.mult(0.98); this.age++; }
Moves particle, adds global wind influence, applies 2% drag, and increments 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; }
Returns true if particle is too old or off-screen
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); }
Draws a line from the particle position in the direction opposite to velocity, fading as it ages

class LightningBolt

LightningBolt creates a realistic-looking lightning effect with a jagged path. The path is generated randomly but constrained by the energy level: stronger bolts are longer and spread wider. The bolt is drawn with two layers: a thick blue glow and a thin white core, both fading over 40 frames.

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();
  }
}

๐Ÿ”ง Subcomponents:

method Constructor constructor(x, energy) { ... this.buildPath(x); }

Creates a lightning bolt with random jagged path based on energy

method Path building method buildPath(startX) { ... }

Generates a random jagged path from top to bottom

method Update method update() { this.age++; }

Simply increments age each frame

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

Returns true after 40 frames

method Draw method draw() { ... }

Draws a glow layer and bright core, both fading with age

Line by Line:

constructor(x, energy) { this.energy = constrain(energy, 0.1, 1); this.life = 40; this.age = 0; this.points = []; this.buildPath(x); }
Creates a bolt with clamped energy (0.1-1), 40-frame lifespan, and builds its path
buildPath(startX) { let x = startX; let y = 0; this.points.push(createVector(x, y)); ... }
Starts at the top (y=0) at the given x position
const segments = int(10 + this.energy * 14); const maxSpread = 30 + this.energy * 40
Stronger bolts have more segments (10-24) and spread wider (30-70 pixels)
for (let i = 0; i < segments; i++) { y += height / segments + random(-10, 10); x += random(-maxSpread, maxSpread); this.points.push(createVector(x, y)); }
Creates segments going down the screen, randomly spreading left and right
update() { this.age++; }
Simply increments the age counter each frame
isDead() { return this.age > this.life; }
Returns true when age exceeds 40 frames
const alpha = map(this.age, 0, this.life, 255, 0); const coreWeight = 2 + this.energy * 3; const glowWeight = 6 + this.energy * 6
Calculates fading alpha and stroke weights based on age and energy
stroke(180, 200, 255, alpha * 0.6); strokeWeight(glowWeight); ... for (let p of this.points) vertex(p.x, p.y); endShape()
Draws the glow layer as a thick blue line with 60% alpha
stroke(255, 255, 255, alpha); strokeWeight(coreWeight); ... for (let p of this.points) vertex(p.x, p.y); endShape()
Draws the core as a thinner white line on top of the glow

class Cloud

Cloud is a class that creates realistic-looking clouds with parallax depth. Clouds at different layers move at different speeds and scales, creating a sense of depth. Clouds respond to wind, bob gently, and change color based on time of day and weather. When a cloud goes off-screen, it wraps to the other side.

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);
    }
  }
}

๐Ÿ”ง Subcomponents:

method Constructor constructor(layer) { this.layer = layer; this.reset(...); }

Creates a cloud at a specific depth layer

method Reset method reset(x, y) { ... }

Initializes or reinitializes cloud properties based on layer depth

method Update method update() { ... }

Updates position based on wind and time, wraps around screen

method Draw method draw() { ... }

Renders the cloud as overlapping ellipses with dynamic color

Line by Line:

constructor(layer) { this.layer = layer; this.reset(random(width), random(height * 0.05, height * 0.45)); }
Creates a cloud at a random x position and in the upper half of the screen (5-45% down)
reset(x, y) { this.x = x; this.baseY = y; this.y = y; ... }
Initializes cloud position and properties
this.scale = lerp(0.6, 1.6, this.layer)
Far clouds (layer 0.1) are smaller (0.6x), near clouds (layer 1) are larger (1.6x)
this.w = random(140, 260) * this.scale; this.h = random(50, 100) * this.scale
Randomizes cloud width and height, then scales based on layer depth
this.speedBase = lerp(0.15, 0.5, this.layer)
Far clouds move slowly (0.15 px/frame), near clouds move faster (0.5 px/frame)
this.noiseOffset = random(1000)
Random offset for vertical bobbing so clouds don't all bob in sync
const windInfluence = globalWind.x * 0.04 * (0.4 + this.layer); this.x += (this.speedBase + windInfluence)
Moves cloud horizontally: base speed plus wind influence (stronger for near clouds)
this.y = this.baseY + sin(frameCount * 0.01 + this.noiseOffset) * 6 * this.layer
Adds gentle vertical bobbing using sine wave, more pronounced for near clouds
const stormDrop = lerp(0, height * 0.12, stormFactor) * this.layer; this.y += stormDrop
During storms, clouds drop lower (up to 12% of screen height), more for near clouds
const margin = this.w * 1.3; if (this.x > width + margin) { this.reset(-margin, ...); } else if (this.x < -margin) { this.reset(width + margin, ...); }
When cloud goes off-screen, resets it to the opposite side with a new random y position
let base = isNight ? lerpColor(clearNight, darkNight, stormFactor) : lerpColor(clearDay, storm, stormFactor)
Selects base color based on day/night and blends toward storm color based on storminess
const alpha = lerp(80, 210, 0.3 + 0.5 * this.layer * (0.4 + stormFactor))
Calculates opacity: far clouds are fainter (80), near clouds are more opaque (210), stormy clouds are more visible
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); }
Draws 6 overlapping ellipses in a row to create a fluffy multi-lobed cloud shape

๐Ÿ“ฆ Key Variables

rainDrops array

Stores all active rain drop particles; updated and drawn each frame

let rainDrops = [];
snowflakes array

Stores all active snowflake particles; updated and drawn each frame

let snowflakes = [];
windParticles array

Stores visual wind streak particles created by dragging; fades over time

let windParticles = [];
lightningBolts array

Stores active lightning bolts; limited to MAX_LIGHTNING (8) at a time

let lightningBolts = [];
clouds array

Stores all cloud objects; 20 clouds at different depth layers

let clouds = [];
MAX_RAIN number

Maximum number of rain drops allowed on screen (800)

const MAX_RAIN = 800;
MAX_SNOW number

Maximum number of snowflakes allowed on screen (800)

const MAX_SNOW = 800;
MAX_WIND_PARTICLES number

Maximum number of wind particles allowed on screen (900)

const MAX_WIND_PARTICLES = 900;
MAX_LIGHTNING number

Maximum number of lightning bolts on screen at once (8)

const MAX_LIGHTNING = 8;
NUM_CLOUDS number

Number of cloud objects to create (20)

const NUM_CLOUDS = 20;
GROUND_SEGMENTS number

Number of segments to divide the ground into for accumulation tracking (80)

const GROUND_SEGMENTS = 80;
waterHeights array

Tracks water accumulation height at each ground segment; decays and spreads over time

let waterHeights = [];
snowHeights array

Tracks snow accumulation height at each ground segment; decays and spreads over time

let snowHeights = [];
globalWind p5.Vector

Represents current wind direction and magnitude; affects all particles and clouds

let globalWind = createVector(0, 0);
mousePressStartMillis number

Timestamp when mouse was pressed; used to calculate press duration for lightning charging

let mousePressStartMillis = 0;
mousePressStartPos p5.Vector

Position where mouse was pressed; used to detect drag distance

let mousePressStartPos = null;
mouseHasDraggedFar boolean

Flag indicating if mouse has moved far enough to count as a drag gesture

let mouseHasDraggedFar = false;
DRAG_DISTANCE_THRESHOLD number

Minimum distance in pixels to count as a drag (20px)

const DRAG_DISTANCE_THRESHOLD = 20;
LIGHTNING_CHARGE_THRESHOLD number

Minimum hold time in milliseconds to trigger lightning (400ms)

const LIGHTNING_CHARGE_THRESHOLD = 400;
MAX_LIGHTNING_CHARGE number

Maximum charge time in milliseconds (2000ms); after this, charge ratio stays at 1.0

const MAX_LIGHTNING_CHARGE = 2000;
isNight boolean

Toggles between day and night mode; affects sky color, aurora visibility, and audio

let isNight = false;
rainIntensity number

Normalized value (0-1) representing current rain intensity based on drop count

let rainIntensity = 0;
snowIntensity number

Normalized value (0-1) representing current snow intensity based on snowflake count

let snowIntensity = 0;
windIntensity number

Normalized value (0-1) representing current wind intensity based on wind vector magnitude

let windIntensity = 0;
lightningIntensity number

Normalized value (0-1) representing current lightning activity based on active bolts

let lightningIntensity = 0;
stormFactor number

Combined weather intensity (0-1) affecting sky color, cloud behavior, and audio

let stormFactor = 0;
lightningFlash number

Current intensity of the white flash overlay; decays each frame

let lightningFlash = 0;
currentSuggestion string

Current AI suggestion text displayed at bottom of screen

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

Timestamp of last suggestion update; used to throttle suggestion changes

let lastSuggestionChange = 0;
SUGGESTION_INTERVAL number

Milliseconds between suggestion updates (7000ms)

const SUGGESTION_INTERVAL = 7000;
audioStarted boolean

Flag indicating if Tone.js audio has been initialized

let audioStarted = false;
windNoise Tone.Noise

Pink noise generator for wind sounds

let windNoise;
rainNoise Tone.Noise

White noise generator for rain sounds

let rainNoise;
thunderNoise Tone.Noise

Brown noise generator for thunder sounds

let thunderNoise;
windGain Tone.Gain

Volume control node for wind audio

let windGain;
rainGain Tone.Gain

Volume control node for rain audio

let rainGain;
thunderGain Tone.Gain

Volume control node for thunder audio

let thunderGain;
sunGain Tone.Gain

Volume control node for sunny day melody

let sunGain;
snowGain Tone.Gain

Volume control node for snowy chime melody

let snowGain;
sunSynth Tone.PolySynth

Synthesizer for sunny weather melody using sine waves

let sunSynth;
snowSynth Tone.PolySynth

Synthesizer for snowy weather melody using triangle waves

let snowSynth;
melodyLoop Tone.Loop

Generative loop that plays random notes from a major scale for sunny weather

let melodyLoop;
snowLoop Tone.Loop

Generative loop that plays random notes for snowy weather

let snowLoop;

๐Ÿงช Try This!

Experiment with the code by making these changes:

  1. Change MAX_RAIN from 800 to 300 and MAX_SNOW from 800 to 300. Click multiple times to spawn rain and notice how the sketch feels lighter with fewer particles on screen.
  2. Modify the DRAG_DISTANCE_THRESHOLD from 20 to 5 pixels. Now even tiny mouse movements will create wind currents instead of requiring larger drags.
  3. In the drawBackgroundGradient() function, change the clearDayTop color from color(120, 190, 255) to color(255, 100, 100) to create a reddish sky during the day.
  4. In the PrecipParticle class, change the rain gravity from 0.25 to 0.1 to make rain fall more slowly and float more like snow.
  5. Modify the Cloud class update() method: change this.speedBase from lerp(0.15, 0.5, this.layer) to lerp(0.5, 1.5, this.layer) to make clouds move much faster across the sky.
  6. In updateWeatherState(), change the sunLevel calculation from 'isNight ? clearAmount * 0.15 : clearAmount * 0.5' to 'isNight ? clearAmount * 0.5 : clearAmount * 0.8' to make the sunny melody play louder.
  7. In the LightningBolt class, change this.life from 40 to 100 to make lightning bolts stay visible much longer on screen.
  8. Modify the shouldDrawAurora() function to return 'isNight && stormFactor < 0.5' instead of 0.35, allowing aurora to appear even during slightly stormy nights.
  9. In spawnPrecipitationBurst(), change the rain count from 80 to 200 and snow count from 45 to 120 to create much more dramatic bursts.
  10. In the Cloud draw() method, change the number of segments from 6 to 12 to create more complex, detailed cloud shapes with more lobes.
Open in Editor & Experiment โ†’

๐Ÿ”ง Potential Improvements

Here are some ways this code could be enhanced:

PERFORMANCE drawBackgroundGradient()

Drawing a horizontal line for every pixel row (height iterations) is inefficient. For a 1080px tall canvas, this means 1080 line draws per frame.

๐Ÿ’ก Use p5's built-in gradient or createGraphics() to draw the gradient once and cache it, or use a shader. Alternatively, draw fewer lines and let them be thicker to cover the space.

BUG PrecipParticle.update()

The collision detection uses 'this.pos.y + this.size * 0.5 >= groundY' but particles can tunnel through the ground if velocity is very high, especially in the snow's sine drift calculation.

๐Ÿ’ก Add a check to clamp the particle to the ground surface: 'if (this.pos.y > groundY) { this.pos.y = groundY; this.landAtGround(); }' to prevent tunneling.

FEATURE Audio system

The sketch doesn't provide visual feedback about whether audio is playing or if Tone.js failed to load. Users might not realize audio is disabled.

๐Ÿ’ก Add a small indicator in drawUI() that shows audio status, or log a visible message to the console when audio fails to initialize.

STYLE Global variables

Many global variables could be organized into objects to reduce namespace pollution and improve code organization.

๐Ÿ’ก Create objects like 'let weather = { rainIntensity: 0, snowIntensity: 0, windIntensity: 0, ... }' and 'let particles = { rain: [], snow: [], wind: [], lightning: [] }' to group related variables.

BUG mouseDragged() and mouseReleased()

If the user presses the mouse, doesn't move it far, then releases quickly, the code correctly creates rain. However, if they press, move slightly (but not past threshold), then move far, mouseHasDraggedFar gets set to true but the wind particles are already being created. This works but is confusing logic.

๐Ÿ’ก Clarify the logic by checking mouseHasDraggedFar at the start of mouseDragged() and only creating wind particles if it's true, rather than setting it mid-function.

PERFORMANCE smoothArray()

The function creates a copy of the entire array each frame for every array being smoothed (waterHeights and snowHeights). With 80 segments, this is minor, but it's unnecessary work.

๐Ÿ’ก Instead of arr.slice(), use a single temporary array that's reused, or implement smoothing in-place with careful index management to avoid reading modified values.

FEATURE UI and controls

There's no way to clear the accumulated water and snow without reloading the page, and no way to reset the sketch.

๐Ÿ’ก Add a 'C' key handler to clear waterHeights and snowHeights arrays, or add a 'Reset' button in the UI that reinitializes the sketch state.

BUG triggerThunder()

The function calls 'thunderNoise.start(t)' and 'thunderNoise.stop(t + duration)' multiple times per frame when multiple lightning bolts are created. Tone.js noise generators can only be started once, so subsequent calls will fail silently.

๐Ÿ’ก Create a new Tone.Noise instance for each thunder sound, or use a pool of noise generators that can be reused. Alternatively, use a single noise generator and control it entirely through gain automation.

STYLE PrecipParticle, WindParticle, LightningBolt, Cloud classes

The classes don't have consistent method ordering or documentation. Some have update() before isDead(), others have draw() last.

๐Ÿ’ก Standardize the method order across all classes: constructor, update, isDead, draw. Add JSDoc comments to each class explaining its purpose.

PERFORMANCE draw() loop

The function calls updateWeatherState() every frame, which loops through all lightning bolts to calculate energy. If there are many bolts, this adds up.

๐Ÿ’ก Cache the lightning energy calculation and only update it when a bolt is added or removed, rather than recalculating every frame.

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 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, precipparticle, windparticle, lightningbolt, cloud
Code Flow Diagram