---
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"`.
grep -c "AudioContext\|webkitAudioContext" index.html && grep -c "isInitialized" index.html && grep -c "soundEnabled" index.html && grep -c "paddleHit\|wallHit\|score" index.html
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.
grep -c "Audio\.play('paddleHit')" index.html && grep -c "Audio\.play('wallHit')" index.html && grep -c "Audio\.play('score')" index.html
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)