function setup() {
const cnv = createCanvas(windowWidth, windowHeight);
cnv.id('p5canvas');
// Only spawn fountains when clicking the canvas, not UI
cnv.mousePressed(spawnFountainAtMouse);
// Use HSB for easier color control
// https://p5js.org/reference/#/p5/colorMode
colorMode(HSB, 360, 100, 100, 100);
groundY = height * 0.9;
setupUI();
// Start with one fountain near the "ground"
fountains.push(new Fountain(width / 2, groundY));
}
Line by Line:
const cnv = createCanvas(windowWidth, windowHeight);
- Creates a canvas that fills the entire window, storing the canvas object in cnv so we can attach event listeners to it
cnv.id('p5canvas');
- Assigns an HTML id to the canvas element for CSS styling if needed
cnv.mousePressed(spawnFountainAtMouse);
- Attaches a click event listener to the canvas that calls spawnFountainAtMouse when clicked
colorMode(HSB, 360, 100, 100, 100);
- Switches color mode from RGB to HSB (Hue, Saturation, Brightness) with ranges 0-360, 0-100, 0-100, 0-100 for easier color control based on particle speed
groundY = height * 0.9;
- Sets the ground level at 90% down the canvas height where particles will collide and create splashes
setupUI();
- Calls the setupUI function to create the gravity and speed control sliders
fountains.push(new Fountain(width / 2, groundY));
- Creates the first fountain at the center of the canvas and adds it to the fountains array
function draw() {
// Motion-blur / trail effect: semi-transparent background
// https://p5js.org/reference/#/p5/background
background(0, 0, 0, 20);
// Update parameters from sliders
gravity = gravitySlider.value();
baseInitialSpeed = speedSlider.value();
gravityValueSpan.html(gravity.toFixed(2));
speedValueSpan.html(baseInitialSpeed.toFixed(1));
const gravityForce = createVector(0, gravity);
drawGround();
// Emit particles from all active fountains
for (let i = fountains.length - 1; i >= 0; i--) {
const f = fountains[i];
f.emit();
if (f.isDead()) {
fountains.splice(i, 1);
}
}
// Additive blending for glowing particles
// https://p5js.org/reference/#/p5/blendMode
blendMode(ADD);
// Main fountain particles
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.applyForce(gravityForce);
p.update();
// Collision with ground -> create splash particles
if (p.pos.y >= groundY && p.vel.y > 0) {
createSplash(p);
particles.splice(i, 1);
continue;
}
p.display();
if (p.isDead()) {
particles.splice(i, 1);
}
}
// Splash (secondary) particles
for (let i = splashes.length - 1; i >= 0; i--) {
const s = splashes[i];
s.applyForce(gravityForce);
s.update();
s.display();
// Let splash particles bounce very slightly off the ground
if (s.pos.y >= groundY && s.vel.y > 0) {
s.pos.y = groundY;
s.vel.y *= -0.3; // small bounce
}
if (s.isDead()) {
splashes.splice(i, 1);
}
}
// Return to normal blending (for anything else that might be drawn)
blendMode(BLEND);
}
๐ง Subcomponents:
calculation
Update Physics Parameters from Sliders
gravity = gravitySlider.value();
baseInitialSpeed = speedSlider.value();
Reads the current slider values and updates the global physics parameters in real-time
for-loop
Fountain Emission Loop
for (let i = fountains.length - 1; i >= 0; i--) {
const f = fountains[i];
f.emit();
if (f.isDead()) {
fountains.splice(i, 1);
}
}
Iterates through all fountains backwards, emits particles from each, and removes dead fountains
for-loop
Main Particle Physics Loop
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.applyForce(gravityForce);
p.update();
if (p.pos.y >= groundY && p.vel.y > 0) {
createSplash(p);
particles.splice(i, 1);
continue;
}
p.display();
if (p.isDead()) {
particles.splice(i, 1);
}
}
Updates all particles with gravity, checks for ground collision to create splashes, displays particles, and removes dead ones
for-loop
Splash Particle Physics Loop
for (let i = splashes.length - 1; i >= 0; i--) {
const s = splashes[i];
s.applyForce(gravityForce);
s.update();
s.display();
if (s.pos.y >= groundY && s.vel.y > 0) {
s.pos.y = groundY;
s.vel.y *= -0.3;
}
if (s.isDead()) {
splashes.splice(i, 1);
}
}
Updates splash particles, allows them to bounce slightly off the ground, and removes dead ones
Line by Line:
background(0, 0, 0, 20);
- Fills the canvas with a semi-transparent black (alpha=20) instead of fully opaque, creating a motion blur/trail effect as particles fade over time
gravity = gravitySlider.value();
- Reads the current value from the gravity slider and updates the gravity variable
baseInitialSpeed = speedSlider.value();
- Reads the current value from the speed slider and updates the baseInitialSpeed variable
gravityValueSpan.html(gravity.toFixed(2));
- Updates the HTML display of the gravity value on the UI to show 2 decimal places
speedValueSpan.html(baseInitialSpeed.toFixed(1));
- Updates the HTML display of the speed value on the UI to show 1 decimal place
const gravityForce = createVector(0, gravity);
- Creates a vector pointing straight down with magnitude equal to the gravity value, used to apply gravity to all particles
drawGround();
- Calls the drawGround function to render the ground rectangle and line
for (let i = fountains.length - 1; i >= 0; i--)
- Loops backwards through the fountains array (backwards is important when removing items during iteration)
f.emit();
- Calls the emit method on the fountain to spawn new particles from it
if (f.isDead()) { fountains.splice(i, 1); }
- Checks if the fountain has finished emitting and removes it from the array if it's dead
blendMode(ADD);
- Switches to additive blending mode, which makes overlapping particles brighten each other instead of covering each other, creating a glowing effect
p.applyForce(gravityForce);
- Adds the gravity force to the particle's acceleration
p.update();
- Updates the particle's velocity and position based on accumulated forces
if (p.pos.y >= groundY && p.vel.y > 0) { createSplash(p); particles.splice(i, 1); continue; }
- Detects when a particle hits the ground (y position at or below groundY and moving downward), creates splash particles, removes the main particle, and skips to the next iteration
p.display();
- Draws the particle on the canvas with its glow effect
if (p.isDead()) { particles.splice(i, 1); }
- Checks if the particle's lifespan has expired and removes it from the array
s.pos.y = groundY;
- Snaps the splash particle's position to the ground level to prevent it from sinking below
s.vel.y *= -0.3;
- Reverses and reduces the downward velocity to create a small bounce effect (30% of previous speed)
blendMode(BLEND);
- Returns to normal blending mode for any subsequent drawing
function setupUI() {
// Using p5 DOM functions (bundled in core)
// https://p5js.org/reference/#/p5/createDiv
const ui = createDiv();
ui.id('ui');
ui.style('position', 'fixed');
ui.style('top', '16px');
ui.style('left', '16px');
ui.style('padding', '10px 12px');
ui.style('border-radius', '8px');
ui.style('background', 'rgba(0, 0, 0, 0.45)');
ui.style('color', '#fff');
ui.style('font-family', 'system-ui, sans-serif');
ui.style('font-size', '13px');
ui.style('backdrop-filter', 'blur(8px)');
ui.style('-webkit-backdrop-filter', 'blur(8px)');
ui.style('z-index', '10');
ui.style('user-select', 'none');
// Gravity row
const gRow = createDiv();
gRow.parent(ui);
gRow.style('margin-bottom', '6px');
const gLabel = createSpan('Gravity:');
gLabel.parent(gRow);
gLabel.style('margin-right', '6px');
// https://p5js.org/reference/#/p5/createSlider
gravitySlider = createSlider(0, 0.6, gravity, 0.01);
gravitySlider.parent(gRow);
gravitySlider.style('width', '120px');
gravitySlider.style('vertical-align', 'middle');
gravityValueSpan = createSpan(gravity.toFixed(2));
gravityValueSpan.parent(gRow);
gravityValueSpan.style('margin-left', '6px');
gravityValueSpan.style('opacity', '0.8');
// Speed / initial velocity row
const sRow = createDiv();
sRow.parent(ui);
const sLabel = createSpan('Initial speed:');
sLabel.parent(sRow);
sLabel.style('margin-right', '6px');
speedSlider = createSlider(2, 20, baseInitialSpeed, 0.1);
speedSlider.parent(sRow);
speedSlider.style('width', '120px');
speedSlider.style('vertical-align', 'middle');
speedValueSpan = createSpan(baseInitialSpeed.toFixed(1));
speedValueSpan.parent(sRow);
speedValueSpan.style('margin-left', '6px');
speedValueSpan.style('opacity', '0.8');
}
๐ง Subcomponents:
calculation
Main UI Container Setup
const ui = createDiv();
ui.id('ui');
ui.style('position', 'fixed');
...
Creates the main UI container div with fixed positioning and styling including background blur effect
calculation
Gravity Slider Controls
const gRow = createDiv();
gravitySlider = createSlider(0, 0.6, gravity, 0.01);
gravityValueSpan = createSpan(gravity.toFixed(2));
Creates the gravity control row with slider (0-0.6 range) and value display
calculation
Speed Slider Controls
const sRow = createDiv();
speedSlider = createSlider(2, 20, baseInitialSpeed, 0.1);
speedValueSpan = createSpan(baseInitialSpeed.toFixed(1));
Creates the speed control row with slider (2-20 range) and value display
Line by Line:
const ui = createDiv();
- Creates a new HTML div element to serve as the container for all UI controls
ui.id('ui');
- Assigns the id 'ui' to the div so it can be styled by CSS
ui.style('position', 'fixed');
- Makes the UI stay in a fixed position on screen, not scrolling with content
ui.style('top', '16px'); ui.style('left', '16px');
- Positions the UI 16 pixels from the top-left corner of the window
ui.style('backdrop-filter', 'blur(8px)');
- Applies a blur effect to the background behind the UI panel for a frosted glass appearance
const gRow = createDiv(); gRow.parent(ui);
- Creates a new div for the gravity controls and makes it a child of the main ui div
gravitySlider = createSlider(0, 0.6, gravity, 0.01);
- Creates a slider with minimum 0, maximum 0.6, starting value of gravity variable, and 0.01 step size
gravityValueSpan = createSpan(gravity.toFixed(2));
- Creates a text span displaying the gravity value rounded to 2 decimal places
speedSlider = createSlider(2, 20, baseInitialSpeed, 0.1);
- Creates a slider with minimum 2, maximum 20, starting value of baseInitialSpeed variable, and 0.1 step size
speedValueSpan = createSpan(baseInitialSpeed.toFixed(1));
- Creates a text span displaying the speed value rounded to 1 decimal place
function drawGround() {
// Soft ground area
noStroke();
fill(210, 20, 15, 40);
rect(0, groundY, width, height - groundY);
// Ground line
stroke(210, 10, 60, 70);
strokeWeight(2);
line(0, groundY, width, groundY);
}
๐ง Subcomponents:
calculation
Ground Rectangle
noStroke();
fill(210, 20, 15, 40);
rect(0, groundY, width, height - groundY);
Draws a semi-transparent reddish rectangle from the ground level to the bottom of the canvas
calculation
Ground Line
stroke(210, 10, 60, 70);
strokeWeight(2);
line(0, groundY, width, groundY);
Draws a horizontal line at the ground level with a reddish color
Line by Line:
noStroke();
- Disables stroke (outline) for the rectangle that follows
fill(210, 20, 15, 40);
- Sets fill color to HSB hue 210 (red), saturation 20, brightness 15, with alpha 40 (semi-transparent)
rect(0, groundY, width, height - groundY);
- Draws a rectangle from the left edge at ground level to the bottom-right of the canvas
stroke(210, 10, 60, 70);
- Sets stroke color to HSB hue 210 (red), saturation 10, brightness 60, with alpha 70
strokeWeight(2);
- Sets the line thickness to 2 pixels
line(0, groundY, width, groundY);
- Draws a horizontal line across the full width at the ground level
function createSplash(particle) {
const count = floor(random(8, 16));
const impactSpeed = particle.vel.mag();
for (let i = 0; i < count; i++) {
// Splash angles mostly upwards & sideways
const angle = random(-PI, 0);
const speed = impactSpeed * random(0.2, 0.7);
const vx = cos(angle) * speed;
const vy = sin(angle) * speed * 0.7;
splashes.push(
new Particle(
particle.pos.x,
groundY - 1, // slightly above ground
vx,
vy,
true
)
);
}
}
๐ง Subcomponents:
calculation
Random Splash Count
const count = floor(random(8, 16));
Determines how many splash particles to create (randomly between 8 and 16)
calculation
Impact Speed Calculation
const impactSpeed = particle.vel.mag();
Calculates the magnitude (speed) of the incoming particle's velocity
for-loop
Splash Particle Creation Loop
for (let i = 0; i < count; i++) {
const angle = random(-PI, 0);
const speed = impactSpeed * random(0.2, 0.7);
const vx = cos(angle) * speed;
const vy = sin(angle) * speed * 0.7;
splashes.push(new Particle(...));
Creates multiple splash particles with varied angles and speeds based on impact velocity
Line by Line:
const count = floor(random(8, 16));
- Generates a random integer between 8 and 16 to determine how many splash particles to create
const impactSpeed = particle.vel.mag();
- Calculates the speed (magnitude) of the incoming particle's velocity vector
const angle = random(-PI, 0);
- Generates a random angle between -ฯ and 0 (180ยฐ to 0ยฐ), covering the upper hemisphere for upward splash direction
const speed = impactSpeed * random(0.2, 0.7);
- Calculates splash particle speed as a fraction (20-70%) of the impact speed, making faster impacts create faster splashes
const vx = cos(angle) * speed;
- Converts the angle and speed into a horizontal velocity component using cosine
const vy = sin(angle) * speed * 0.7;
- Converts the angle and speed into a vertical velocity component using sine, reduced by 0.7 to make splashes less vertical
splashes.push(new Particle(..., true));
- Creates a new Particle object with isSplash=true and adds it to the splashes array
class Fountain {
constructor(x, y) {
this.origin = createVector(x, y);
this.emissionRate = 6; // particles per frame
this.lifeFrames = 60 * 4; // fountain emits for ~4 seconds
}
emit() {
if (this.lifeFrames <= 0) return;
for (let i = 0; i < this.emissionRate; i++) {
// Random angle in upward hemisphere
const angle = random(-PI, 0);
const speed = baseInitialSpeed * random(0.6, 1.2);
// Narrow cone: reduce horizontal component slightly
const vx = cos(angle) * speed * 0.5;
const vy = sin(angle) * speed;
particles.push(new Particle(this.origin.x, this.origin.y, vx, vy, false));
}
this.lifeFrames--;
}
isDead() {
return this.lifeFrames <= 0;
}
}
๐ง Subcomponents:
calculation
Fountain Constructor
constructor(x, y) {
this.origin = createVector(x, y);
this.emissionRate = 6;
this.lifeFrames = 60 * 4;
}
Initializes a fountain at position (x,y) with emission rate of 6 particles per frame and lifespan of 240 frames (~4 seconds)
for-loop
Particle Emission Loop
for (let i = 0; i < this.emissionRate; i++) {
const angle = random(-PI, 0);
const speed = baseInitialSpeed * random(0.6, 1.2);
const vx = cos(angle) * speed * 0.5;
const vy = sin(angle) * speed;
particles.push(new Particle(...));
Creates 6 particles per frame with varied angles and speeds, adding them to the global particles array
Line by Line:
this.origin = createVector(x, y);
- Stores the fountain's position as a vector
this.emissionRate = 6;
- Sets the fountain to emit 6 particles per frame
this.lifeFrames = 60 * 4;
- Sets the fountain's lifespan to 240 frames (60 fps ร 4 seconds), after which it stops emitting
if (this.lifeFrames <= 0) return;
- Stops emitting particles if the fountain's lifespan has expired
const angle = random(-PI, 0);
- Generates a random angle between -ฯ and 0 radians (upper hemisphere) for upward particle direction
const speed = baseInitialSpeed * random(0.6, 1.2);
- Calculates particle speed as 60-120% of the base speed, creating variation
const vx = cos(angle) * speed * 0.5;
- Converts angle to horizontal velocity, multiplied by 0.5 to narrow the cone and keep particles more vertical
const vy = sin(angle) * speed;
- Converts angle to vertical velocity at full speed for upward motion
particles.push(new Particle(this.origin.x, this.origin.y, vx, vy, false));
- Creates a new Particle at the fountain's position with calculated velocity and isSplash=false
this.lifeFrames--;
- Decrements the fountain's lifespan counter each frame
return this.lifeFrames <= 0;
- Returns true if the fountain has finished emitting, false otherwise
class Particle {
constructor(x, y, vx, vy, isSplash) {
// https://p5js.org/reference/#/p5.Vector
this.pos = createVector(x, y);
this.vel = createVector(vx, vy);
this.acc = createVector(0, 0);
this.isSplash = isSplash;
this.size = isSplash ? random(2, 5) : random(4, 8);
this.lifespan = isSplash ? 180 : 255; // frames
}
applyForce(force) {
this.acc.add(force);
}
update() {
this.vel.add(this.acc);
this.pos.add(this.vel);
this.acc.mult(0);
this.lifespan -= this.isSplash ? 5 : 3;
}
isDead() {
return this.lifespan <= 0 ||
this.pos.y < -50 ||
this.pos.x < -50 ||
this.pos.x > width + 50;
}
display() {
const speed = this.vel.mag();
const maxSpeed = 18;
// Map speed to color (slow = blue, fast = pink/white)
const hue = map(speed, 0, maxSpeed, 180, 330, true);
const brightness = map(speed, 0, maxSpeed, 60, 100, true);
const baseAlpha = map(this.lifespan, 0, 255, 0, 100, true);
noStroke();
// Draw multiple circles for a soft glow
// https://p5js.org/reference/#/p5/circle
for (let i = 3; i >= 1; i--) {
const r = this.size * i;
const a = baseAlpha / (i * i); // fade outer halos
fill(hue, 80, brightness, a);
circle(this.pos.x, this.pos.y, r);
}
}
}
๐ง Subcomponents:
calculation
Particle Constructor
constructor(x, y, vx, vy, isSplash) {
this.pos = createVector(x, y);
this.vel = createVector(vx, vy);
this.acc = createVector(0, 0);
this.isSplash = isSplash;
this.size = isSplash ? random(2, 5) : random(4, 8);
this.lifespan = isSplash ? 180 : 255;
Initializes a particle with position, velocity, acceleration vectors, and properties that differ for splash vs fountain particles
for-loop
Multi-Circle Glow Effect
for (let i = 3; i >= 1; i--) {
const r = this.size * i;
const a = baseAlpha / (i * i);
fill(hue, 80, brightness, a);
circle(this.pos.x, this.pos.y, r);
Draws 3 concentric circles with decreasing opacity to create a soft glowing halo effect
Line by Line:
this.pos = createVector(x, y);
- Stores the particle's position as a vector
this.vel = createVector(vx, vy);
- Stores the particle's velocity as a vector
this.acc = createVector(0, 0);
- Initializes acceleration as zero, will be updated by applyForce()
this.isSplash = isSplash;
- Stores whether this is a splash particle (true) or fountain particle (false)
this.size = isSplash ? random(2, 5) : random(4, 8);
- Sets particle size: splash particles are smaller (2-5) than fountain particles (4-8)
this.lifespan = isSplash ? 180 : 255;
- Sets particle lifespan: splash particles fade faster (180 frames) than fountain particles (255 frames)
this.acc.add(force);
- Adds a force vector to the particle's acceleration (used for gravity)
this.vel.add(this.acc);
- Updates velocity by adding acceleration (Newton's second law: v = v + a)
this.pos.add(this.vel);
- Updates position by adding velocity (p = p + v)
this.acc.mult(0);
- Resets acceleration to zero each frame so forces don't accumulate
this.lifespan -= this.isSplash ? 5 : 3;
- Decreases lifespan: splash particles fade faster (5 per frame) than fountain particles (3 per frame)
return this.lifespan <= 0 || this.pos.y < -50 || this.pos.x < -50 || this.pos.x > width + 50;
- Returns true if particle is dead: lifespan expired OR moved far off-screen (with 50px buffer)
const speed = this.vel.mag();
- Calculates the particle's current speed as the magnitude of its velocity vector
const hue = map(speed, 0, maxSpeed, 180, 330, true);
- Maps particle speed (0-18) to hue color (180-330): slow particles are blue, fast particles are pink
const brightness = map(speed, 0, maxSpeed, 60, 100, true);
- Maps particle speed to brightness: slow particles are dimmer (60), fast particles are brighter (100)
const baseAlpha = map(this.lifespan, 0, 255, 0, 100, true);
- Maps remaining lifespan to alpha: new particles are opaque (100), dying particles are transparent (0)
for (let i = 3; i >= 1; i--)
- Loops 3 times (i=3, 2, 1) to draw 3 concentric circles of increasing size
const r = this.size * i;
- Calculates radius for each circle: 3x, 2x, and 1x the particle's base size
const a = baseAlpha / (i * i);
- Reduces alpha for outer circles: divided by 9, 4, and 1, creating a fade effect
fill(hue, 80, brightness, a);
- Sets fill color with calculated hue, fixed saturation (80), brightness, and alpha
circle(this.pos.x, this.pos.y, r);
- Draws a circle at the particle's position with the calculated radius