Stage 4 — Input, Collision, and Scoring
Stage 4 wires up the player. By the end, clicking or tapping a bubble pops it (increments the score), missing too many costs a life, and the difficulty ramps up with each level.
What you will build
Section titled “What you will build”POP.handleInput()— translates click/touch coordinates to canvas spaceBubble.contains()— circle–point collision checkPOP.popBubble()— removes the bubble and increments the score- Lives deduction when bubbles escape the top
- HUD update method
Checkpoint: open the game (with the temporary test code from Stage 3 still in place). Click a bubble — it disappears and “Score: 1” appears in the HUD.
Step 1 — Register event listeners in init()
Section titled “Step 1 — Register event listeners in init()”Add these two lines at the end of init(), after the this.loop() call:
this.canvas.addEventListener('click', (e) => this.handleInput(e));this.canvas.addEventListener('touchstart', (e) => { e.preventDefault(); this.handleInput(e.touches[0]);}, { passive: false });click— fires on mouse click and also on a single tap on mobile (after a ~300 ms delay on older browsers). Registeringtouchstartseparately eliminates that delay on touch devices.e.preventDefault()ontouchstart— stops the browser from also firing theclickevent (which would triggerhandleInputtwice) and prevents scroll/zoom on the canvas.{ passive: false }— required to allowpreventDefault()inside atouchstartlistener; browsers default touch listeners to passive for performance.
Step 2 — Write handleInput()
Section titled “Step 2 — Write handleInput()”The player clicks somewhere on screen. The canvas may be displayed at a different size than its pixel dimensions (480 × 640) due to CSS scaling. You need to convert the screen coordinates to canvas coordinates:
handleInput(e) { if (this.state !== 'playing') return;
const rect = this.canvas.getBoundingClientRect(); const scaleX = this.config.canvas.width / rect.width; const scaleY = this.config.canvas.height / rect.height; const x = (e.clientX - rect.left) * scaleX; const y = (e.clientY - rect.top) * scaleY;
for (let i = this.bubbles.length - 1; i >= 0; i--) { if (this.bubbles[i].contains(x, y)) { this.popBubble(i); break; } }},The coordinate scaling problem
Section titled “The coordinate scaling problem”e.clientX and e.clientY are in viewport pixels. getBoundingClientRect() returns the canvas element’s position and CSS-rendered size. Dividing the canvas pixel width by the CSS width gives a scale factor.
For example, if the canvas pixel width is 480 but the CSS-rendered width is 360 (scaled down for a small screen), scaleX is 480 / 360 = 1.333. A click at CSS x=100 maps to canvas x=133.
Without this scaling, click detection fails on any screen where the canvas is not displayed at exactly 480 × 640 pixels.
Why iterate in reverse?
Section titled “Why iterate in reverse?”The loop checks bubbles from the last index toward zero. Bubbles drawn later (higher index) appear on top. Hitting the topmost visible bubble is more intuitive than hitting one hidden behind it. The break after a hit ensures only one bubble pops per click.
Step 3 — Collision detection in Bubble.contains()
Section titled “Step 3 — Collision detection in Bubble.contains()”You already wrote contains() in Stage 3. Here is why it works:
contains(px, py) { const dx = px - this.x; const dy = py - this.y; return dx * dx + dy * dy <= this.radius * this.radius;}The distance between point (px, py) and circle centre (this.x, this.y) is:
distance = Math.sqrt(dx² + dy²)A point is inside the circle when distance <= radius. Computing Math.sqrt is slower than a multiplication, so the check uses the squared form instead — both sides squared, no square root needed:
dx² + dy² <= radius²The result is identical but faster to compute.
Step 4 — Write popBubble()
Section titled “Step 4 — Write popBubble()”popBubble(index) { const b = this.bubbles[index]; this.spawnParticles(b.x, b.y, b.color); this.bubbles.splice(index, 1); this.score++; this.updateHud();},spawnParticles is written in Stage 5. For now, add a stub so the code runs without errors:
spawnParticles(x, y, color) { // implemented in Stage 5},Step 5 — Deduct a life when a bubble escapes
Section titled “Step 5 — Deduct a life when a bubble escapes”In the update() method, replace the comment // Lives handling comes in Stage 4 with:
this.lives--;this.updateHud();if (this.lives <= 0) { this.endGame(); return;}endGame() is written in Stage 5. Add a stub for now:
endGame() { this.state = 'gameover'; console.log('Game over — score:', this.score);},Step 6 — Write updateHud()
Section titled “Step 6 — Write updateHud()”updateHud() { this.scoreDisplay.textContent = `Score: ${this.score}`; this.levelDisplay.textContent = `Level: ${this.level}`; this.livesDisplay.textContent = `Lives: ${this.lives}`;},Call this at the end of startGame() (which you will write in Stage 5) to set initial HUD values, and from popBubble() and the escaped-bubble path in update().
Checkpoint
Section titled “Checkpoint”With the temporary state = 'playing' code from Stage 3 still in place:
- Open
index.html - Click a bubble — it should disappear and the score should increment
- Let bubbles escape — the lives count should decrement
- Let all 3 lives drain — the console should log “Game over — score: X”
If click is registering but misses the bubble visually, the coordinate scaling is off. Check that getBoundingClientRect() is called inside the handler (not cached at startup, because the page may resize).
What’s next
Section titled “What’s next”In Stage 5 you will add the Particle class, implement the full start/game-over state machine, and save the high score to localStorage.