AI Bubble Pop - Satisfying Click Game Pop colorful bubbles as they float up! Click bubbles to burst

This sketch creates a satisfying bubble-popping game where colorful bubbles float up from the bottom of the screen. Click any bubble to burst it, trigger a percussion sound that varies with bubble size, and release a particle explosion—all while tracking your score.

🧪 Try This!

Experiment with the code by making these changes:

  1. Make bubbles move faster — Increase the speed range so bubbles float upward quicker, making the game more chaotic and challenging.
  2. Make bubbles warmer colors — Change the hue range from cool blues/purples to warm reds/oranges for a completely different visual feel.
  3. Reverse the pitch mapping — Make small bubbles produce low pitched pops and large bubbles produce high pitched pops—it feels counterintuitive but is fun to try.
  4. Create bigger, fewer bubbles — Reduce the bubble count but increase their maximum size to make the game feel less crowded but easier to pop.
  5. More intense particle bursts — Increase particle count per pop to create bigger, more satisfying explosions.
Prefer the full editor? Open it there →

📖 About This Sketch

This sketch builds an interactive bubble-popping game that demonstrates several intermediate p5.js techniques working together. Colorful bubbles continuously float upward with gentle horizontal drift, and clicking any bubble triggers a satisfying chain reaction: a pitched pop sound that changes based on bubble size, an explosion of particles that scatter and fade, and a score increment. The visual polish—soft bubble glows, particle physics with gravity and friction, and smooth animations—makes this feel like a real game despite being relatively compact code.

The code is organized around three main classes (Bubble, Particle, and sound/UI functions) plus a game loop that updates and draws everything every frame. By studying it, you will learn how to build playable interactions in p5.js using collision detection, how to use p5.sound's oscillators and envelopes to create dynamic audio feedback, how to manage multiple animated objects with arrays, and how particle systems create convincing explosion effects. Each piece teaches a technique that applies far beyond games.

⚙️ How It Works

  1. When the sketch loads, setup() initializes the canvas to fill the window, sets up a reusable sound synthesizer (oscillator + envelope), and creates 18 Bubble objects with random sizes, positions near the bottom, and cool-colored hues.
  2. Every frame, draw() updates and displays all bubbles (which float upward with drift) and all particles (which spray outward then fade and fall under gravity).
  3. When you click the canvas, mousePressed() or touchStarted() calls handlePop() to check if your cursor is inside any bubble, working backward through the array to pop the topmost one first.
  4. When a bubble pops, its pop() method plays a pitch-shifted sound (smaller bubbles = higher pitch) using the shared oscillator and envelope, creates 12-22 particles that inherit the bubble's color, increments the score, and respawns the bubble at the bottom.
  5. Particles update each frame with velocity, gravity, and friction applied via p5.Vector math, then fade out and are removed from the array when their life reaches zero.
  6. The score displays in the top-left corner, updated every time a bubble is popped.

🎓 Concepts You'll Learn

Class-based object designCollision detection (point-in-circle)Particle systems with physicsp5.sound synthesis and envelopesHSB color mode and color manipulationFrequency mapping and dynamic audioArray management (push/splice)Vector math for motion

📝 Code Breakdown

setup()

setup() runs once when the sketch starts. It is the place to initialize your canvas, settings, and objects. Notice how the sound synthesizer is created here once and reused for all pops—this is more efficient than creating new oscillators on demand.

function setup() {
  createCanvas(windowWidth, windowHeight); // 2D canvas

  // Use HSB for nice rainbow colors
  colorMode(HSB, 360, 100, 100, 255);
  noStroke();
  textFont('system-ui');
  textSize(24);

  // Setup pop sound: one oscillator + envelope reused for all pops
  popOsc = new p5.Oscillator('sine');
  popEnv = new p5.Envelope();
  // Fast, percussive pop: short attack/decay
  popEnv.setADSR(0.001, 0.1, 0.0, 0.1);
  popEnv.setRange(0.5, 0.0); // max amp, sustain amp
  popOsc.amp(popEnv);
  popOsc.start();
  popOsc.freq(400); // base; will be changed per bubble

  // Create initial bubbles
  for (let i = 0; i < NUM_BUBBLES; i++) {
    bubbles.push(new Bubble());
  }
}
Line-by-line explanation (12 lines)

🔧 Subcomponents:

function-call Canvas Initialization createCanvas(windowWidth, windowHeight);

Creates a canvas that fills the entire browser window, responsive to screen size

function-call HSB Color Mode colorMode(HSB, 360, 100, 100, 255);

Switches to HSB color space so hue (rainbow), saturation, and brightness can be controlled independently

object-creation Sound Synthesizer Setup popOsc = new p5.Oscillator('sine');

Creates a reusable sine-wave oscillator that will be triggered for every bubble pop

object-creation Envelope Configuration popEnv.setADSR(0.001, 0.1, 0.0, 0.1);

Defines the amplitude shape (attack/decay/sustain/release) so each pop is a quick percussive blip

for-loop Initial Bubble Spawning for (let i = 0; i < NUM_BUBBLES; i++) { bubbles.push(new Bubble()); }

Populates the bubbles array with the initial fleet of floating bubbles

createCanvas(windowWidth, windowHeight);
Creates a canvas that fills the browser window; windowWidth and windowHeight are p5.js variables that update if the window resizes
colorMode(HSB, 360, 100, 100, 255);
Switches from RGB to HSB color mode: hue (0-360°), saturation (0-100%), brightness (0-100%), and alpha (0-255). This makes it easy to generate rainbow colors and brighten/darken them
noStroke();
Disables outlines on all shapes drawn after this, so circles are solid with no border
textFont('system-ui');
Sets the font for all text drawn in the sketch (used for the score display)
textSize(24);
Makes text 24 pixels tall
popOsc = new p5.Oscillator('sine');
Creates an oscillator that generates a sine wave; this will be the source of every pop sound
popEnv = new p5.Envelope();
Creates an envelope object that controls how the oscillator's amplitude changes over time, shaping it into a percussive pop
popEnv.setADSR(0.001, 0.1, 0.0, 0.1);
Sets the envelope shape: attack (0.001s = instant), decay (0.1s = fade quickly), sustain (0 = silent), release (0.1s = tail out). This creates a short 'pop' sound
popEnv.setRange(0.5, 0.0);
Sets the envelope's amplitude range: it will rise to 0.5 (half volume) and fall to 0 (silent)
popOsc.amp(popEnv);
Connects the envelope as the amplitude controller for the oscillator; when the envelope plays, it modulates the oscillator's volume
popOsc.start();
Starts the oscillator running continuously (but silently until the envelope is triggered)
popOsc.freq(400);
Sets a default frequency of 400 Hz; this will be changed dynamically based on each bubble's size

draw()

draw() is the game loop—it runs 60 times per second by default. Every frame, we clear the canvas, update and redraw all objects, and remove dead particles. The backward loop for particles is a professional pattern: when you splice an array during forward iteration, you skip elements; backward iteration avoids this bug.

🔬 This loop removes dead particles by splicing them out. What happens if you change it to count backward (i--) but remove the if(p.isDead()) check—will you see particles live longer, or will something else change?

  for (let i = particles.length - 1; i >= 0; i--) {
    let p = particles[i];
    p.update();
    p.show();
    if (p.isDead()) {
      particles.splice(i, 1);
    }
  }
function draw() {
  // Slightly animated background
  background(230, 50, 10); // HSB: deep blue-ish background

  // Draw and update bubbles
  for (let b of bubbles) {
    b.update();
    b.show();
  }

  // Draw and update particles
  for (let i = particles.length - 1; i >= 0; i--) {
    let p = particles[i];
    p.update();
    p.show();
    if (p.isDead()) {
      particles.splice(i, 1);
    }
  }

  // Draw score
  drawScore();
}
Line-by-line explanation (11 lines)

🔧 Subcomponents:

function-call Background Fill background(230, 50, 10);

Clears the canvas and fills it with a deep blue color each frame, erasing the previous frame's drawings

for-loop Update and Draw Bubbles for (let b of bubbles) { b.update(); b.show(); }

Iterates through all bubbles, updating their positions and redrawing them each frame

for-loop Update and Draw Particles (Reverse) for (let i = particles.length - 1; i >= 0; i--) { let p = particles[i]; p.update(); p.show(); if (p.isDead()) { particles.splice(i, 1); } }

Loops backward through particles, updating and drawing each, then removing dead ones. Backward iteration prevents skipping when splicing

background(230, 50, 10);
Fills the entire canvas with a dark blue-ish color (HSB: hue=230°, sat=50%, brightness=10%). Called every frame to erase the previous frame
for (let b of bubbles) {
Loops through each bubble in the bubbles array using the for...of syntax
b.update();
Calls the bubble's update method to move it upward and apply drift
b.show();
Calls the bubble's show method to draw it on the canvas
for (let i = particles.length - 1; i >= 0; i--) {
Loops backward through the particles array (from last to first). Backward iteration is crucial because we will remove particles mid-loop with splice()
let p = particles[i];
Retrieves the particle at index i
p.update();
Updates the particle's velocity (applying gravity and friction) and position
p.show();
Draws the particle as a small circle, with alpha fading based on remaining life
if (p.isDead()) {
Checks if the particle's life has reached zero
particles.splice(i, 1);
Removes the dead particle from the array. Because we are looping backward, skipping one index does not cause any particles to be missed
drawScore();
Calls the drawScore() function to render the current score in the top-left corner

windowResized()

p5.js calls windowResized() automatically whenever the browser window is resized. Without this function, the canvas would stay at its original size. By resizing it here, the game adapts to any screen.

function windowResized() {
  resizeCanvas(windowWidth, windowHeight);
}
Line-by-line explanation (1 lines)
resizeCanvas(windowWidth, windowHeight);
Resizes the canvas to match the current window dimensions whenever the browser is resized

Bubble (class)

The Bubble class is the core game object. Notice how reset() is called both in the constructor and after popping—this is a clean pattern for reinitializing state. The update() method handles physics (movement) and the show() method handles drawing, keeping logic and visuals separate. The contains() method is pure collision detection, used by the click handler.

🔬 The show() method draws three circles: an outer glow, a main bubble, and a highlight. What happens if you remove the first circle() call (the soft outer bubble)? Will the bubbles look flat or shiny?

    // Outer soft bubble
    fill(hue(this.col), saturation(this.col), brightness(this.col), 80);
    circle(this.x, this.y, this.r * 2.3);

    // Main bubble
    fill(this.col);
    circle(this.x, this.y, this.r * 2);

    // Highlight
    fill(0, 0, 100, 180);
    circle(this.x - this.r * 0.4, this.y - this.r * 0.4, this.r * 0.6);

🔬 The drift line uses frameCount to create a smooth sine-wave sway. What if you change frameCount * 0.01 to frameCount * 0.05? Will bubbles drift faster or slower?

    this.x += sin(frameCount * 0.01 + this.driftOffset) * this.driftAmount;

    // Keep within horizontal bounds
    this.x = constrain(this.x, this.r, width - this.r);
class Bubble {
  constructor() {
    this.reset(true);
  }

  reset(initial = false) {
    this.r = random(BUBBLE_MIN_R, BUBBLE_MAX_R);
    this.x = random(this.r, width - this.r);

    // Start from bottom; if initial, randomize a bit up the screen
    if (initial) {
      this.y = random(height + this.r * 0.5, height + this.r * 4);
    } else {
      this.y = height + this.r * 2;
    }

    this.speed = random(0.7, 1.8);
    this.driftAmount = random(0.2, 0.8);
    this.driftOffset = random(TWO_PI);

    const hue = random(180, 300); // cool colors
    const sat = random(60, 100);
    const bri = random(70, 100);
    this.col = color(hue, sat, bri, 180);
  }

  update() {
    // Float up
    this.y -= this.speed;

    // Gentle horizontal drift using a sine wave
    this.x += sin(frameCount * 0.01 + this.driftOffset) * this.driftAmount;

    // Keep within horizontal bounds
    this.x = constrain(this.x, this.r, width - this.r);

    // If bubble went off the top, respawn at bottom
    if (this.y + this.r < 0) {
      this.reset(false);
    }
  }

  show() {
    // Outer soft bubble
    fill(hue(this.col), saturation(this.col), brightness(this.col), 80);
    circle(this.x, this.y, this.r * 2.3);

    // Main bubble
    fill(this.col);
    circle(this.x, this.y, this.r * 2);

    // Highlight
    fill(0, 0, 100, 180);
    circle(this.x - this.r * 0.4, this.y - this.r * 0.4, this.r * 0.6);
  }

  contains(px, py) {
    return dist(px, py, this.x, this.y) < this.r;
  }

  pop() {
    // Play sound and create particle burst
    playPopSound(this);
    createParticleBurst(this.x, this.y, this.col);

    // Respawn bubble at bottom
    this.reset(false);
  }
}
Line-by-line explanation (17 lines)

🔧 Subcomponents:

function reset() Initialization reset(initial = false) { ... }

Sets or resets a bubble's position, size, speed, drift, and color; called on construction and after popping

function update() Position Logic update() { ... }

Moves the bubble upward each frame, applies horizontal drift, constrains it within bounds, and respawns if it drifts off the top

function show() Drawing show() { ... }

Draws three circles to create a bubble with a soft outer glow, solid middle, and bright highlight

function contains() Collision Detection contains(px, py) { ... }

Tests if a point (px, py) is inside this bubble using distance math

function pop() Interaction pop() { ... }

Triggered when clicked; plays sound, creates particles, and respawns the bubble

this.r = random(BUBBLE_MIN_R, BUBBLE_MAX_R);
Assigns a random radius between the minimum (20) and maximum (60) pixel values defined at the top
this.x = random(this.r, width - this.r);
Positions the bubble randomly along the horizontal axis, ensuring its radius stays within the canvas bounds
if (initial) { this.y = random(height + this.r * 0.5, height + this.r * 4); } else { this.y = height + this.r * 2; }
If this is the first spawn (initial=true), scatter bubbles slightly above the bottom to fill the screen; otherwise spawn just below the canvas to float up naturally
this.speed = random(0.7, 1.8);
Each bubble gets a random upward speed so they rise at different rates, making the motion less mechanical
this.driftAmount = random(0.2, 0.8);
Controls how far left-right each bubble sways; higher values = wider drift
this.driftOffset = random(TWO_PI);
Seeds each bubble with a different sine-wave offset so they drift out of phase with each other
const hue = random(180, 300);
Picks a hue in the cool-color range (180° = cyan, 300° = magenta), skipping warm reds and yellows
this.y -= this.speed;
Moves the bubble upward each frame by subtracting its speed from its y-coordinate (remember: y=0 is the top)
this.x += sin(frameCount * 0.01 + this.driftOffset) * this.driftAmount;
Uses a sine wave to create smooth horizontal sway; frameCount increments each frame, and the driftOffset makes each bubble drift independently
this.x = constrain(this.x, this.r, width - this.r);
Clamps the bubble's x-position so it never extends beyond the left or right edge (accounting for its radius)
if (this.y + this.r < 0) {
Checks if the bubble has drifted completely above the top of the canvas (where it is no longer visible)
fill(hue(this.col), saturation(this.col), brightness(this.col), 80);
Extracts HSB values from the bubble's color and creates a semi-transparent version (alpha=80) for the soft outer glow
circle(this.x, this.y, this.r * 2.3);
Draws the outermost, softest bubble layer at 2.3× the radius to create a halo effect
circle(this.x, this.y, this.r * 2);
Draws the main solid bubble body at 2× the radius (diameter = radius * 2)
fill(0, 0, 100, 180); circle(this.x - this.r * 0.4, this.y - this.r * 0.4, this.r * 0.6);
Draws a small bright white highlight offset slightly up and left of the bubble's center, making it look shiny and 3D
return dist(px, py, this.x, this.y) < this.r;
Uses the dist() function to calculate the distance between the click point (px, py) and the bubble's center, returning true if that distance is less than the radius
playPopSound(this); createParticleBurst(this.x, this.y, this.col); // Respawn bubble at bottom this.reset(false);
When popped, trigger the sound (passing this bubble so its size can determine pitch), spawn particle effects, and reset the bubble to respawn

Particle (class)

The Particle class demonstrates basic physics simulation using vectors. Friction and gravity are forces applied to velocity each frame. Notice how alpha (transparency) is mapped to remaining life—this is a classic technique to make particles fade out convincingly. A burst of 12-22 particles with slightly different colors, sizes, and trajectories creates a satisfying explosion effect.

🔬 This is the particle physics: friction slows the particle, gravity pulls it down, position updates, and life decrements. What happens if you comment out the gravity line? Will particles drift straight, or will they gain speed?

    this.vel.mult(this.friction);
    this.vel.add(this.gravity);
    this.pos.add(this.vel);
    this.life--;
class Particle {
  constructor(x, y, c) {
    this.pos = createVector(x, y);

    const angle = random(TWO_PI);
    const speed = random(1.5, 4);
    this.vel = p5.Vector.fromAngle(angle).mult(speed);

    this.gravity = createVector(0, 0.04);
    this.friction = 0.96;

    this.life = random(25, 45);
    this.totalLife = this.life;

    const baseHue = hue(c);
    const sat = saturation(c);
    const bri = brightness(c);
    // Slight color variation
    this.col = color(
      baseHue + random(-10, 10),
      sat,
      bri,
      255
    );
    this.size = random(3, 7);
  }

  update() {
    this.vel.mult(this.friction);
    this.vel.add(this.gravity);
    this.pos.add(this.vel);
    this.life--;
  }

  isDead() {
    return this.life <= 0;
  }

  show() {
    const alpha = map(this.life, 0, this.totalLife, 0, 255);
    fill(hue(this.col), saturation(this.col), brightness(this.col), alpha);
    circle(this.pos.x, this.pos.y, this.size);
  }
}
Line-by-line explanation (17 lines)

🔧 Subcomponents:

function constructor() Initialization constructor(x, y, c) { ... }

Creates a particle with position, velocity in a random direction, gravity, friction, life span, and a color variant

function update() Physics update() { ... }

Applies friction to slow the particle, adds gravity to pull it downward, updates position, and decrements life

function isDead() State Check isDead() { ... }

Returns true if the particle's life has expired

function show() Drawing show() { ... }

Draws the particle as a small circle with alpha fading based on remaining life

this.pos = createVector(x, y);
Stores the particle's starting position as a p5.Vector, which makes it easy to apply velocity and gravity math
const angle = random(TWO_PI);
Picks a random angle between 0 and 360 degrees (TWO_PI radians) for the particle to initially move in
const speed = random(1.5, 4);
Chooses a random initial speed so particles scatter at different rates
this.vel = p5.Vector.fromAngle(angle).mult(speed);
Creates a velocity vector pointing in the random angle direction at the random speed; the particle will move in this direction each frame
this.gravity = createVector(0, 0.04);
Defines a constant downward acceleration (positive y = down); this will be added to velocity each frame to pull particles toward the bottom
this.friction = 0.96;
A multiplier (0-1) applied to velocity each frame to slow the particle down; 0.96 means it retains 96% of its speed, losing 4% to friction
this.life = random(25, 45);
Particles live for a random number of frames between 25 and 45 (roughly 0.4 to 0.75 seconds at 60 fps)
this.totalLife = this.life;
Stores the initial life value so we can calculate how much life remains as a percentage for the fade-out effect
this.col = color( baseHue + random(-10, 10), sat, bri, 255 );
Creates a color based on the parent bubble's color, but with the hue slightly randomized (±10°) so particles are not all identical
this.size = random(3, 7);
Each particle gets a random size between 3 and 7 pixels so the burst looks organic and varied
this.vel.mult(this.friction);
Slows the particle down by multiplying its velocity by the friction value (0.96), preserving direction but reducing speed
this.vel.add(this.gravity);
Pulls the particle downward by adding the gravity vector to the velocity each frame
this.pos.add(this.vel);
Updates the particle's position by adding its velocity vector, moving it one step in its current direction
this.life--;
Decrements the life counter by 1 each frame, counting down toward zero
return this.life <= 0;
Simple check: if life has reached zero or below, the particle is dead and should be removed
const alpha = map(this.life, 0, this.totalLife, 0, 255);
Converts remaining life to an alpha value: when life is full, alpha=255 (opaque); when life=0, alpha=0 (transparent). This creates a smooth fade-out
circle(this.pos.x, this.pos.y, this.size);
Draws a small circle at the particle's current position with the fading alpha, creating the burst effect

ensureAudioStarted()

The AudioContext in modern browsers is locked until the user makes a gesture (like a click or touch). p5.sound's userStartAudio() unlocks it. This function ensures it runs once at the first interaction, not repeatedly.

function ensureAudioStarted() {
  if (!audioStarted) {
    userStartAudio(); // resumes AudioContext on first user gesture
    audioStarted = true;
  }
}
Line-by-line explanation (3 lines)

🔧 Subcomponents:

conditional Audio Context Initialization if (!audioStarted) { ... }

Ensures the browser's audio context is started only once, on first user interaction (required by modern browsers)

if (!audioStarted) {
Checks if audioStarted is false (the ! means 'not'). If this is the first time, the condition is true
userStartAudio();
p5.sound function that resumes the browser's AudioContext, required before any sound can play. Modern browsers lock audio until the user interacts (security feature)
audioStarted = true;
Sets the flag to true so this initialization runs only once, not on every click

playPopSound(bubble)

This function demonstrates dynamic audio synthesis: the oscillator's frequency is changed based on game state (bubble size), creating pitch feedback tied to the player's actions. The map() function is key to converting game values (radius) into audio parameters (frequency).

function playPopSound(bubble) {
  // Map bubble size to pitch: small bubbles -> higher pitch
  const freq = map(
    bubble.r,
    BUBBLE_MIN_R,
    BUBBLE_MAX_R,
    800, // small radius
    200, // large radius
    true
  );
  popOsc.freq(freq);
  popEnv.play(); // triggers envelope on the oscillator
}
Line-by-line explanation (3 lines)

🔧 Subcomponents:

calculation Bubble Size to Pitch Mapping const freq = map( bubble.r, BUBBLE_MIN_R, BUBBLE_MAX_R, 800, // small radius 200, // large radius true );

Converts bubble radius to a frequency: small bubbles produce high pitches (800 Hz), large bubbles produce low pitches (200 Hz)

function-call Oscillator Frequency Update popOsc.freq(freq);

Updates the oscillator's frequency before playing it, so each bubble pop has pitch based on its size

function-call Envelope Trigger popEnv.play();

Triggers the envelope, causing the oscillator's amplitude to spike and decay, creating the percussive pop sound

const freq = map( bubble.r, BUBBLE_MIN_R, BUBBLE_MAX_R, 800, 200, true );
The map() function remaps the bubble's radius (input range: 20-60) to a frequency (output range: 800-200). The true flag allows values outside the input range to map proportionally. Small bubbles (r=20) → 800 Hz (high), large bubbles (r=60) → 200 Hz (low), creating pitch variation
popOsc.freq(freq);
Sets the oscillator's frequency to this value in Hertz, changing its pitch before the envelope is triggered
popEnv.play();
Plays the envelope, which ramps the oscillator's amplitude up and down according to the ADSR settings, creating one short pop sound

createParticleBurst(x, y, col)

This is a factory function that creates multiple particles at once, inheriting the bubble's color. The random count adds visual variety so not every pop feels identical. Notice how the color (col) is passed through, connecting the particle system to the bubble that created it.

🔬 This function creates a burst of particles. What happens if you change 'particles.push' to just 'new Particle(x, y, col);' without pushing? Will particles still appear, or will they be created and immediately forgotten?

  const count = floor(random(12, 22));
  for (let i = 0; i < count; i++) {
    particles.push(new Particle(x, y, col));
  }
function createParticleBurst(x, y, col) {
  const count = floor(random(12, 22));
  for (let i = 0; i < count; i++) {
    particles.push(new Particle(x, y, col));
  }
}
Line-by-line explanation (3 lines)

🔧 Subcomponents:

calculation Random Particle Count const count = floor(random(12, 22));

Chooses how many particles to create (12-22), so each burst feels slightly different

for-loop Particle Instantiation Loop for (let i = 0; i < count; i++) { particles.push(new Particle(x, y, col)); }

Creates count particles at position (x, y) with the bubble's color and adds them to the particles array

const count = floor(random(12, 22));
Generates a random decimal between 12 and 22, then floor() rounds it down to an integer. This means each burst has a slightly different number of particles (12, 13, 14, ... 21), making pops feel organic
for (let i = 0; i < count; i++) {
Loops count times, creating a new particle each iteration
particles.push(new Particle(x, y, col));
Creates a new Particle object at position (x, y) with the bubble's color and adds it to the particles array. The Particle constructor will randomize its direction, speed, and color variant

mousePressed()

mousePressed() is a p5.js event function that runs automatically when the user clicks. It delegates to handlePop() to keep the code modular.

function mousePressed() {
  handlePop(mouseX, mouseY);
}
Line-by-line explanation (1 lines)
handlePop(mouseX, mouseY);
Calls the handlePop() helper function with the current mouse coordinates (provided automatically by p5.js)

touchStarted()

touchStarted() is a p5.js event function for touch input. By returning false, we prevent default browser actions and ensure the game responds smoothly to mobile touches.

function touchStarted() {
  // On many platforms, touch also triggers mousePressed, but this ensures
  // touch-only environments still work nicely.
  handlePop(mouseX, mouseY);
  return false; // prevent default scrolling on touch
}
Line-by-line explanation (2 lines)
handlePop(mouseX, mouseY);
Calls handlePop() with the touch position; p5.js automatically updates mouseX and mouseY on touch, so this works identically to mousePressed()
return false;
Returns false to prevent the browser's default touch behavior (like page scrolling), so the game gets the touch event exclusively

handlePop(px, py)

This is the game's input handler. It demonstrates three key patterns: (1) backward array iteration to prioritize topmost objects, (2) early exit (break) to handle only the first match, and (3) encapsulation of side effects (sound, particles, score) in the bubble's pop() method.

🔬 This loop finds only ONE bubble to pop and then breaks. What happens if you remove the 'break' statement? Will multiple bubbles pop on one click, or will the loop behave differently?

  for (let i = bubbles.length - 1; i >= 0; i--) {
    if (bubbles[i].contains(px, py)) {
      bubbles[i].pop();
      score++;
      break;
    }
  }
function handlePop(px, py) {
  ensureAudioStarted();

  // Pop only one bubble per click, preferring the one drawn last (topmost)
  for (let i = bubbles.length - 1; i >= 0; i--) {
    if (bubbles[i].contains(px, py)) {
      bubbles[i].pop();
      score++;
      break;
    }
  }
}
Line-by-line explanation (6 lines)

🔧 Subcomponents:

function-call Audio Context Startup ensureAudioStarted();

On first click/touch, initializes the browser's audio context so sound can play

for-loop Bubble Collision Detection (Reverse) for (let i = bubbles.length - 1; i >= 0; i--) { if (bubbles[i].contains(px, py)) { bubbles[i].pop(); score++; break; } }

Loops backward through bubbles to find the topmost one clicked, pops it, increments score, and stops (only one bubble per click)

ensureAudioStarted();
Ensures the audio context is initialized before any sound plays
for (let i = bubbles.length - 1; i >= 0; i--) {
Loops backward through the bubbles array (from the last one to the first). Because bubbles drawn last are on top, starting from the end finds the topmost bubble first
if (bubbles[i].contains(px, py)) {
Calls the bubble's contains() method to check if the click point (px, py) is inside this bubble
bubbles[i].pop();
If clicked, calls the bubble's pop() method, which plays sound, creates particles, and respawns the bubble
score++;
Increments the score by 1
break;
Exits the loop immediately, ensuring only one bubble is popped per click even if clicks overlap multiple bubbles

drawScore()

The push() and pop() pattern isolates graphics state changes. This is a best practice: if drawScore() set fill to white but didn't restore it, all subsequent drawings would be white too. By wrapping it in push/pop, we ensure clean isolation.

function drawScore() {
  push();
  textAlign(LEFT, TOP);
  fill(0, 0, 100, 220); // bright text
  text("Score: " + score, 16, 16);
  pop();
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

function-call Graphics State Management push(); ... pop();

Saves and restores the graphics state so text styling does not affect other drawings

function-call Text Display text("Score: " + score, 16, 16);

Draws the score string at the top-left corner (16 pixels from the top and left edges)

push();
Saves the current graphics state (fill color, text alignment, etc.) so changes made here don't affect other parts of the sketch
textAlign(LEFT, TOP);
Aligns text so that the top-left corner of the text sits at the given coordinates, making positioning intuitive
fill(0, 0, 100, 220);
Sets the text color to bright white (HSB: hue=0, sat=0, brightness=100) with alpha=220 (nearly opaque) for good visibility
text("Score: " + score, 16, 16);
Renders the string 'Score: ' concatenated with the current score number at pixel position (16, 16)
pop();
Restores the graphics state to what it was before push(), undoing the text alignment and fill color changes

📦 Key Variables

NUM_BUBBLES number

Constant defining how many bubbles float on screen at any given time

const NUM_BUBBLES = 18;
BUBBLE_MIN_R number

Constant for the smallest possible bubble radius in pixels

const BUBBLE_MIN_R = 20;
BUBBLE_MAX_R number

Constant for the largest possible bubble radius in pixels

const BUBBLE_MAX_R = 60;
bubbles array

Array holding all active Bubble objects currently on screen

let bubbles = [];
particles array

Array holding all active Particle objects (burst fragments from popped bubbles)

let particles = [];
score number

Tracks the total number of bubbles popped so far in this game session

let score = 0;
popOsc object

A p5.Oscillator (sine wave) that is reused to generate every bubble pop sound with dynamic frequency

let popOsc;
popEnv object

A p5.Envelope that shapes the oscillator's amplitude into fast percussive pops

let popEnv;
audioStarted boolean

Flag tracking whether the audio context has been initialized (required by modern browsers on first user gesture)

let audioStarted = false;

🔧 Potential Improvements (5)

Here are some ways this code could be enhanced:

BUG Bubble.contains()

Collision detection uses bubble center radius (this.r) but drawing diameter is this.r * 2, creating a mismatch where clickable area is smaller than visual bubble

💡 Change contains() to compare against this.r * 1.15 (accounting for the main bubble's actual drawn radius) or document the intentional difference as a game balance mechanic

PERFORMANCE Bubble.show()

Extracts HSB values from the bubble color every frame using hue(), saturation(), brightness() functions—these are called 3 times per bubble per frame (9 times for all 18 bubbles)

💡 Store HSB values in the reset() method as this.hue, this.sat, this.bri to avoid repeated color component extraction

FEATURE handlePop()

No feedback if the player clicks an empty area—the game is silent unless a bubble is hit, which can feel unresponsive

💡 Add a quiet 'miss' click sound or visual feedback (canvas flash) on any click, even if no bubble was hit

STYLE Bubble.show()

Magic numbers (2.3, 2, 0.4, 0.6) for circle sizes and highlight offset make the code hard to understand and adjust

💡 Define named constants like OUTER_SCALE = 2.3, MAIN_SCALE = 2, HIGHLIGHT_OFFSET = 0.4, HIGHLIGHT_SCALE = 0.6 at the top of the sketch

BUG windowResized()

Bubbles with large radius near canvas edge can get stuck if canvas shrinks, as constrain() may force them outside bounds after resize

💡 After resizeCanvas(), validate all existing bubble positions and clamp them: for (let b of bubbles) { b.x = constrain(b.x, b.r, width - b.r); }

🔄 Code Flow

Code flow showing setup, draw, windowresized, bubble, particle, ensureaudiostarted, playpopsound, createparticlburst, mousepressed, touchstarted, handlepop, drawscore

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

graph TD start[Start] --> setup[setup] setup --> canvas-creation[Canvas Initialization] setup --> hsb-setup[HSB Color Mode] setup --> sound-synthesizer[Sound Synthesizer Setup] setup --> envelope-setup[Envelope Configuration] setup --> bubble-creation[Initial Bubble Spawning] setup --> draw[draw loop] draw --> background-draw[Background Fill] draw --> bubble-loop[Update and Draw Bubbles] bubble-loop --> update-method[update() Position Logic] bubble-loop --> show-method[show() Drawing] bubble-loop --> contains-method[contains() Collision Detection] draw --> particle-loop[Update and Draw Particles] particle-loop --> particle-update[update() Physics] particle-loop --> particle-show[show() Drawing] particle-loop --> particle-isdead[isDead() State Check] draw --> drawscore[drawScore] drawscore --> push-pop[Graphics State Management] drawscore --> text-rendering[Text Display] click setup href "#fn-setup" click draw href "#fn-draw" click canvas-creation href "#sub-canvas-creation" click hsb-setup href "#sub-hsb-setup" click sound-synthesizer href "#sub-sound-synthesizer" click envelope-setup href "#sub-envelope-setup" click bubble-creation href "#sub-bubble-creation" click background-draw href "#sub-background-draw" click bubble-loop href "#sub-bubble-loop" click update-method href "#sub-update-method" click show-method href "#sub-show-method" click contains-method href "#sub-contains-method" click particle-loop href "#sub-particle-loop" click particle-update href "#sub-particle-update" click particle-show href "#sub-particle-show" click particle-isdead href "#sub-particle-isdead" click drawscore href "#fn-drawscore" click push-pop href "#sub-push-pop" click text-rendering href "#sub-text-rendering"

❓ Frequently Asked Questions

What visual experience does the AI Bubble Pop sketch provide?

The sketch creates a vibrant scene where colorful bubbles float upwards against a deep blue-ish background, providing a visually satisfying experience.

How can players interact with the AI Bubble Pop game?

Users can click on the bubbles as they float up to burst them, contributing to a fun and engaging gameplay experience.

What creative coding concepts does the AI Bubble Pop sketch illustrate?

This sketch demonstrates the use of object-oriented programming with classes to manage bubbles, as well as sound synthesis to enhance user interaction through audio feedback.

Preview

AI Bubble Pop - Satisfying Click Game Pop colorful bubbles as they float up! Click bubbles to burst - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of AI Bubble Pop - Satisfying Click Game Pop colorful bubbles as they float up! Click bubbles to burst - Code flow showing setup, draw, windowresized, bubble, particle, ensureaudiostarted, playpopsound, createparticlburst, mousepressed, touchstarted, handlepop, drawscore
Code Flow Diagram