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 explanation (6 lines)
const cnv = createCanvas(windowWidth, windowHeight);
- Creates a canvas that fills the entire browser window and stores a reference to it in cnv.
cnv.mousePressed(spawnFountainAtMouse);
- Attaches a click handler directly to the canvas element so clicking anywhere on it calls spawnFountainAtMouse(), without accidentally triggering when you click the UI sliders.
colorMode(HSB, 360, 100, 100, 100);
- Switches p5's color system to Hue-Saturation-Brightness with ranges 0-360 for hue and 0-100 for the rest, which makes it much easier to shift particle color based on speed.
groundY = height * 0.9;
- Places an invisible 'ground line' 90% of the way down the canvas, used later for collision detection.
setupUI();
- Calls a helper function that builds the gravity and speed sliders as DOM elements overlaid on the canvas.
fountains.push(new Fountain(width / 2, groundY));
- Creates the very first Fountain object centered horizontally at the ground line, so the sketch has something happening immediately on load.
🔬 This makes splash particles bounce softly off the ground. What happens if you change -0.3 to -0.8 (bouncier) or to 0 (particles just stop dead on impact)?
if (s.pos.y >= groundY && s.vel.y > 0) {
s.pos.y = groundY;
s.vel.y *= -0.3; // small bounce
}
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);
}
Line-by-line explanation (9 lines)
🔧 Subcomponents:
for-loop
Fountain Update Loop
for (let i = fountains.length - 1; i >= 0; i--) {
Calls emit() on every active fountain and removes fountains whose lifeFrames have run out.
for-loop
Main Particle Loop
for (let i = particles.length - 1; i >= 0; i--) {
Applies gravity, updates position, checks for ground collisions, draws, and removes dead particles.
conditional
Ground Collision Check
if (p.pos.y >= groundY && p.vel.y > 0) {
Detects when a rising-then-falling particle reaches the ground line and converts it into a splash burst instead of drawing it.
for-loop
Splash Particle Loop
for (let i = splashes.length - 1; i >= 0; i--) {
Updates and draws secondary splash particles and gives them a small bounce off the ground.
background(0, 0, 0, 20);
- Draws a nearly-transparent black rectangle over the whole canvas instead of fully clearing it, which leaves faint trails behind moving particles.
gravity = gravitySlider.value();
- Reads the current position of the gravity slider each frame so dragging it live-updates the physics.
const gravityForce = createVector(0, gravity);
- Builds a downward-pointing vector representing gravity, ready to be applied to every particle this frame.
for (let i = fountains.length - 1; i >= 0; i--) {
- Loops backward through the fountains array so that removing an item with splice() doesn't skip the next one.
blendMode(ADD);
- Switches to additive color blending so overlapping particle glows brighten each other, creating a neon look.
if (p.pos.y >= groundY && p.vel.y > 0) {
- Checks whether the particle has reached the ground AND is moving downward (not still rising), preventing a false trigger while it launches from ground level.
createSplash(p);
- Turns the impact into a burst of smaller splash particles at the point of contact.
s.vel.y *= -0.3; // small bounce
- Reverses the splash particle's vertical velocity and shrinks it to 30%, simulating a soft, energy-losing bounce.
blendMode(BLEND);
- Restores normal (non-additive) blending at the end of the frame so it doesn't affect anything drawn outside this loop.
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');
}
Line-by-line explanation (5 lines)
const ui = createDiv();
- Creates a new HTML <div> element that will hold all the slider controls, using p5's DOM API.
ui.style('background', 'rgba(0, 0, 0, 0.45)');
- Gives the UI panel a semi-transparent dark background so it's readable over the animated canvas.
gravitySlider = createSlider(0, 0.6, gravity, 0.01);
- Creates an HTML range slider for gravity with min 0, max 0.6, starting value equal to the current gravity, and a step of 0.01.
gravityValueSpan = createSpan(gravity.toFixed(2));
- Creates a small text label next to the slider showing the current numeric gravity value, formatted to 2 decimal places.
speedSlider = createSlider(2, 20, baseInitialSpeed, 0.1);
- Creates the second slider controlling how fast particles launch, ranging from 2 to 20.
🔬 The angle is randomized across the full upward hemisphere (-PI to 0). What happens if you narrow it to random(-PI * 0.6, -PI * 0.4) so the fountain shoots almost straight up?
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);
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;
}
}
Line-by-line explanation (8 lines)
🔧 Subcomponents:
conditional
Lifetime Guard
if (this.lifeFrames <= 0) return;
Stops the fountain from emitting any more particles once its lifespan has expired.
for-loop
Particle Spawn Loop
for (let i = 0; i < this.emissionRate; i++) {
Spawns emissionRate new Particle objects each frame with randomized upward angle and speed.
this.origin = createVector(x, y);
- Stores the fountain's fixed spawn point as a p5.Vector so every particle it emits starts from the same location.
this.emissionRate = 6; // particles per frame
- Sets how many particles are created every single frame while the fountain is alive.
this.lifeFrames = 60 * 4; // fountain emits for ~4 seconds
- Gives the fountain a countdown timer (in frames) of roughly 4 seconds at 60fps before it stops emitting and is removed.
const angle = random(-PI, 0);
- Picks a random angle between straight left (-PI radians) and straight right (0), which in screen coordinates covers the whole upward hemisphere.
const speed = baseInitialSpeed * random(0.6, 1.2);
- Randomizes each particle's launch speed around the slider-controlled base speed, so particles don't all move identically.
const vx = cos(angle) * speed * 0.5;
- Converts the angle into a horizontal velocity component, shrinking it by half to keep the fountain narrow rather than wide.
particles.push(new Particle(this.origin.x, this.origin.y, vx, vy, false));
- Creates a brand-new fountain particle (isSplash = false) and adds it to the global particles array so draw() will update and display it.
this.lifeFrames--;
- Counts down the fountain's remaining lifetime by one frame each time emit() runs.
🔬 This loop draws 3 glow layers. What happens visually if you start the loop at i = 6 instead of i = 3, drawing six layers of glow?
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);
}
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);
}
}
}
Line-by-line explanation (10 lines)
🔧 Subcomponents:
for-loop
Layered Glow Circles
for (let i = 3; i >= 1; i--) {
Draws three overlapping circles of decreasing opacity and increasing radius to fake a soft glow halo around each particle.
this.pos = createVector(x, y);
- Stores the particle's current position as a 2D vector.
this.vel = createVector(vx, vy);
- Stores the particle's velocity (speed and direction) as a vector, set once at creation from the launch angle and speed.
this.acc = createVector(0, 0);
- Stores acceleration, starting at zero; forces like gravity get added to this each frame.
this.size = isSplash ? random(2, 5) : random(4, 8);
- Gives splash particles a smaller random size than main fountain particles, using a ternary to pick the range.
this.vel.add(this.acc);
- Applies the accumulated acceleration (like gravity) to velocity - this is the core of the physics simulation, following v = v + a.
this.pos.add(this.vel);
- Moves the particle by its current velocity, updating its on-screen position.
this.acc.mult(0);
- Resets acceleration to zero after it's been used, so forces don't accidentally stack up across frames.
this.lifespan -= this.isSplash ? 5 : 3;
- Shrinks the particle's remaining life each frame; splash particles fade faster (5) than main particles (3).
const hue = map(speed, 0, maxSpeed, 180, 330, true);
- Converts the particle's current speed into a hue value between 180 (cyan/blue) and 330 (pink), so fast particles look hot and slow ones look cool.
for (let i = 3; i >= 1; i--) {
- Loops 3 times to draw three stacked circles of shrinking size and fading opacity, creating a soft glow effect instead of a hard-edged dot.
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);
}
Line-by-line explanation (3 lines)
fill(210, 20, 15, 40);
- Sets a dim bluish, low-brightness, semi-transparent fill (in HSB) for the ground area rectangle.
rect(0, groundY, width, height - groundY);
- Draws a rectangle spanning the full width of the canvas from the ground line down to the bottom edge, suggesting a floor.
line(0, groundY, width, groundY);
- Draws a horizontal line across the canvas at groundY to visually mark where particles will splash.
🔬 What happens if you increase the upper bound of random(0.2, 0.7) to random(0.2, 1.5), letting some splash particles fly faster than the original impact?
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);
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
)
);
}
}
Line-by-line explanation (4 lines)
🔧 Subcomponents:
for-loop
Splash Burst Loop
for (let i = 0; i < count; i++) {
Creates a random number of small splash Particle objects at the impact point, each with velocity derived from the original particle's impact speed.
const count = floor(random(8, 16));
- Randomly picks a whole number between 8 and 15 splash particles to create for this impact, so every splash looks slightly different.
const impactSpeed = particle.vel.mag();
- Measures how fast the original particle was moving when it hit the ground, using .mag() to get the vector's length - faster impacts make bigger splashes.
const speed = impactSpeed * random(0.2, 0.7);
- Gives each splash particle a fraction of the original impact speed, so splash particles are gentler than the particle that caused them.
groundY - 1, // slightly above ground
- Spawns splash particles just 1 pixel above the ground line so they don't immediately re-trigger a ground collision.