Files

11 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
03-complete-experience 02 execute 2
03-01
index.html
true
AUD-01
AUD-02
AUD-03
AUD-04
AUD-05
truths artifacts key_links
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
path provides contains
index.html Audio module with WebAudio synthesis, event-triggered playback const Audio = {
from to via pattern
Audio.init() Input._handleKeyDown Called on first keydown before processing input Audio.init
from to via pattern
Audio.play('paddleHit') Physics._checkPaddleCollision / _checkPaddle2Collision Called after collision is confirmed — add at end of each collision method Audio.play.*paddleHit
from to via pattern
Audio.play('wallHit') Physics.update() wall bounce block Called when ball.vy is reversed by wall — add inside both wall bounce if-blocks Audio.play.*wallHit
from to via pattern
Audio.play('score') GameLoop.main() score detection block Called immediately after gs.score1++ or gs.score2++ 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.

<execution_context> @/home/dabit/.claude/get-shit-done/workflows/execute-plan.md @/home/dabit/.claude/get-shit-done/templates/summary.md </execution_context>

@.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:

GameState.soundEnabled  // boolean — true by default; toggled via settings

Input._handleKeyDown additions from Plan 01:

// 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):

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):

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):

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 = {`).
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:

Audio.play('wallHit');

The top-wall block becomes:

if (ball.y - ball.radius < 0) {
  ball.y = ball.radius;
  ball.vy = Math.abs(ball.vy);
  Audio.play('wallHit');
}

The bottom-wall block becomes:

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:

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

<success_criteria>

  • 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) </success_criteria>
After completion, create `.planning/phases/03-complete-experience/03-02-SUMMARY.md`