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.
What you will build
Section titled “What you will build”- The complete
POP.configobject POP.loop(),POP.update(), andPOP.render()methods- The
Bubbleclass 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.
Step 1 — Fill in POP.config
Section titled “Step 1 — Fill in POP.config”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 frameframeCount— incremented each frame; used to time bubble spawnsanimId— stores therequestAnimationFramereturn value (not needed for this project, but useful if you ever add a pause feature)
Step 3 — Expand init()
Section titled “Step 3 — Expand init()”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,Step 4 — Write the game loop
Section titled “Step 4 — Write the game loop”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 thethiscontext. Without it,thisinsideloop()would beundefined(orwindowin non-strict mode).
Step 5 — Write update()
Section titled “Step 5 — Write update()”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.
Step 6 — Write render()
Section titled “Step 6 — Write render()”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.
Step 7 — Write spawnBubble()
Section titled “Step 7 — Write spawnBubble()”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. xis 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.
Step 8 — Write the Bubble class
Section titled “Step 8 — Write the Bubble class”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(); }}How the sine wave works
Section titled “How the sine wave works”Each bubble has an originX (where it spawned) and a tick counter. Every frame:
this.tick += this.sineFreq; // advance the anglethis.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.
How the radial gradient works
Section titled “How the radial gradient works”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.
ctx.save() / ctx.restore()
Section titled “ctx.save() / ctx.restore()”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.
Testing without the Start button
Section titled “Testing without the Start button”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 5this.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.
What’s next
Section titled “What’s next”In Stage 4 you will add click and touch input, circle–point collision detection to pop bubbles, and the lives and scoring system.