--- phase: 02-core-gameplay plan: 01 type: execute wave: 1 depends_on: [] files_modified: [index.html] autonomous: true requirements: [CORE-08, AI-01, AI-02, AI-03, AI-04] must_haves: truths: - "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)" artifacts: - path: "index.html" provides: "GameState.paddle2 object, AI module, extended Input, extended Physics" contains: "AI_EASY, AI_MEDIUM, AI_HARD, getVerticalInput2, _checkPaddle2Collision" key_links: - from: "Physics.update()" to: "AI.update(deltaTime)" via: "called when GameState.mode === 'ai'" pattern: "AI\\.update" - from: "Physics.update()" to: "_checkPaddle2Collision" via: "called when ball.vx > 0" pattern: "ball\\.vx > 0" - from: "AI._predictBallY()" to: "wall bounce simulation" via: "stepped ray cast loop" pattern: "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. @/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/STATE.md @.planning/phases/02-core-gameplay/02-CONTEXT.md @.planning/phases/02-core-gameplay/02-RESEARCH.md GameConfig (existing — extend, do NOT replace): ```javascript 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): ```javascript paddle1: { x: 30, y: , width: 12, height: 80, speed: 400, color: '#fff' } ``` GameState.ball (existing): ```javascript ball: { x, y, radius: 8, vx, vy, speed, color: '#fff' } ``` Input (existing — extend, do NOT replace): ```javascript 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): ```javascript 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): ```javascript 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`): ```javascript 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): ```javascript 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): ```javascript 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: ```javascript // 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 = {`): ```javascript 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`): ```javascript _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): ```javascript // --- 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);`: ```javascript // --- 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()`): ```javascript 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) - 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 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`.