Skip to content

Stage 3 — Game Loop and Bubbles

Stage 3 is where the game becomes alive. You will implement the game loop that drives every frame, add the full config object, write the Bubble class, and wire up spawning. By the end, bubbles will be rising from the bottom of the screen — no interaction yet, but the game is moving.


  • The complete POP.config object
  • POP.loop(), POP.update(), and POP.render() methods
  • The Bubble class with sine-wave movement and gradient rendering
  • POP.spawnBubble() and the frame-count spawn timer

Checkpoint: open index.html, click Play (wired up in Stage 5, but the start screen will be hidden for testing in a moment), and bubbles should rise up the canvas.


The config object is the single place where all game tuning lives — speeds, sizes, timings. Replace the empty config: {} stub from Stage 1 with:

config: {
canvas: { width: 480, height: 640 },
bubble: {
minRadius: 18,
maxRadius: 40,
minSpeed: 0.8,
maxSpeed: 2.2,
sineAmp: 1.2,
sineFreq: 0.025,
spawnRate: 90, // frames between spawns
},
particle: {
count: 8,
speed: 3,
life: 30,
},
level: {
scorePerLevel: 10,
maxLevel: 10,
},
lives: 3,
highScoreKey: 'bubblePop_highScore',
},

Having all values in one place means you can tune the game feel by changing numbers here without hunting through the logic.


Step 2 — Add state arrays and frame counter

Section titled “Step 2 — Add state arrays and frame counter”

Below config, add the state properties the game loop will use:

state: 'start',
bubbles: [],
particles: [],
score: 0,
lives: 0,
level: 1,
frameCount: 0,
animId: null,
  • bubbles / particles — arrays of entity objects updated and drawn each frame
  • frameCount — incremented each frame; used to time bubble spawns
  • animId — stores the requestAnimationFrame return value (not needed for this project, but useful if you ever add a pause feature)

Replace the stub init() with the full version that grabs all DOM refs:

init() {
this.canvas = document.getElementById('game-canvas');
this.ctx = this.canvas.getContext('2d');
this.startScreen = document.getElementById('start-screen');
this.gameoverScreen = document.getElementById('gameover-screen');
this.hud = document.getElementById('hud');
this.scoreDisplay = document.getElementById('score-display');
this.levelDisplay = document.getElementById('level-display');
this.livesDisplay = document.getElementById('lives-display');
this.finalScore = document.getElementById('final-score');
this.newHighScore = document.getElementById('new-high-score');
this.highScoreDisplay = document.getElementById('high-score-display');
const { width, height } = this.config.canvas;
this.canvas.width = width;
this.canvas.height = height;
this.highScore = parseInt(localStorage.getItem(this.config.highScoreKey) || '0', 10);
this.highScoreDisplay.textContent = `Best: ${this.highScore}`;
this.loop();
},

Also add these DOM ref properties alongside the others at the top of POP:

startScreen: null,
gameoverScreen: null,
hud: null,
scoreDisplay: null,
levelDisplay: null,
livesDisplay: null,
finalScore: null,
newHighScore: null,
highScoreDisplay: null,
highScore: 0,

The game loop calls itself every frame using requestAnimationFrame. Each tick it updates game state and draws the frame:

loop() {
this.animId = requestAnimationFrame(() => this.loop());
this.update();
this.render();
},
  • requestAnimationFrame — asks the browser to call your function before the next screen repaint, typically 60 times per second. It automatically pauses when the tab is in the background to save battery.
  • The arrow function () => this.loop() preserves the this context. Without it, this inside loop() would be undefined (or window in non-strict mode).

update() runs once per frame and advances all game state. For now it handles frame counting, spawn timing, and bubble movement:

update() {
if (this.state !== 'playing') return;
this.frameCount++;
// Level progression
const level = Math.min(
1 + Math.floor(this.score / this.config.level.scorePerLevel),
this.config.level.maxLevel
);
if (level !== this.level) {
this.level = level;
this.updateHud();
}
// Spawn on interval
const spawnInterval = Math.max(30, this.config.bubble.spawnRate - (this.level - 1) * 6);
if (this.frameCount % spawnInterval === 0) {
this.spawnBubble();
}
// Move bubbles; remove escaped ones
for (let i = this.bubbles.length - 1; i >= 0; i--) {
const b = this.bubbles[i];
b.update();
if (b.escaped()) {
this.bubbles.splice(i, 1);
// Lives handling comes in Stage 4
}
}
},

Iterating the array in reverse (from the last index down to 0) is required when removing items with splice inside the loop. Removing an item shifts every subsequent index down by one — iterating forward would cause items immediately after the removed one to be skipped.


render() clears the canvas and draws everything:

render() {
const { ctx } = this;
const { width, height } = this.config.canvas;
ctx.clearRect(0, 0, width, height);
if (this.state === 'playing') {
this.bubbles.forEach(b => b.render(ctx));
}
},

clearRect wipes the entire canvas before drawing the new frame. Without it, each frame would paint on top of the previous one and leave trails.


spawnBubble() {
const { minRadius, maxRadius, minSpeed, maxSpeed, sineAmp, sineFreq } = this.config.bubble;
const radius = minRadius + Math.random() * (maxRadius - minRadius);
const x = radius + Math.random() * (this.config.canvas.width - radius * 2);
const speed = (minSpeed + Math.random() * (maxSpeed - minSpeed)) * (1 + (this.level - 1) * 0.15);
const hue = Math.floor(Math.random() * 360);
this.bubbles.push(new Bubble(x, this.config.canvas.height + radius, radius, speed, sineAmp, sineFreq, hue));
},
  • The bubble spawns just below the bottom edge (height + radius) so it slides into view smoothly.
  • x is clamped so bubbles with large radii do not spawn partially off-screen.
  • Speed scales by 15 % per level — fast enough to feel the progression without becoming impossible.

Add the Bubble class below the closing }; of the POP object:

class Bubble {
constructor(x, y, radius, speed, sineAmp, sineFreq, hue) {
this.x = x;
this.y = y;
this.radius = radius;
this.speed = speed;
this.sineAmp = sineAmp;
this.sineFreq = sineFreq;
this.originX = x;
this.tick = Math.random() * Math.PI * 2;
this.hue = hue;
this.color = `hsl(${hue}, 80%, 65%)`;
}
update() {
this.tick += this.sineFreq;
this.y -= this.speed;
this.x = this.originX + Math.sin(this.tick) * this.sineAmp * this.radius;
}
contains(px, py) {
const dx = px - this.x;
const dy = py - this.y;
return dx * dx + dy * dy <= this.radius * this.radius;
}
escaped() {
return this.y + this.radius < 0;
}
render(ctx) {
ctx.save();
// Outer glow
ctx.shadowColor = this.color;
ctx.shadowBlur = 16;
// Bubble body — radial gradient from bright highlight to transparent edge
const grad = ctx.createRadialGradient(
this.x - this.radius * 0.3, this.y - this.radius * 0.3, this.radius * 0.1,
this.x, this.y, this.radius
);
grad.addColorStop(0, `hsla(${this.hue}, 90%, 90%, 0.9)`);
grad.addColorStop(0.6, `hsla(${this.hue}, 80%, 65%, 0.7)`);
grad.addColorStop(1, `hsla(${this.hue}, 70%, 40%, 0.3)`);
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
ctx.fillStyle = grad;
ctx.fill();
// Rim
ctx.strokeStyle = `hsla(${this.hue}, 80%, 80%, 0.6)`;
ctx.lineWidth = 1.5;
ctx.stroke();
// White highlight dot (top-left)
ctx.shadowBlur = 0;
ctx.beginPath();
ctx.arc(
this.x - this.radius * 0.28,
this.y - this.radius * 0.28,
this.radius * 0.18,
0, Math.PI * 2
);
ctx.fillStyle = 'rgba(255, 255, 255, 0.7)';
ctx.fill();
ctx.restore();
}
}

Each bubble has an originX (where it spawned) and a tick counter. Every frame:

this.tick += this.sineFreq; // advance the angle
this.x = this.originX + Math.sin(this.tick) * this.sineAmp * this.radius;

Math.sin(tick) oscillates between −1 and +1. Multiplying by sineAmp * radius scales the side-to-side drift proportionally to the bubble’s size. Large bubbles sway more than small ones.

Starting tick at a random angle (Math.random() * Math.PI * 2) means each bubble begins at a different point in its oscillation cycle so they don’t all sway in unison.

createRadialGradient(x1, y1, r1, x2, y2, r2) takes two circles — an inner and an outer. The gradient blends from the inner circle outward to the outer.

The inner circle is offset up and to the left (x - radius * 0.3, y - radius * 0.3) to simulate light coming from the upper left. The result is a bright highlight at the top and a darker, more transparent edge at the bottom — the characteristic bubble look.

save() pushes the current canvas state (transforms, fill styles, shadow settings, etc.) onto a stack. restore() pops it back. This prevents the shadowBlur and shadowColor set for the glow from leaking into subsequent draw calls.


The Start button logic is wired in Stage 5. To test the bubbles now, temporarily change the initial state at the top of POP from 'start' to 'playing' and add a stub startGame() call at the bottom of init():

// Temporary — remove in Stage 5
this.state = 'playing';
this.lives = this.config.lives;
this.hud.style.display = 'flex';
this.startScreen.classList.add('hidden');

Open index.html — bubbles should rise and sway. Remove this test code before Stage 5.


In Stage 4 you will add click and touch input, circle–point collision detection to pop bubbles, and the lives and scoring system.