--- phase: 03-complete-experience plan: 02 type: execute wave: 2 depends_on: - 03-01 files_modified: - index.html autonomous: true requirements: - AUD-01 - AUD-02 - AUD-03 - AUD-04 - AUD-05 must_haves: truths: - "A mid-range sine tone plays on every paddle hit (both paddles)" - "A higher-pitch snappy tone plays on every wall bounce" - "A low-frequency deep thud plays every time a player scores" - "Page load produces no audio — first keypress initializes the AudioContext" - "Sound Off in settings silences all subsequent audio; Sound On restores it — no game state disruption" artifacts: - path: "index.html" provides: "Audio module with WebAudio synthesis, event-triggered playback" contains: "const Audio = {" key_links: - from: "Audio.init()" to: "Input._handleKeyDown" via: "Called on first keydown before processing input" pattern: "Audio\\.init" - from: "Audio.play('paddleHit')" to: "Physics._checkPaddleCollision / _checkPaddle2Collision" via: "Called after collision is confirmed — add at end of each collision method" pattern: "Audio\\.play.*paddleHit" - from: "Audio.play('wallHit')" to: "Physics.update() wall bounce block" via: "Called when ball.vy is reversed by wall — add inside both wall bounce if-blocks" pattern: "Audio\\.play.*wallHit" - from: "Audio.play('score')" to: "GameLoop.main() score detection block" via: "Called immediately after gs.score1++ or gs.score2++" pattern: "Audio\\.play.*score" --- Add the Audio module with WebAudio synthesized sound effects and wire it to game events. Purpose: Sound is a core part of the game feel. Three distinct tones (paddle, wall, score) provide satisfying feedback. The WebAudio approach satisfies the browser autoplay policy automatically since Audio.init() is called from within the Input keydown handler established in Plan 01. Output: index.html with an Audio module and sound effects firing at paddle hits, wall bounces, and score events — all gated by GameState.soundEnabled. @/home/dabit/.claude/get-shit-done/workflows/execute-plan.md @/home/dabit/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/phases/03-complete-experience/03-CONTEXT.md @.planning/phases/03-complete-experience/03-RESEARCH.md @.planning/phases/03-complete-experience/03-01-SUMMARY.md GameState additions from Plan 01: ```javascript GameState.soundEnabled // boolean — true by default; toggled via settings ``` Input._handleKeyDown additions from Plan 01: ```javascript // Already contains this guard at the top of the handler: if (typeof Audio !== 'undefined' && !Audio.isInitialized) Audio.init(); // Audio.init() will be called on the FIRST keydown event ``` Physics collision methods (unchanged from Phase 2): ```javascript Physics._checkPaddleCollision(ball, paddle) // called when ball.vx < 0 (toward paddle1) Physics._checkPaddle2Collision(ball, paddle) // called when ball.vx > 0 (toward paddle2) // Both return early if no collision; if collision confirmed, code runs after the push-out line ``` Physics.update() wall bounce (unchanged from Phase 2): ```javascript if (ball.y - ball.radius < 0) { ball.y = ball.radius; ball.vy = Math.abs(ball.vy); // ADD: Audio.play('wallHit'); here } if (ball.y + ball.radius > this.height) { ball.y = this.height - ball.radius; ball.vy = -Math.abs(ball.vy); // ADD: Audio.play('wallHit'); here } ``` GameLoop.main() score detection (unchanged from Phase 2): ```javascript if (ball.x + ball.radius < 0) { gs.score2++; // ADD: Audio.play('score'); here gs.gameState = 'scored'; ... } else if (ball.x - ball.radius > Physics.width) { gs.score1++; // ADD: Audio.play('score'); here gs.gameState = 'scored'; ... } ``` Task 1: Add Audio module with WebAudio synthesis index.html Add the Audio module object to index.html between the AI object and the GameLoop object (before `const GameLoop = {`). ```javascript const Audio = { audioContext: null, isInitialized: false, init() { if (this.isInitialized) return; this.audioContext = new (window.AudioContext || window.webkitAudioContext)(); if (this.audioContext.state === 'suspended') { this.audioContext.resume(); } this.isInitialized = true; }, play(eventType) { if (!GameState.soundEnabled || !this.isInitialized || !this.audioContext) return; const ctx = this.audioContext; const now = ctx.currentTime; const osc = ctx.createOscillator(); const gain = ctx.createGain(); if (eventType === 'paddleHit') { // Mid-range tone — recognizable rally sound osc.type = 'sine'; osc.frequency.setValueAtTime(400, now); gain.gain.setValueAtTime(0.1, now); gain.gain.exponentialRampToValueAtTime(0.001, now + 0.15); osc.connect(gain); gain.connect(ctx.destination); osc.start(now); osc.stop(now + 0.15); } else if (eventType === 'wallHit') { // Higher-pitch quick snap — distinct from paddle osc.type = 'triangle'; osc.frequency.setValueAtTime(700, now); gain.gain.setValueAtTime(0.07, now); gain.gain.exponentialRampToValueAtTime(0.001, now + 0.08); osc.connect(gain); gain.connect(ctx.destination); osc.start(now); osc.stop(now + 0.08); } else if (eventType === 'score') { // Deep low-frequency thud — weightier than the other two osc.type = 'sine'; osc.frequency.setValueAtTime(80, now); // Pitch drop adds weight to the thud osc.frequency.exponentialRampToValueAtTime(40, now + 0.2); gain.gain.setValueAtTime(0.15, now); gain.gain.exponentialRampToValueAtTime(0.001, now + 0.3); osc.connect(gain); gain.connect(ctx.destination); osc.start(now); osc.stop(now + 0.3); } } }; ``` **Critical design notes:** - `exponentialRampToValueAtTime` must NOT ramp to exactly 0 (use 0.001 minimum) — exponential ramp to 0 is mathematically invalid and causes audio clicking artifacts (pitfall #5 from RESEARCH.md) - Always call `osc.stop()` to free WebAudio node resources — omitting this causes a memory leak - The `play()` guard `if (!GameState.soundEnabled || !this.isInitialized || !this.audioContext)` covers all three failure modes: settings muted, not yet init'd, context creation failed Open browser DevTools console. Load index.html. Run `console.log(typeof Audio, Audio.isInitialized)` — should print `"object" false`. Press any key. Run `console.log(Audio.isInitialized, Audio.audioContext.state)` — should print `true "running"`. Audio module exists in index.html. isInitialized is false on page load. After first keypress, isInitialized is true and audioContext.state is 'running'. Task 2: Wire Audio.play() calls to game events index.html Add Audio.play() calls at exactly four locations in index.html: **Location 1 — Physics._checkPaddleCollision, after the push-out line:** Find: `ball.x = paddle.x + paddle.width + ball.radius + 1;` After that line, add: `Audio.play('paddleHit');` **Location 2 — Physics._checkPaddle2Collision, after the push-out line:** Find: `ball.x = paddle.x - ball.radius - 1;` After that line, add: `Audio.play('paddleHit');` **Location 3 — Physics.update(), both wall bounce blocks:** Find the two wall bounce if-blocks. In each block, after the `ball.vy` assignment, add: ```javascript Audio.play('wallHit'); ``` The top-wall block becomes: ```javascript if (ball.y - ball.radius < 0) { ball.y = ball.radius; ball.vy = Math.abs(ball.vy); Audio.play('wallHit'); } ``` The bottom-wall block becomes: ```javascript if (ball.y + ball.radius > this.height) { ball.y = this.height - ball.radius; ball.vy = -Math.abs(ball.vy); Audio.play('wallHit'); } ``` **Location 4 — GameLoop.main() score detection block:** Find the two scoring branches (ball.x + ball.radius < 0 and ball.x - ball.radius > Physics.width). In each branch, immediately after the `gs.score2++` or `gs.score1++` line, add: ```javascript Audio.play('score'); ``` No other changes. Do NOT add Audio.play() calls anywhere else. The Input handler's Audio.init() guard was added by Plan 01 — verify it is present (`if (typeof Audio !== 'undefined' && !Audio.isInitialized) Audio.init();` at the top of _handleKeyDown). Open index.html. Start a game (title → play → mode select → AI mode → playing). Verify: 1. Paddle hit: rally the ball against the AI paddle — hear a mid-tone ping each time it hits either paddle 2. Wall hit: let ball bounce off top/bottom wall — hear a distinct higher-pitch snap (different from paddle) 3. Score: let a point be scored — hear a low deep thud (clearly different weight from other sounds) 4. Sound off: go to Settings, set Sound to OFF, play a game — no sounds. Set Sound to ON — sounds return. 5. No audio on page load (before first keypress): DevTools > Console > `Audio.audioContext` should be null before pressing any key. Audio.play('paddleHit') fires on paddle1 and paddle2 collisions. Audio.play('wallHit') fires on top and bottom wall bounces. Audio.play('score') fires on each point scored. Toggling soundEnabled via settings correctly mutes/unmutes. AudioContext is null until first keydown. Full audio verification checklist: 1. Page load: DevTools console, `Audio.audioContext === null` — confirms AUD-04 (no audio init at load) 2. First keydown: `Audio.audioContext.state === 'running'` — confirms autoplay policy satisfied 3. Start a rally: distinct sounds on paddle hit vs wall bounce (AUD-01, AUD-02) 4. Score a point: deep thud plays (AUD-03) 5. Settings → Sound OFF → play game → silence; Settings → Sound ON → sounds return (AUD-05) 6. No clicking or popping artifacts in any sound - Three synthesized sounds are clearly distinct (pitch and duration differ between paddle, wall, score) - AudioContext initializes only after first user input (AUD-04 satisfied) - soundEnabled flag in settings reliably gates all audio (AUD-05 satisfied) - No audio-related memory leaks (osc.stop() called on all oscillators) - Sound toggle works without affecting game state (score, paddles, ball position unchanged after toggle) After completion, create `.planning/phases/03-complete-experience/03-02-SUMMARY.md`