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.
What you will build
Section titled “What you will build”- The
Particleclass — pop burst effect rendered on canvas POP.startGame()— resets state and transitions to playingPOP.endGame()— checks for a high score and shows the game-over screenPOP.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.
Step 1 — Write the Particle class
Section titled “Step 1 — Write the Particle class”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(); }}How particles work
Section titled “How particles work”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) lifedecrements 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.
Step 2 — Implement spawnParticles()
Section titled “Step 2 — Implement spawnParticles()”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 onesfor (let i = this.particles.length - 1; i >= 0; i--) { this.particles[i].update(); if (this.particles[i].isDead()) this.particles.splice(i, 1);}Step 4 — Implement startGame()
Section titled “Step 4 — Implement startGame()”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.
Step 5 — Implement endGame()
Section titled “Step 5 — Implement endGame()”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';},How localStorage works
Section titled “How localStorage works”localStorage is a key-value store in the browser that persists between sessions. The data survives page refreshes and browser restarts.
// SavelocalStorage.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.
Step 6 — Wire up the buttons in init()
Section titled “Step 6 — Wire up the buttons in init()”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}`;Final pop.js structure
Section titled “Final pop.js structure”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());Checkpoint — full play-through
Section titled “Checkpoint — full play-through”Remove any temporary test code from Stage 3. Open index.html and verify:
- Start screen — “Bubble Pop” title, “Best: 0”, instructions, Play button
- Click Play — start screen hides, HUD appears, bubbles begin rising
- Pop a bubble — particles burst, score increments, HUD updates
- Let bubbles escape — lives decrement; at 0, game ends
- Game-over screen — shows final score; “New High Score!” appears if you beat the previous best
- Click Play Again — full reset, new game starts
- Refresh the page — “Best” on the start screen shows the saved high score
Stretch goals
Section titled “Stretch goals”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-containerto fit small screens using a CSStransform: scale()
Congratulations
Section titled “Congratulations”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.