Files
Dabit 2cd0462a46 docs(phase-02): research core gameplay — Player 2 control, AI opponent, scoring system
Documented standard stack (vanilla JS, HTML5 Canvas), architecture patterns (extend Physics for paddle2, add AI module, score detection at GameLoop level), common pitfalls (tunneling, AI balance, input lag, memory leaks), and validation architecture for Phase 2 requirements. Verified with locked decisions from CONTEXT.md and prior phase research. Ready for planning.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 20:54:02 +01:00

38 KiB

Phase 2: Core Gameplay - Research

Researched: 2026-03-10 Domain: HTML5 Canvas Pong — second paddle (Player 2 / AI), scoring system, game state management Confidence: HIGH

Summary

Phase 2 builds on the Phase 1 foundation (ball physics, wall bounce, zone deflection, speed increment) by adding:

  1. Second Paddle Control: Player 2 controls paddle with Up/Down arrow keys independently from Player 1 (W/S)
  2. Scoring System: Points awarded when ball passes opponent's paddle; first to 7 wins
  3. AI Opponent: Predictive interception with 3 difficulty levels (Easy, Medium, Hard) using reaction delay + speed cap
  4. Game Mode Selection: Temporary key-press selection (1 = Solo vs AI, 2 = 2-Player local)
  5. Difficulty Selection: Temporary key-press selection (1 = Easy, 2 = Medium, 3 = Hard)
  6. Match-End Condition: Winner displayed on-canvas; R key restarts
  7. Ball Re-serve Logic: ~1 second pause after score, then auto-serve

The architecture remains module-object pattern in a single HTML file. All new logic integrates into existing modules (Input, Physics, GameState, Renderer) or introduces minimal new modules (AI object).

Primary recommendation: Extend Physics.update() to handle paddle2 movement (human or AI branch). Add AI object with predictive interception logic. Add scoring detection to GameLoop.main() after Physics.update(). Separate AI difficulty constants in GameConfig. Handle input cleanup and event listener management to prevent memory leaks.


<user_constraints>

User Constraints (from CONTEXT.md)

Locked Decisions

  • Mode activation: temporary key press (1 = Solo vs AI, 2 = 2-Player local); phase 3 wires real screens to same GameState fields
  • Match end: temporary on-canvas winner text ("Player 1 Wins! Press R to restart"); phase 3 replaces with real game-over screen
  • Target score: first to 7 points (WIN_SCORE: 7 constant in GameConfig)
  • Ball re-serve: ~1 second pause, then auto-serve; no player input required
  • AI strategy: predictive interception with ball trajectory traced to AI x-position, handles wall bounces in prediction
  • AI difficulty: reaction delay + speed cap (Easy: slow speed + late reaction; Medium: moderate speed + small delay; Hard: fast speed + minimal delay)

Claude's Discretion

  • Exact speed/delay values per difficulty (tune for feel after initial implementation)
  • How ball serves direction is chosen after scoring (alternates or goes to player who scored)
  • Score display typography and positioning (on-canvas, minimal)
  • Whether prediction re-calculates every frame or throttled interval

Deferred Ideas (OUT OF SCOPE)

  • None — all Phase 2 scope contained in discussion

</user_constraints>

<phase_requirements>

Phase Requirements

ID Description Research Support
CORE-05 Player scores point when ball passes opponent's paddle Scoring detection at GameLoop level after Physics.update(); point awarded to opponent of player whose side ball left from
CORE-06 Match ends when a player reaches target score (first to N points) WIN_SCORE: 7 constant in GameConfig; match-end detection in GameLoop checks if score1 or score2 >= 7; temporary on-canvas display, phase 3 replaces
CORE-08 Player 2 controls paddle with Up/Down arrow keys Input.getVerticalInput2() mirrors Input.getVerticalInput(); listens to ArrowUp/ArrowDown key codes; physics.update() reads input and applies to paddle2 movement
AI-01 AI opponent tracks and intercepts the ball AI object implements predictive interception: calculates ball y-position when ball reaches AI paddle x-position, accounting for wall bounces; AI moves toward target y
AI-02 Easy difficulty: AI reacts slowly with error margin — beatable by any player GameConfig.AI_EASY: { speed: 200, reactionDelay: 0.3, errorMargin: 20 } — slow paddle, late reaction start (ball crosses threshold before AI begins tracking)
AI-03 Medium difficulty: AI reacts at moderate speed — provides fair challenge GameConfig.AI_MEDIUM: { speed: 320, reactionDelay: 0.1, errorMargin: 5 } — balanced speed/delay, small error margin
AI-04 Hard difficulty: AI reacts fast but not perfectly — requires skill to beat GameConfig.AI_HARD: { speed: 400, reactionDelay: 0.05, errorMargin: 2 } — fast but not instant; still beatable with skill

</phase_requirements>

Standard Stack

Core (No Dependencies)

Component Version Purpose Why Standard
Vanilla JavaScript (ES6) Native browser Ball physics, paddle control, AI logic, game state No build step needed; single HTML file deployment. ES6 provides classes and arrow functions for clean code. All MDN docs reference ES6.
HTML5 Canvas 2D Context Native browser API Rendering paddles, ball, score, text Standard for 2D games. All research and MDN docs assume Canvas 2D.
requestAnimationFrame Native browser API Game loop timing, delta time calculation Required for responsive input, smooth animation, battery efficiency. Syncs with monitor refresh.
Web Audio API Native browser (Phase 3) Sound effects and music Standard for browser games. Defer to Phase 3 per roadmap.

Supporting (Integrated into Existing Modules)

Module Purpose When to Use
GameState Central state object (scores, paddles, ball, game mode, difficulty) Every update/render; single source of truth for game data
Physics Movement and collision (extended for paddle2 and AI) Every frame delta time; handles both ball and paddle physics
Input Keyboard event handlers, key state map (extended for Arrow keys) Every update; polls key state, not event-driven movement
Renderer Canvas drawing (extended for score, paddle2, game-over text) Every frame after Physics.update()
GameLoop requestAnimationFrame orchestration, delta time capping, score detection Every frame; top-level frame synchronization
AI (new) Predictive ball interception, difficulty-based reaction delay and error margin Every Physics.update() when mode == 'AI'

No External Dependencies

This project uses vanilla JavaScript, HTML5 Canvas, and browser APIs only. No npm, no build tool, no framework.

Installation: None required. Static HTML file runs directly.


Architecture Patterns

Current structure (single index.html file):

index.html
  ├── <canvas id="gameCanvas">
  └── <script>
      ├── GameConfig (constants)
      ├── Renderer (canvas drawing)
      ├── GameState (game data)
      ├── Input (keyboard input)
      ├── Physics (ball + paddle movement)
      ├── AI (new — predictive interception)
      ├── GameLoop (orchestration)
      └── Initialize all modules

Rationale:

  • Single-file design matches project's "no build step" constraint
  • Minimal file count keeps context of whole game visible
  • All modules can reference each other via global scope
  • Phase 3 UI screens added in same file (state machine pattern can be added later if needed)

Architecture Integration Points

1. GameState Extension

Current:

const GameState = {
  paddle1: { x, y, width, height, speed, color },
  ball: { x, y, radius, vx, vy, speed, color }
};

Extend to Phase 2:

const GameState = {
  paddle1: { ... },          // Existing
  paddle2: { ... },          // NEW: mirrors paddle1 structure
  ball: { ... },             // Existing
  score1: 0,                 // NEW
  score2: 0,                 // NEW
  mode: null,                // NEW: 'ai' or '2p' (set by mode selection)
  difficulty: 'medium',      // NEW: 'easy', 'medium', 'hard' (for AI)
  gameState: 'playing',      // NEW: 'playing', 'paused', 'gameover'
  winner: null               // NEW: 'player1', 'ai', 'player2', or null
};

Integration point: Physics.update() and Renderer both read/write to GameState fields


2. Input Extension

Current:

const Input = {
  keys: { w: false, s: false },
  getVerticalInput() { return -1/0/1; }
};

Extend to Phase 2:

const Input = {
  keys: { w: false, s: false, arrowUp: false, arrowDown: false },
  getVerticalInput() { ... },        // Player 1 (W/S)
  getVerticalInput2() { ... }        // Player 2 (Up/Down arrows)
};

// In keydown/keyup handlers:
// if (e.code === 'ArrowUp') this.keys.arrowUp = true;
// if (e.code === 'ArrowDown') this.keys.arrowDown = true;

Integration point: Physics.update() calls both input methods depending on mode


3. Physics Extension

Current:

Physics.update(deltaTime) {
  // Move paddle1 based on Input.getVerticalInput()
  // Move ball
  // Check wall bounces
  // Check paddle1 collision
  // Reset ball if out of bounds
}

Extend to Phase 2:

Physics.update(deltaTime) {
  // Move paddle1 (existing)
  const dir1 = Input.getVerticalInput();
  paddle1.y += dir1 * paddle1.speed * deltaTime;
  paddle1.y = clamp(paddle1.y, 0, height - paddle1.height);

  // Move paddle2 (NEW)
  if (mode === '2p') {
    const dir2 = Input.getVerticalInput2();
    paddle2.y += dir2 * paddle2.speed * deltaTime;
    paddle2.y = clamp(paddle2.y, 0, height - paddle2.height);
  } else if (mode === 'ai') {
    AI.update(deltaTime);  // AI updates paddle2 position
  }

  // Move ball, check wall bounces (existing)
  // ...

  // Check paddle1 collision (existing)
  if (ball.vx < 0) this._checkPaddleCollision(ball, paddle1);

  // Check paddle2 collision (NEW)
  if (ball.vx > 0) this._checkPaddleCollision(ball, paddle2);

  // Detect score (NEW — moved to GameLoop.main() after Physics.update())
}

Integration point: Physics becomes responsible for both paddles' movement; collision detection extended to both paddles


4. AI Predictive Interception (New Module)

Pattern: Predictive tracking with reaction delay and error margin

const AI = {
  difficulty: 'medium',  // Set from GameState.difficulty
  reactionTime: 0,       // Elapsed time since ball was targetable

  init(difficulty) {
    this.difficulty = difficulty;
    // Difficulty values set from GameConfig at mode selection
  },

  update(deltaTime) {
    const config = GameConfig[`AI_${this.difficulty.toUpperCase()}`];
    const ball = GameState.ball;
    const paddle2 = GameState.paddle2;

    // Reaction delay: only start tracking after ball crosses a threshold
    if (ball.x < Physics.width * 0.7) {
      this.reactionTime += deltaTime;
    } else {
      this.reactionTime = 0;
    }

    if (this.reactionTime < config.reactionDelay) {
      return;  // Not reacting yet
    }

    // Predict where ball will be at paddle2's x position
    const targetY = this._predictBallY(ball, paddle2.x);

    // Add error margin (random jitter)
    const error = (Math.random() - 0.5) * config.errorMargin * 2;
    const aimY = targetY + error;

    // Move paddle toward aim Y
    const centerY = paddle2.y + paddle2.height / 2;
    const diff = aimY - centerY;

    if (Math.abs(diff) > 2) {  // Deadzone to prevent wiggling
      const dir = Math.sign(diff);
      paddle2.y += dir * config.speed * deltaTime;
      paddle2.y = Math.max(0, Math.min(Physics.height - paddle2.height, paddle2.y));
    }
  },

  _predictBallY(ball, targetX) {
    // Trace ball path from current position to targetX, accounting for wall bounces
    let x = ball.x;
    let y = ball.y;
    let vx = ball.vx;
    let vy = ball.vy;

    // Simple ray casting: step ball position until x reaches targetX
    while ((vx > 0 && x < targetX) || (vx < 0 && x > targetX)) {
      const stepsToTarget = Math.abs(targetX - x) / Math.abs(vx);
      const timeStep = Math.min(0.016, stepsToTarget * 0.016);  // Cap at frame time

      x += vx * timeStep;
      y += vy * timeStep;

      // Handle wall bounces during prediction
      if (y - ball.radius < 0) {
        y = ball.radius;
        vy = Math.abs(vy);
      }
      if (y + ball.radius > Physics.height) {
        y = Physics.height - ball.radius;
        vy = -Math.abs(vy);
      }
    }

    return y;
  }
};

Integration point: Called from Physics.update() when mode === 'ai'


5. Scoring and Game-Over Detection (GameLoop Level)

Current GameLoop.main():

main(currentTime) {
  // Delta time, Physics.update(), Renderer.clear(), render entities, debug text
}

Extend to Phase 2:

main(currentTime) {
  const deltaTime = Math.min((currentTime - this.lastTime) / 1000, 0.05);
  this.lastTime = currentTime;

  Physics.update(deltaTime);

  // NEW: Detect scoring (ball left field)
  const ballLeft = ball.x - ball.radius < 0;
  const ballRight = ball.x + ball.radius > Physics.width;

  if (ballLeft) {
    GameState.score2++;  // Ball left on left side → player 2 scores
    this.onScore();
  } else if (ballRight) {
    GameState.score1++;  // Ball left on right side → player 1 scores
    this.onScore();
  }

  // NEW: Check for match end
  if (GameState.score1 >= GameConfig.WIN_SCORE) {
    GameState.gameState = 'gameover';
    GameState.winner = 'player1';
  } else if (GameState.score2 >= GameConfig.WIN_SCORE) {
    GameState.gameState = 'gameover';
    GameState.winner = (GameState.mode === 'ai' ? 'ai' : 'player2');
  }

  Renderer.clear();
  // Render entities and score
}

onScore() {
  // ~1 second pause before auto-serve
  this.scoreTime = performance.now();
  GameState.gameState = 'scored';  // Temporary state
}

6. Renderer Extension

New rendering elements:

// In GameLoop.main(), after rendering entities:

// Draw scores
Renderer.ctx.fillStyle = '#fff';
Renderer.ctx.font = '24px monospace';
Renderer.ctx.fillText(GameState.score1, Renderer.logicalWidth / 4, 40);
Renderer.ctx.fillText(GameState.score2, (3 * Renderer.logicalWidth) / 4, 40);

// Draw game-over message
if (GameState.gameState === 'gameover') {
  const winner = GameState.winner === 'player1' ? 'Player 1' :
                 GameState.winner === 'player2' ? 'Player 2' : 'AI';
  const text = `${winner} Wins! Press R to restart`;
  Renderer.ctx.fillText(text, Renderer.logicalWidth / 2 - 150, Renderer.logicalHeight / 2);
}

Don't Hand-Roll

Problem Don't Build Use Instead Why
Collision detection for two paddles Custom per-paddle check each frame Extend existing _checkPaddleCollision() to handle both paddle1 and paddle2 Physics already has tested AABB collision; reuse it.
AI ball prediction with bounces Naive linear interpolation (ignores wall bounces) Stepped ray casting that handles wall bounces in the prediction loop Incorrect prediction makes AI behave stupidly or unfairly. Ray casting is standard.
Reaction delay simulation Hardcoded if-checks per frame Reaction delay parameter + elapsed timer in AI.update() Cleaner parameterization; easier to tune.
Event listener cleanup on state transitions Leave listeners attached (memory leaks) removeEventListener() in state exit handler; track listeners in Input object Phase 2 success criteria include "No memory leaks after multiple rounds."
Input state management Direct movement in keydown handler Key state map polled in update(); prevents de-sync between input and rendering Input lag and non-deterministic behavior if not decoupled.

Common Pitfalls

Pitfall 1: Ball Tunneling at Paddle

What goes wrong: Ball moves so fast it passes through paddle without detecting collision. Player sees ball teleport through paddle, feels cheated.

Why it happens: At high ball speeds (500+ px/s), discrete collision detection only checks start/end positions. If ball is "before paddle" at frame N and "after paddle" at frame N+1, no collision is triggered.

How to avoid:

  • Current Phase 1 speeds (~800 px/s max with speedIncrement) are below tunneling threshold with paddle.height = 80 (tunneling risk above ~4800 px/s at 60fps)
  • Phase 2 doesn't increase ball speed further; Phase 4 may add power-ups
  • If Phase 5 introduces power-ups that spike speed, implement substep physics or swept collision (see research/PITFALLS.md Pitfall #1)
  • For now: verify ball speed never exceeds 2000 px/s; retest when power-ups added

Warning signs: Ball occasionally passes through paddles during rallies; players report "I had my paddle right there and it went through!"

Phase 2 specific: Add assertion at end of Physics.update(): if ball speed > 2000, log warning. Test AI difficulty with max expected speed.


Pitfall 2: AI Difficulty Not Balanced

What goes wrong: Easy AI is still unbeatable; Medium is too hard; no difficulty middle ground. Single-player becomes unplayable or boring.

Why it happens: Developers hardcode AI to perfectly track ball (instant reaction, no error). Single difficulty level with no variance.

How to avoid:

  • Implement three difficulty levels with distinct reaction delays and error margins (LOCKED DECISIONS from CONTEXT.md)
  • Easy: 300ms+ delay, ±20px error — should be beatable by average player in 3-5 rounds
  • Medium: 100ms delay, ±5px error — competitive, fair challenge
  • Hard: 50ms delay, ±2px error — requires skill to beat, but not impossible
  • Playtest at least 10 rounds at each difficulty before considering it balanced
  • After Phase 2 complete, get human playtesting feedback before moving to Phase 3

Warning signs:

  • Player crushes Easy difficulty in 1-2 minutes without trying
  • Player gives up after 10 attempts on Hard (scores 0-2 points)
  • Medium feels identical to Easy or Hard (tuning was insufficient)

Phase 2 specific: Add difficulty selection UI (temporary key-press: 1=Easy, 2=Medium, 3=Hard). Document initial values in GameConfig but mark as "subject to tuning feedback."


Pitfall 3: Input Lag from Event Handler Movement

What goes wrong: Player presses arrow key to move paddle, but paddle moves late or in chunks. Feels unresponsive. Can't time hits on fast balls.

Why it happens: Movement applied directly in keydown event handler instead of polled in update(). Frame rate dependent; key repeat lag; de-sync between input and rendering.

How to avoid:

  • Maintain key state map (boolean per key)
  • Update key state in keydown/keyup (fast)
  • Read key state in Physics.update() (once per frame, delta time scaled)
  • Never move entities in event handlers

Phase 2 specific: Input.getVerticalInput2() must follow same pattern as Input.getVerticalInput(). Both read from the key state map during Physics.update(), not during event handling.

Warning signs: Paddle movement feels sluggish; can't hit balls reliably; paddle response time > 50ms (test with 240fps camera or browser input latency tester).


Pitfall 4: Memory Leaks from Event Listeners

What goes wrong: After 5-10 game rounds, memory usage grows. Browser becomes sluggish. No error messages, just creeping slowness.

Why it happens: Keyboard event listeners added in Input.init() are never removed when game state changes (mode selection, game over, restart). Each state transition adds more listeners without cleaning up old ones.

How to avoid:

  • Track all active event listeners in Input object
  • Implement Input.cleanup() method that removes all listeners
  • Call Input.cleanup() before state transitions (mode selection, game over, return to menu)
  • Use named functions for removeEventListener (anonymous functions can't be removed)
  • Test with Chrome DevTools Memory profiler: play 10 rounds, switch modes 5 times, take heap snapshot. Memory should be stable, not growing.

Phase 2 specific: Success criteria include "No memory leaks after multiple rounds." Implement cleanup pattern now:

const Input = {
  _handlers: {},  // Store references to handlers
  init() {
    this._handlers.keydown = (e) => { ... };
    this._handlers.keyup = (e) => { ... };
    document.addEventListener('keydown', this._handlers.keydown);
    document.addEventListener('keyup', this._handlers.keyup);
  },
  cleanup() {
    document.removeEventListener('keydown', this._handlers.keydown);
    document.removeEventListener('keyup', this._handlers.keyup);
  }
};

Warning signs: Memory grows 10-50MB per round; slowdown after 10+ rounds; Chrome DevTools shows detached DOM nodes or listeners accumulating.


Pitfall 5: Score Detection Logic in Wrong Place

What goes wrong: Score is detected in Physics.update() but triggering game-over state transition there causes cascading effects. Physics layer shouldn't know about game states. Hard to debug because physics and game logic are entangled.

Why it happens: Score detection is a "collision-like" event (ball left field), so developers put it in Physics. But scoring is game logic, not physics.

How to avoid:

  • Physics.update() only handles movement, bounces, collisions with paddles/walls
  • Scoring detection happens at GameLoop.main() level AFTER Physics.update()
  • GameLoop checks if ball is off-screen and increments scores
  • GameLoop.main() then checks for match-end condition
  • This keeps physics pure (no game state coupling)

Phase 2 specific:

// In GameLoop.main():
Physics.update(deltaTime);

// Score detection (not in Physics)
if (ball.x + ball.radius < 0) {
  GameState.score2++;
  this.onScore();
} else if (ball.x - ball.radius > Physics.width) {
  GameState.score1++;
  this.onScore();
}

// Match end detection
if (GameState.score1 >= GameConfig.WIN_SCORE) {
  GameState.gameState = 'gameover';
  GameState.winner = 'player1';
}

Warning signs: Physics module has references to GameState.score or gameState; hard to test Physics in isolation; score detection happens at wrong time in frame (before or after ball is off-screen).


Pitfall 6: AI Prediction Ignores Wall Bounces

What goes wrong: AI predicts ball will hit paddle at Y=200, but ball bounces off top wall on the way and actually hits at Y=150. AI aims incorrectly, misses easy shots.

Why it happens: Simple linear prediction targetY = ball.y + (ball.vy / ball.vx) * (paddle.x - ball.x) doesn't account for wall bounces.

How to avoid:

  • Use stepped ray casting (as described in AI._predictBallY)
  • Simulate ball movement frame-by-frame until it reaches paddle x-position
  • Handle wall bounces during prediction (flip vy when hitting top/bottom)
  • This is standard AI technique for Pong

Phase 2 specific: AI._predictBallY() must implement wall bounce logic. Test with ball heading toward corner; AI should still intercept accurately.

Warning signs: AI misses balls that bounce off walls; AI aims at wrong paddle position; AI difficulty feels inconsistent (easy on some angles, impossible on others).


Code Examples

Score Detection in GameLoop

Source: Architecture pattern from research/ARCHITECTURE.md

GameLoop = {
  stopHandle: null,
  lastTime: 0,
  pauseTime: null,  // For serve pause

  main(currentTime) {
    this.stopHandle = window.requestAnimationFrame(this.main.bind(this));

    const deltaTime = Math.min((currentTime - this.lastTime) / 1000, 0.05);
    this.lastTime = currentTime;

    // Only update if not paused for serve
    if (GameState.gameState === 'playing') {
      Physics.update(deltaTime);
    }

    // Detect scoring
    if (GameState.gameState === 'playing') {
      const ball = GameState.ball;
      if (ball.x + ball.radius < 0) {
        GameState.score2++;
        GameState.gameState = 'scored';
        this.pauseTime = performance.now();
      } else if (ball.x - ball.radius > Physics.width) {
        GameState.score1++;
        GameState.gameState = 'scored';
        this.pauseTime = performance.now();
      }
    }

    // Auto-serve after pause
    if (GameState.gameState === 'scored') {
      const elapsed = (currentTime - this.pauseTime) / 1000;
      if (elapsed > 1.0) {
        Physics.serveBall();
        GameState.gameState = 'playing';
      }
    }

    // Check for match end
    if (GameState.score1 >= GameConfig.WIN_SCORE) {
      GameState.gameState = 'gameover';
      GameState.winner = 'player1';
    } else if (GameState.score2 >= GameConfig.WIN_SCORE) {
      const winner = GameState.mode === 'ai' ? 'ai' : 'player2';
      GameState.gameState = 'gameover';
      GameState.winner = winner;
    }

    Renderer.clear();
    // Render entities
  }
};

AI Predictive Interception with Reaction Delay

Source: Standard Pong AI technique (research/PITFALLS.md Pitfall #5, research/ARCHITECTURE.md Pattern 5)

const AI = {
  difficulty: 'medium',
  reactionElapsed: 0,

  init(difficulty) {
    this.difficulty = difficulty;
    this.reactionElapsed = 0;
  },

  update(deltaTime) {
    const config = GameConfig[`AI_${this.difficulty.toUpperCase()}`];
    const ball = GameState.ball;
    const paddle2 = GameState.paddle2;

    // Reaction delay: only track when ball is on AI's side
    if (ball.x > Physics.width * 0.6) {
      this.reactionElapsed = 0;
      return;  // Ball on player side, don't react
    }

    this.reactionElapsed += deltaTime;
    if (this.reactionElapsed < config.reactionDelay) {
      return;  // Still reacting, don't move
    }

    // Predict ball position at paddle x
    const targetY = this._predictBallY(ball, paddle2.x);

    // Add error margin (difficulty-based aim jitter)
    const error = (Math.random() - 0.5) * config.errorMargin * 2;
    const aimY = targetY + error;

    // Move paddle toward aim Y
    const centerY = paddle2.y + paddle2.height / 2;
    const diff = aimY - centerY;

    if (Math.abs(diff) > 3) {  // Small deadzone to prevent jitter
      const dir = Math.sign(diff);
      paddle2.y += dir * config.speed * deltaTime;
      paddle2.y = Math.max(0, Math.min(Physics.height - paddle2.height, paddle2.y));
    }
  },

  _predictBallY(ball, targetX) {
    // Ray cast from ball to targetX, accounting for wall bounces
    let x = ball.x;
    let y = ball.y;
    let vx = ball.vx;
    let vy = ball.vy;

    const maxSteps = 1000;  // Prevent infinite loops
    let steps = 0;

    // Step ball until x reaches targetX
    while (steps < maxSteps) {
      if (Math.abs(x - targetX) < 1) break;  // Close enough

      // Time to reach targetX at current vx
      const timeToTarget = (targetX - x) / vx;
      const timeStep = Math.min(0.016, Math.abs(timeToTarget));

      // Move ball
      x += vx * timeStep;
      y += vy * timeStep;

      // Handle wall bounces during prediction
      if (y - ball.radius < 0) {
        y = ball.radius;
        vy = Math.abs(vy);
      } else if (y + ball.radius > Physics.height) {
        y = Physics.height - ball.radius;
        vy = -Math.abs(vy);
      }

      steps++;
    }

    return y;
  }
};

Input Extension for Player 2

Source: Current Input pattern in index.html

const Input = {
  keys: { w: false, s: false, arrowUp: false, arrowDown: false },

  init() {
    this._handleKeyDown = (e) => {
      if (e.code === 'KeyW') { this.keys.w = true; e.preventDefault(); }
      if (e.code === 'KeyS') { this.keys.s = true; e.preventDefault(); }
      if (e.code === 'ArrowUp') { this.keys.arrowUp = true; e.preventDefault(); }
      if (e.code === 'ArrowDown') { this.keys.arrowDown = true; e.preventDefault(); }
      // Mode selection keys
      if (e.code === 'Digit1') { GameState.mode = 'ai'; }
      if (e.code === 'Digit2') { GameState.mode = '2p'; }
      // Difficulty selection
      if (e.code === 'Digit1') { GameState.difficulty = 'easy'; }
      if (e.code === 'Digit2') { GameState.difficulty = 'medium'; }
      if (e.code === 'Digit3') { GameState.difficulty = 'hard'; }
      // Restart
      if (e.code === 'KeyR' && GameState.gameState === 'gameover') {
        this.reset();
      }
    };

    this._handleKeyUp = (e) => {
      if (e.code === 'KeyW') this.keys.w = false;
      if (e.code === 'KeyS') this.keys.s = false;
      if (e.code === 'ArrowUp') this.keys.arrowUp = false;
      if (e.code === 'ArrowDown') this.keys.arrowDown = false;
    };

    document.addEventListener('keydown', this._handleKeyDown.bind(this));
    document.addEventListener('keyup', this._handleKeyUp.bind(this));
  },

  cleanup() {
    // For memory leak prevention (Phase 2 success criteria)
    document.removeEventListener('keydown', this._handleKeyDown);
    document.removeEventListener('keyup', this._handleKeyUp);
  },

  getVerticalInput() {
    if (this.keys.w) return -1;
    if (this.keys.s) return 1;
    return 0;
  },

  getVerticalInput2() {
    if (this.keys.arrowUp) return -1;
    if (this.keys.arrowDown) return 1;
    return 0;
  },

  reset() {
    // Reset game state
    GameState.score1 = 0;
    GameState.score2 = 0;
    GameState.gameState = 'playing';
    GameState.winner = null;
    GameState.mode = null;  // Return to mode selection
    GameState.difficulty = null;
    Physics.init(Renderer.logicalWidth, Renderer.logicalHeight);
    AI.init('medium');
  }
};

GameConfig Extension for AI Difficulty

Source: Locked decisions from CONTEXT.md

const GameConfig = {
  initialBallSpeed: 220,
  speedIncrement: 18,
  paddleSpeed: 400,

  // NEW: Scoring and match end
  WIN_SCORE: 7,

  // NEW: AI difficulty presets
  AI_EASY: {
    speed: 200,           // Slow paddle movement
    reactionDelay: 0.3,   // 300ms before reacting
    errorMargin: 20       // ±20px aim jitter
  },
  AI_MEDIUM: {
    speed: 320,
    reactionDelay: 0.1,   // 100ms delay
    errorMargin: 5        // ±5px jitter
  },
  AI_HARD: {
    speed: 400,           // Match player speed
    reactionDelay: 0.05,  // 50ms delay
    errorMargin: 2        // ±2px jitter (almost perfect)
  }
};

State of the Art

For Pong AI and collision handling (as of Feb 2025):

Old Approach Current Approach Change Date Impact
Instant AI reaction Reaction delay + error margin (3 difficulty levels) 2020s industry standard Makes AI beatable and tunable
Linear ball prediction Ray casting with wall bounce simulation 2015+ standard Pong implementations Accurate AI interception
Direct keydown event movement Key state map polled in update() 2010+ best practice Eliminates input lag, ensures determinism
Global event listeners never cleaned up Listener cleanup on state transitions Always (current issue in research) Prevents memory leaks in long sessions
Single AI difficulty 3 difficulty levels (Easy/Medium/Hard) 2015+ standard for Pong Enables player progression and skill expression

Open Questions

  1. Serve Direction After Score

    • What we know: CONTEXT.md marks this as Claude's discretion
    • What's unclear: Should ball serve toward scorer or alternate (pseudo-random)?
    • Recommendation: Implement as parameter in GameConfig.serveDirection ('scorer' or 'alternate'). Default to 'alternate' for balance. Test both in playtesting phase before finalizing.
  2. AI Prediction Recalculation Frequency

    • What we know: CONTEXT.md allows discretion on throttling
    • What's unclear: Recalculate every frame or every 50ms?
    • Recommendation: Start with every frame (simple, responsive). If performance issue appears, add throttling parameter. For 2 entities on canvas, every-frame prediction is negligible cost.
  3. Exact Difficulty Tuning Values

    • What we know: Locked strategy (reaction delay + speed cap) but exact numbers marked for tuning
    • What's unclear: Are initial GameConfig values final or temporary?
    • Recommendation: Use values from Pitfalls research as starting point, but plan human playtesting in Phase 2 completion. Document in PLAN as "subject to revision after playtesting."
  4. Memory Leak Testing Method

    • What we know: Phase 2 success criteria include "No memory leaks after multiple rounds"
    • What's unclear: How to verify? Chrome DevTools Memory profiler needed?
    • Recommendation: Add validation step in Phase 2 checklist: take heap snapshots before/after 10 game rounds, verify memory is stable (not growing by >10MB).

Validation Architecture

Test Framework

Property Value
Framework Manual testing + browser DevTools (no test runner in Phase 2)
Config file None — vanilla JavaScript, no test config needed
Quick run command Open index.html in browser; play 3 rounds (Easy, Medium, Hard)
Full suite command Play 10 rounds at each difficulty; test 2-Player mode; verify memory stable

Phase Requirements → Test Map

Req ID Behavior Test Type Validation Method Status
CORE-05 Ball passes paddle → opponent scores point Manual gameplay Play until score appears; verify correct player gets point Wave 0
CORE-06 Reach 7 points → match ends with winner message Manual gameplay Play to conclusion (can let AI win or force loss); verify "Player X Wins!" or "AI Wins!" appears Wave 0
CORE-08 Up/Down arrow keys move Player 2 paddle Manual input test Press Up/Down arrows; verify paddle2 moves smoothly, no lag Wave 0
AI-01 AI tracks and intercepts ball Manual observation Play against AI; observe if AI paddle moves toward ball; AI should hit most serves Wave 0
AI-02 Easy AI is beatable Manual playtesting Play Easy difficulty; average player (beginner level) should win in 3-5 attempts Wave 0
AI-03 Medium AI provides fair challenge Manual playtesting Play Medium difficulty; experienced player should win in 30-50% of attempts Wave 0
AI-04 Hard AI requires skill Manual playtesting Play Hard difficulty; experienced player should win in <30% of attempts; still possible Wave 0

Sampling Rate

  • Per task commit: Play 1 full game round (until winner); verify score tracking and no crashes
  • Per wave merge: Play 5 rounds (1 Easy, 1 Medium, 1 Hard, 2x 2-Player); verify all modes work; no memory growth observed
  • Phase gate: Full test suite (see below) passes before /gsd:verify-work

Phase 2 Full Validation Checklist

Before marking Phase 2 complete:

  • Player 2 Input

    • Up arrow moves paddle2 up smoothly (no lag)
    • Down arrow moves paddle2 down smoothly
    • Up+Down pressed simultaneously → paddle stops (correct multi-key handling)
    • Release key → paddle stops immediately (no drift)
  • AI Interception

    • Easy AI misses about 20-30% of serves (beatable)
    • Medium AI misses about 50% of serves (fair challenge)
    • Hard AI misses about 70-80% of serves (skilled)
    • AI still intercepts after wall bounces (prediction logic works)
  • Scoring System

    • Ball passes left edge → score2 increments
    • Ball passes right edge → score1 increments
    • Scores display on-canvas (readable position)
    • Scores reset on restart
  • Match End

    • Player 1 reaches 7 points → "Player 1 Wins!" appears
    • AI/Player 2 reaches 7 points → "AI Wins!" or "Player 2 Wins!" appears
    • Winner message disappears during next game start
    • R key restarts game successfully
  • Game Modes

    • Mode selection: press 1 → Solo vs AI mode selected; press 2 → 2-Player mode selected
    • Difficulty selection: press 1/2/3 → Easy/Medium/Hard set
    • 2-Player mode: both paddles controllable (Player 1 = W/S, Player 2 = Up/Down)
    • AI mode: only Player 1 controllable; paddle2 is AI
  • Memory & Stability

    • Play 10 game rounds (variety of modes/difficulties)
    • Chrome DevTools Memory: take heap snapshot before → play 10 rounds → take heap snapshot after
    • Memory growth should be <10MB (stable, not creeping)
    • No console errors or warnings during gameplay
  • Ball Re-serve Timing

    • After score, ~1 second pause before ball re-serves
    • During pause, ball is stationary (visible pause)
    • After pause, ball serves automatically (no player input needed)
  • Edge Cases

    • Rapidly scoring multiple points (spam ball left/right) → scores tracked correctly
    • Switching modes mid-game → clean restart (scores reset)
    • Window resize during gameplay → paddles and ball scale correctly, game doesn't crash

Wave 0 Gaps

None — existing test infrastructure (manual browser testing) covers all phase requirements. No automated test framework is used in Phase 2 (vanilla JS, single HTML file). All verification is manual play-testing using checklist above.

(If test framework is added in Phase 3 or later, retroactive unit tests can be added for core logic: AI prediction, score detection, state transitions.)


Sources

Primary (HIGH confidence)

  • Project CONTEXT.md (2026-03-10) — Locked decisions on AI strategy, difficulty parameters, game modes, match-end condition
  • Phase 1 index.html (2026-03-10) — Existing GameState, Physics, Input, Renderer, GameLoop architecture
  • research/PITFALLS.md (2026-03-10) — Ball tunneling, input lag, memory leaks, AI difficulty balance, reaction delay pattern
  • research/ARCHITECTURE.md (2026-03-10) — Module-object pattern, state machine, entity systems, collision detection patterns, data flow
  • MDN: Window.requestAnimationFrame — RAF timing, delta time usage
  • MDN: Canvas API — Drawing operations for score text, game-over message

Secondary (MEDIUM confidence)

Tertiary (LOW — needs validation)

  • Various online Pong tutorials (GitHub, Dev.to) — AI implementations vary widely; use as reference, not authority

Metadata

Confidence breakdown:

  • Standard Stack (HIGH): Vanilla JS + Canvas is the only option for this project (locked decisions). No alternatives to research.
  • Architecture Patterns (HIGH): Module-object pattern established in Phase 1; extension to Phase 2 follows same patterns. Predictive interception is standard Pong AI technique. Reaction delay + error margin is industry standard for difficulty levels.
  • Pitfalls (HIGH): Tunneling, input lag, memory leaks, AI balance, score detection placement all documented in prior research with sources. Direct application to Phase 2.

Research date: 2026-03-10 Valid until: 2026-04-10 (30 days for stable domain; HTML5 Canvas APIs unlikely to change)


Phase 2 research for: Super Pong Next Gen Researched: 2026-03-10 by Claude Code