Skip to content

Stage 5 — Start Screen, Game Over, and High Score

Stage 5 is the finishing pass. You will replace every stub from the earlier stages with real implementations, add the particle burst effect, build the full start/play/game-over state machine, and persist the high score between sessions using localStorage. When this stage is complete, the game is fully playable.


  • The Particle class — pop burst effect rendered on canvas
  • POP.startGame() — resets state and transitions to playing
  • POP.endGame() — checks for a high score and shows the game-over screen
  • POP.spawnParticles() — creates a burst of particles at a popped bubble’s position
  • High score saved and loaded from localStorage
  • Button event listeners for Start and Restart

Checkpoint: the full game loop — start screen → playing → game over → restart — works end to end.


Add the Particle class below the Bubble class:

class Particle {
constructor(x, y, vx, vy, color, maxLife) {
this.x = x;
this.y = y;
this.vx = vx;
this.vy = vy;
this.color = color;
this.life = maxLife;
this.maxLife = maxLife;
}
update() {
this.x += this.vx;
this.y += this.vy;
this.vy += 0.1; // gravity
this.life--;
}
isDead() {
return this.life <= 0;
}
render(ctx) {
const alpha = this.life / this.maxLife;
const radius = 4 * alpha;
ctx.save();
ctx.globalAlpha = alpha;
ctx.beginPath();
ctx.arc(this.x, this.y, radius, 0, Math.PI * 2);
ctx.fillStyle = this.color;
ctx.fill();
ctx.restore();
}
}

Each particle is given a velocity (vx, vy) and a life counter. Every frame:

  • Position advances by velocity (x += vx, y += vy)
  • Gravity pulls the particle down (vy += 0.1)
  • life decrements toward zero

In render(), alpha = life / maxLife goes from 1.0 (full opacity) to 0.0 (invisible) as the particle ages. The radius also shrinks with alpha so particles visually vanish rather than disappear abruptly.

ctx.globalAlpha sets the opacity for all subsequent draw calls. ctx.save() / ctx.restore() resets it after the particle is drawn so other entities are not affected.


Replace the stub from Stage 4 with the real implementation:

spawnParticles(x, y, color) {
const { count, speed, life } = this.config.particle;
for (let i = 0; i < count; i++) {
const angle = (i / count) * Math.PI * 2;
const vel = speed * (0.5 + Math.random() * 0.5);
this.particles.push(
new Particle(x, y, Math.cos(angle) * vel, Math.sin(angle) * vel, color, life)
);
}
},

count particles are spawned in a circle. Dividing the full circle (Math.PI * 2 radians = 360°) by count spaces them evenly. Each particle gets a random speed between 50 % and 100 % of config.particle.speed so the burst looks natural rather than perfectly uniform.


Step 3 — Update render() to draw particles

Section titled “Step 3 — Update render() to draw particles”

In the render() method, add the particles loop after the bubbles loop:

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));
this.particles.forEach(p => p.render(ctx));
}
},

Also update the particle array in update() — add this block after the bubbles loop:

// Update particles; remove dead ones
for (let i = this.particles.length - 1; i >= 0; i--) {
this.particles[i].update();
if (this.particles[i].isDead()) this.particles.splice(i, 1);
}

Replace the temporary test code from Stage 3 with the real startGame() method:

startGame() {
this.bubbles = [];
this.particles = [];
this.score = 0;
this.lives = this.config.lives;
this.level = 1;
this.frameCount = 0;
this.state = 'playing';
this.startScreen.classList.add('hidden');
this.gameoverScreen.classList.add('hidden');
this.hud.style.display = 'flex';
this.updateHud();
},

Resetting all arrays and counters before setting state = 'playing' ensures a clean slate whether the player is starting fresh or restarting after a game over.


Replace the stub from Stage 4:

endGame() {
this.state = 'gameover';
this.finalScore.textContent = `Score: ${this.score}`;
const isNewHigh = this.score > this.highScore;
if (isNewHigh) {
this.highScore = this.score;
localStorage.setItem(this.config.highScoreKey, String(this.highScore));
this.newHighScore.classList.remove('hidden');
} else {
this.newHighScore.classList.add('hidden');
}
this.gameoverScreen.classList.remove('hidden');
this.hud.style.display = 'none';
},

localStorage is a key-value store in the browser that persists between sessions. The data survives page refreshes and browser restarts.

// Save
localStorage.setItem('bubblePop_highScore', String(this.score));
// Load (in init())
parseInt(localStorage.getItem('bubblePop_highScore') || '0', 10);

setItem and getItem always work with strings. parseInt converts the stored string back to a number; the || '0' fallback handles the first visit when no value has been saved yet.


Add these two lines to init(), after the event listeners from Stage 4:

document.getElementById('start-btn').addEventListener('click', () => this.startGame());
document.getElementById('restart-btn').addEventListener('click', () => this.startGame());

Both buttons call the same startGame(). It handles both first play and restart identically — full state reset either way.


Step 7 — Update the high score display on the start screen

Section titled “Step 7 — Update the high score display on the start screen”

At the end of init(), after loading the high score from localStorage, update the display:

this.highScore = parseInt(localStorage.getItem(this.config.highScoreKey) || '0', 10);
this.highScoreDisplay.textContent = `Best: ${this.highScore}`;

This shows the stored high score on the start screen before the first game, and after returning from a game over (since init() only runs once — endGame() shows the game-over screen; when the player clicks Restart, startGame() runs, not init()).

To keep the start screen’s “Best” in sync with the current session’s high score, update it inside endGame() after saving:

this.highScoreDisplay.textContent = `Best: ${this.highScore}`;

At this point, pop.js contains:

const POP = {
config: { ... }
state: 'start'
bubbles/particles/score/lives/level/frameCount/animId: ...
canvas/ctx/DOM refs: null
init() — get DOM refs, set canvas size, load high score, add listeners, start loop
startGame() — reset state, hide screens, show HUD
endGame() — save high score, show game-over screen
loop() — requestAnimationFrame → update → render
update() — advance frame, spawn, move bubbles, remove particles
render() — clear canvas, draw bubbles and particles
handleInput() — convert click/touch coords, check collision, pop bubble
popBubble() — spawn particles, remove bubble, increment score
spawnBubble() — create Bubble at random x below bottom edge
spawnParticles()— create Particle burst at popped bubble position
updateHud() — sync score/level/lives text content
};
class Bubble { constructor / update / contains / escaped / render }
class Particle { constructor / update / isDead / render }
document.addEventListener('DOMContentLoaded', () => POP.init());

Remove any temporary test code from Stage 3. Open index.html and verify:

  1. Start screen — “Bubble Pop” title, “Best: 0”, instructions, Play button
  2. Click Play — start screen hides, HUD appears, bubbles begin rising
  3. Pop a bubble — particles burst, score increments, HUD updates
  4. Let bubbles escape — lives decrement; at 0, game ends
  5. Game-over screen — shows final score; “New High Score!” appears if you beat the previous best
  6. Click Play Again — full reset, new game starts
  7. Refresh the page — “Best” on the start screen shows the saved high score

The game is complete, but here are directions to take it further:

  • Sound — use the Web Audio API to play a pop tone on each bubble (a short sine wave burst)
  • Combo multiplier — award bonus points for popping multiple bubbles in quick succession
  • Size-based scoring — smaller bubbles are harder to hit; award more points for them
  • Animated background — draw faint, slow bubbles behind the game canvas as a decorating layer
  • Mobile sizing — scale the #game-container to fit small screens using a CSS transform: scale()

You built a complete browser game from scratch with vanilla HTML, CSS, and JavaScript — no libraries, no build tools. The techniques used here — canvas rendering, the game loop, entity classes, coordinate scaling, and browser storage — appear in games and interactive applications of all sizes.