Files
Dabit 8029a383e3 docs(02-core-gameplay): create phase 2 plan
Two-wave plan for Core Gameplay phase: paddle2 + AI system (wave 1),
then game state machine + scoring + rendering (wave 2).

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

13 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
02-core-gameplay 01 execute 1
index.html
true
CORE-08
AI-01
AI-02
AI-03
AI-04
truths artifacts key_links
Player 2 can move their paddle up and down with ArrowUp/ArrowDown keys
AI paddle tracks and intercepts the ball using predictive ray-cast logic
Easy AI is visibly slow with a large reaction delay — beatable by any player
Medium AI reacts at moderate speed — provides a fair challenge
Hard AI reacts fast but not perfectly — still beatable with skill
Ball deflects off paddle2 the same way it deflects off paddle1 (zone-based angle)
path provides contains
index.html GameState.paddle2 object, AI module, extended Input, extended Physics AI_EASY, AI_MEDIUM, AI_HARD, getVerticalInput2, _checkPaddle2Collision
from to via pattern
Physics.update() AI.update(deltaTime) called when GameState.mode === 'ai' AI.update
from to via pattern
Physics.update() _checkPaddle2Collision called when ball.vx > 0 ball.vx > 0
from to via pattern
AI._predictBallY() wall bounce simulation stepped ray cast loop while.*steps < maxSteps
Add paddle2 (Player 2 or AI-controlled) and the full AI opponent system to index.html.

Purpose: Make the game two-sided — ball now has a right paddle to deflect off. Player 2 can be human (Up/Down arrows) or AI (predictive interception with 3 tunable difficulty levels). Output: index.html with GameState.paddle2, extended Input (ArrowUp/ArrowDown + named handler refs for cleanup), AI object with predictive ray-cast interception, extended Physics.update() with paddle2 movement branch and paddle2 collision detection.

<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/STATE.md @.planning/phases/02-core-gameplay/02-CONTEXT.md @.planning/phases/02-core-gameplay/02-RESEARCH.md

GameConfig (existing — extend, do NOT replace):

const GameConfig = {
  initialBallSpeed: 220,
  speedIncrement: 18,
  paddleSpeed: 400
  // Plan 02-01 adds: WIN_SCORE, AI_EASY, AI_MEDIUM, AI_HARD
};

GameState.paddle1 (existing structure — paddle2 MUST mirror this):

paddle1: {
  x: 30, y: <centered>,
  width: 12, height: 80,
  speed: 400,
  color: '#fff'
}

GameState.ball (existing):

ball: { x, y, radius: 8, vx, vy, speed, color: '#fff' }

Input (existing — extend, do NOT replace):

const Input = {
  keys: { w: false, s: false },
  init() { /* anonymous addEventListener keydown/keyup */ },
  getVerticalInput() { /* returns -1/0/1 for W/S */ }
};

Physics._checkPaddleCollision(ball, paddle) (existing — reuse for paddle2):

  • AABB collision + zone-based deflection
  • Sets ball.vx positive (away from paddle1, left side)
  • Needs a mirrored version for paddle2 that sets ball.vx negative and pushes ball LEFT

Physics.update(deltaTime) (existing — extend):

update(deltaTime) {
  // Moves paddle1 (Input.getVerticalInput)
  // Moves ball (vx*dt, vy*dt)
  // Wall bounces (top/bottom)
  // Out-of-bounds reset (left or right) → this.serveBall()
  // Paddle1 collision (only when ball.vx < 0)
}

GameLoop.main(currentTime) (existing — NOT modified in this plan):

main(currentTime) {
  const deltaTime = Math.min((currentTime - this.lastTime) / 1000, 0.05);
  Physics.update(deltaTime);
  Renderer.clear();
  // draws debug speed, paddle1, ball
}
Task 1: Extend GameConfig, GameState, and Input for two-player support index.html Make these additions to index.html inside the existing `<script>` tag. Do NOT rewrite the whole file — surgical additions only.

1. Extend GameConfig (add after paddleSpeed: 400):

WIN_SCORE: 7,

AI_EASY:   { speed: 200, reactionDelay: 0.3,  errorMargin: 20 },
AI_MEDIUM: { speed: 320, reactionDelay: 0.1,  errorMargin: 5  },
AI_HARD:   { speed: 400, reactionDelay: 0.05, errorMargin: 2  }

2. Extend GameState (add after ball: {...}, inside the GameState object):

paddle2: {
  x: 0, y: 0,          // Set on Physics.init()
  width: 12, height: 80,
  speed: 400,
  color: '#fff'
},
score1: 0,
score2: 0,
mode: null,             // null = mode select screen; 'ai' = Solo vs AI; '2p' = 2-Player local
difficulty: 'medium',   // 'easy', 'medium', 'hard'
gameState: 'modeSelect',// 'modeSelect', 'diffSelect', 'playing', 'scored', 'gameover'
winner: null            // null, 'player1', 'player2', 'ai'

3. Refactor Input to use named handler refs for cleanup (replace the existing Input object entirely — the existing one has anonymous handlers that can't be removed):

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

  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(); }
    };
    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);
    document.addEventListener('keyup',   this._handleKeyUp);
  },

  cleanup() {
    document.removeEventListener('keydown', this._handleKeyDown);
    document.removeEventListener('keyup',   this._handleKeyUp);
  },

  getVerticalInput()  { return this.keys.w ? -1 : this.keys.s ? 1 : 0; },
  getVerticalInput2() { return this.keys.arrowUp ? -1 : this.keys.arrowDown ? 1 : 0; }
};

4. Extend Physics.init() — add paddle2 positioning after the existing paddle1 positioning:

// Position paddle2 on right side, vertically centered
GameState.paddle2.x = width - 30 - GameState.paddle2.width;
GameState.paddle2.y = height / 2 - GameState.paddle2.height / 2;
GameState.paddle2.speed = GameConfig.paddleSpeed;

Also extend Physics.onResize() to call this.init(width, height) so paddle2 is repositioned on resize (the existing onResize only updates this.width/height). Open index.html in browser. Open DevTools console — no errors on load. Verify in console: GameState.paddle2 has x, y, width, height, speed, color. GameConfig.AI_EASY exists. Input.getVerticalInput2 is a function. GameState has paddle2 mirroring paddle1 structure. GameConfig has WIN_SCORE and all three AI difficulty presets. Input has named handler refs, cleanup(), getVerticalInput2(). No console errors.

Task 2: Add AI module and extend Physics for paddle2 movement and collision index.html Add the AI object between Physics and GameLoop. Then extend Physics.update() to handle paddle2.

1. Add AI object (insert between the closing }; of Physics and the opening const GameLoop = {):

const AI = {
  reactionElapsed: 0,

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

  update(deltaTime) {
    const difficulty = GameState.difficulty;
    const config = GameConfig['AI_' + difficulty.toUpperCase()];
    const ball = GameState.ball;
    const paddle2 = GameState.paddle2;

    // Reset reaction timer when ball is moving away (toward player side)
    if (ball.vx < 0) {
      this.reactionElapsed = 0;
      return;  // Ball heading toward player — AI waits
    }

    this.reactionElapsed += deltaTime;
    if (this.reactionElapsed < config.reactionDelay) {
      return;  // Still in reaction delay window
    }

    // Predict ball y-position when it reaches paddle2 x
    const targetY = this._predictBallY(ball, paddle2.x);

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

    // Move paddle toward aim Y (clamped to field)
    const centerY = paddle2.y + paddle2.height / 2;
    const diff = aimY - centerY;

    if (Math.abs(diff) > 3) {  // Deadzone prevents 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) {
    let x = ball.x, y = ball.y, vx = ball.vx, vy = ball.vy;
    const maxSteps = 500;
    let steps = 0;

    while (steps < maxSteps) {
      if (Math.abs(x - targetX) < 1) break;
      const timeToTarget = (targetX - x) / vx;
      const timeStep = Math.min(0.016, Math.abs(timeToTarget));

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

      // 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;
  }
};

2. Add _checkPaddle2Collision(ball, paddle) to Physics (add after _checkPaddleCollision):

_checkPaddle2Collision(ball, paddle) {
  const inX = ball.x + ball.radius > paddle.x &&
               ball.x - ball.radius < paddle.x + paddle.width;
  const inY = ball.y + ball.radius > paddle.y &&
               ball.y - ball.radius < paddle.y + paddle.height;

  if (!inX || !inY) return;

  const relativeHitPos = (ball.y - paddle.y) / paddle.height;
  const hitZone = Math.max(0, Math.min(4, Math.floor(relativeHitPos * 5)));
  const anglesDeg = [-60, -30, 5, 30, 60];
  const angleRad = anglesDeg[hitZone] * Math.PI / 180;

  ball.speed += GameConfig.speedIncrement;

  // Ball leaves paddle2 going LEFT (negative vx)
  ball.vx = -Math.cos(angleRad) * ball.speed;
  ball.vy =  Math.sin(angleRad) * ball.speed;

  // Push ball left of paddle2 to prevent double-collision
  ball.x = paddle.x - ball.radius - 1;
},

3. Extend Physics.update() — after the existing paddle1 movement block, add paddle2 movement (replace the existing out-of-bounds reset with scoring-aware version in Plan 02-02; for now keep the existing reset but ADD paddle2 movement and collision):

Add after paddle.y = Math.max(0, Math.min(...)); (end of paddle1 movement):

// --- Move paddle2 (human or AI) ---
const paddle2 = GameState.paddle2;
if (GameState.mode === '2p') {
  const dir2 = Input.getVerticalInput2();
  paddle2.y += dir2 * paddle2.speed * deltaTime;
  paddle2.y = Math.max(0, Math.min(this.height - paddle2.height, paddle2.y));
} else if (GameState.mode === 'ai') {
  AI.update(deltaTime);
}

Add after the existing if (ball.vx < 0) this._checkPaddleCollision(ball, paddle);:

// --- Paddle2 collision (only when ball moving right toward paddle2) ---
if (ball.vx > 0) {
  this._checkPaddle2Collision(ball, GameState.paddle2);
}

4. Add AI.init() to the initialization block at the bottom of the script (after Input.init()):

AI.init();
Open index.html. Ball should still bounce and Player 1 W/S still works. Open console — no errors. Verify `typeof AI.update === 'function'` and `typeof Physics._checkPaddle2Collision === 'function'`. Paddle2 is not yet visible (rendering added in Plan 02-02) but it exists in GameState with valid coordinates. AI object exists with init(), update(), _predictBallY(). Physics has _checkPaddle2Collision(). Physics.update() includes paddle2 movement branch (2p/ai). No console errors. Paddle1 W/S still works. After both tasks: - `GameState.paddle2` has x, y, width, height, speed, color (mirrors paddle1 structure) - `Input.cleanup()` is callable (named handlers stored on Input object) - `Input.getVerticalInput2()` returns -1/0/1 for ArrowUp/ArrowDown - `AI.update(deltaTime)` moves paddle2 toward predicted ball intercept when mode === 'ai' - `AI._predictBallY()` simulates wall bounces in its ray cast loop (not linear interpolation) - `Physics._checkPaddle2Collision()` uses same 5-zone deflection as paddle1 but sends ball LEFT - Ball still bounces off walls and Player 1 can still hit it (existing behavior unchanged)

<success_criteria>

  • ArrowUp moves paddle2 up; ArrowDown moves paddle2 down (requires mode selection in Plan 02-02 to fully test, but can test by temporarily setting GameState.mode = '2p' in console)
  • In AI mode, paddle2 moves toward ball intercept position
  • Ball deflects off paddle2 (check by setting mode in console and playing)
  • typeof AI._predictBallY === 'function' is true
  • No console errors on load or during play </success_criteria>
After completion, create `.planning/phases/02-core-gameplay/02-01-SUMMARY.md` following the template at `@/home/dabit/.claude/get-shit-done/templates/summary.md`.