From 8029a383e38c650e2ca25dbc868e6aab3f57009b Mon Sep 17 00:00:00 2001 From: Dabit Date: Tue, 10 Mar 2026 21:01:21 +0100 Subject: [PATCH] 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 --- .planning/ROADMAP.md | 9 +- .../phases/02-core-gameplay/02-01-PLAN.md | 367 +++++++++++++++++ .../phases/02-core-gameplay/02-02-PLAN.md | 376 ++++++++++++++++++ 3 files changed, 750 insertions(+), 2 deletions(-) create mode 100644 .planning/phases/02-core-gameplay/02-01-PLAN.md create mode 100644 .planning/phases/02-core-gameplay/02-02-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 4e86ece..2411265 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -60,7 +60,11 @@ Plans: 5. Match ends when a player reaches target score with clear game-over state 6. No memory leaks or event listener cleanup issues after multiple rounds -**Plans**: TBD +**Plans:** 2 plans + +Plans: +- [ ] 02-01-PLAN.md — Paddle2, AI system (predictive interception + 3 difficulty levels), extended Input/Physics +- [ ] 02-02-PLAN.md — Game state machine, scoring, match-end, re-serve, paddle2 rendering, mode/difficulty selection --- @@ -138,7 +142,7 @@ Plans: | Phase | Plans Complete | Status | Completed | |-------|----------------|--------|-----------| | 1. Foundation | 1/2 | In Progress | — | -| 2. Core Gameplay | 0/? | Not started | — | +| 2. Core Gameplay | 0/2 | Not started | — | | 3. Complete Experience | 0/? | Not started | — | | 4. Polish & Depth | 0/? | Not started | — | | 5. Release | 0/? | Not started | — | @@ -147,3 +151,4 @@ Plans: *Roadmap created: 2026-03-10* *Phase 1 planned: 2026-03-10* +*Phase 2 planned: 2026-03-10* diff --git a/.planning/phases/02-core-gameplay/02-01-PLAN.md b/.planning/phases/02-core-gameplay/02-01-PLAN.md new file mode 100644 index 0000000..5c02837 --- /dev/null +++ b/.planning/phases/02-core-gameplay/02-01-PLAN.md @@ -0,0 +1,367 @@ +--- +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`. + diff --git a/.planning/phases/02-core-gameplay/02-02-PLAN.md b/.planning/phases/02-core-gameplay/02-02-PLAN.md new file mode 100644 index 0000000..34d3656 --- /dev/null +++ b/.planning/phases/02-core-gameplay/02-02-PLAN.md @@ -0,0 +1,376 @@ +--- +phase: 02-core-gameplay +plan: 02 +type: execute +wave: 2 +depends_on: [02-01] +files_modified: [index.html] +autonomous: false +requirements: [CORE-05, CORE-06] +must_haves: + truths: + - "Player scores a point when ball exits past opponent's paddle edge" + - "Scores display on canvas in real time, visible to both players" + - "Ball pauses ~1 second after a score, then auto-serves" + - "Match ends when a player reaches 7 points with a winner message on screen" + - "R key restarts the match (scores reset, mode/difficulty selection reappears)" + - "Mode is selected by pressing 1 (Solo vs AI) or 2 (2-Player)" + - "Difficulty is selected by pressing 1/2/3 (Easy/Medium/Hard) before match starts" + - "Both paddles render on screen during gameplay" + artifacts: + - path: "index.html" + provides: "GameLoop with scoring detection, state machine, rendering of paddle2 and scores" + contains: "gameState.*modeSelect, score1, score2, gameover, WIN_SCORE" + key_links: + - from: "GameLoop.main()" + to: "score detection" + via: "checks ball.x after Physics.update()" + pattern: "score[12]\\+\\+" + - from: "GameLoop.main()" + to: "match-end check" + via: "score >= WIN_SCORE condition" + pattern: "WIN_SCORE" + - from: "GameLoop.main()" + to: "re-serve after pause" + via: "scored state + elapsed > 1.0" + pattern: "elapsed > 1" +--- + + +Wire game state machine, scoring detection, match-end condition, re-serve timing, rendering of paddle2 and score display, and mode/difficulty selection flow into index.html. + +Purpose: Transform the two-paddle foundation (from Plan 02-01) into a fully playable game with score tracking, match progression, and a clear start/restart flow. +Output: Complete Phase 2 — a playable single-player (vs AI) or 2-player local Pong match that ends at 7 points with mode/difficulty selection and R-key restart. + + + +@/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/02-core-gameplay/02-CONTEXT.md +@.planning/phases/02-core-gameplay/02-RESEARCH.md +@.planning/phases/02-core-gameplay/02-01-SUMMARY.md + + + + +GameState fields added in Plan 02-01: +```javascript +GameState.score1 // 0 +GameState.score2 // 0 +GameState.mode // null | 'ai' | '2p' +GameState.difficulty // 'easy' | 'medium' | 'hard' +GameState.gameState // 'modeSelect' | 'diffSelect' | 'playing' | 'scored' | 'gameover' +GameState.winner // null | 'player1' | 'player2' | 'ai' +GameState.paddle2 // { x, y, width, height, speed, color } +``` + +GameConfig fields added in Plan 02-01: +```javascript +GameConfig.WIN_SCORE // 7 +GameConfig.AI_EASY/MEDIUM/HARD // { speed, reactionDelay, errorMargin } +``` + +Physics.serveBall() (existing) — resets ball to center with random direction; does NOT reset scores. + +Physics.init(width, height) — positions both paddles and calls serveBall(); can be used for full restart. + + + + + + + Task 1: State machine — mode/difficulty selection, scoring, match end, and restart + index.html + +Rewrite `GameLoop.main()` to implement the full game state machine. Also add key handlers for mode/difficulty selection and R-key restart to Input (append to the existing `_handleKeyDown` handler — do NOT replace the arrow/WASD key handling already there). + +**1. Add mode/difficulty/restart key handling** inside Input._handleKeyDown (add AFTER the ArrowDown block): +```javascript +// Mode selection (only when in modeSelect state) +if (e.code === 'Digit1' && GameState.gameState === 'modeSelect') { + GameState.mode = 'ai'; + GameState.gameState = 'diffSelect'; +} +if (e.code === 'Digit2' && GameState.gameState === 'modeSelect') { + GameState.mode = '2p'; + // Skip difficulty selection for 2-player — go straight to playing + GameState.gameState = 'playing'; + Physics.serveBall(); +} + +// Difficulty selection (only when in diffSelect state) +if (e.code === 'Digit1' && GameState.gameState === 'diffSelect') { + GameState.difficulty = 'easy'; + AI.init(); + GameState.gameState = 'playing'; + Physics.serveBall(); +} +if (e.code === 'Digit2' && GameState.gameState === 'diffSelect') { + GameState.difficulty = 'medium'; + AI.init(); + GameState.gameState = 'playing'; + Physics.serveBall(); +} +if (e.code === 'Digit3' && GameState.gameState === 'diffSelect') { + GameState.difficulty = 'hard'; + AI.init(); + GameState.gameState = 'playing'; + Physics.serveBall(); +} + +// Restart (only when gameover) +if (e.code === 'KeyR' && GameState.gameState === 'gameover') { + GameState.score1 = 0; + GameState.score2 = 0; + GameState.winner = null; + GameState.mode = null; + GameState.difficulty = 'medium'; + GameState.gameState = 'modeSelect'; + AI.init(); + Physics.init(Renderer.logicalWidth, Renderer.logicalHeight); +} +``` + +**2. Rewrite GameLoop.main()** (replace the entire existing `main(currentTime)` method): +```javascript +main(currentTime) { + this.stopHandle = window.requestAnimationFrame(this.main.bind(this)); + const deltaTime = Math.min((currentTime - this.lastTime) / 1000, 0.05); + this.lastTime = currentTime; + + const gs = GameState; + const ball = gs.ball; + + // --- Physics update (only during active play) --- + if (gs.gameState === 'playing') { + Physics.update(deltaTime); + + // Score detection — AFTER Physics.update(), NOT inside Physics + if (ball.x + ball.radius < 0) { + // Ball exited left edge → Player 2 (or AI) scores + gs.score2++; + gs.gameState = 'scored'; + this.pauseTime = currentTime; + } else if (ball.x - ball.radius > Physics.width) { + // Ball exited right edge → Player 1 scores + gs.score1++; + gs.gameState = 'scored'; + this.pauseTime = currentTime; + } + + // Match-end check immediately after scoring + if (gs.score1 >= GameConfig.WIN_SCORE) { + gs.gameState = 'gameover'; + gs.winner = 'player1'; + } else if (gs.score2 >= GameConfig.WIN_SCORE) { + gs.gameState = 'gameover'; + gs.winner = gs.mode === 'ai' ? 'ai' : 'player2'; + } + } + + // --- Auto-serve after ~1s pause --- + if (gs.gameState === 'scored') { + const elapsed = (currentTime - this.pauseTime) / 1000; + if (elapsed >= 1.0) { + Physics.serveBall(); + gs.gameState = 'playing'; + } + } + + // --- Render --- + Renderer.clear(); + + if (gs.gameState === 'modeSelect') { + // Mode selection prompt + Renderer.ctx.fillStyle = '#fff'; + Renderer.ctx.font = 'bold 28px monospace'; + Renderer.ctx.textAlign = 'center'; + Renderer.ctx.fillText('SUPER PONG NEXT GEN', Renderer.logicalWidth / 2, Renderer.logicalHeight / 2 - 60); + Renderer.ctx.font = '20px monospace'; + Renderer.ctx.fillText('Press 1 — Solo vs AI', Renderer.logicalWidth / 2, Renderer.logicalHeight / 2); + Renderer.ctx.fillText('Press 2 — 2 Player Local', Renderer.logicalWidth / 2, Renderer.logicalHeight / 2 + 36); + Renderer.ctx.textAlign = 'left'; + return; + } + + if (gs.gameState === 'diffSelect') { + Renderer.ctx.fillStyle = '#fff'; + Renderer.ctx.font = 'bold 24px monospace'; + Renderer.ctx.textAlign = 'center'; + Renderer.ctx.fillText('SELECT DIFFICULTY', Renderer.logicalWidth / 2, Renderer.logicalHeight / 2 - 50); + Renderer.ctx.font = '20px monospace'; + Renderer.ctx.fillText('1 — Easy', Renderer.logicalWidth / 2, Renderer.logicalHeight / 2); + Renderer.ctx.fillText('2 — Medium', Renderer.logicalWidth / 2, Renderer.logicalHeight / 2 + 36); + Renderer.ctx.fillText('3 — Hard', Renderer.logicalWidth / 2, Renderer.logicalHeight / 2 + 72); + Renderer.ctx.textAlign = 'left'; + return; + } + + // --- Gameplay rendering (playing, scored, gameover) --- + + // Paddles + const p1 = gs.paddle1; + const p2 = gs.paddle2; + Renderer.drawRect(p1.x, p1.y, p1.width, p1.height, p1.color); + Renderer.drawRect(p2.x, p2.y, p2.width, p2.height, p2.color); + + // Ball (stationary during scored pause) + if (gs.gameState !== 'scored') { + const b = gs.ball; + Renderer.drawCircle(b.x, b.y, b.radius, b.color); + } + + // Center divider line (dashed) + Renderer.ctx.setLineDash([10, 14]); + Renderer.ctx.strokeStyle = 'rgba(255,255,255,0.2)'; + Renderer.ctx.lineWidth = 2; + Renderer.ctx.beginPath(); + Renderer.ctx.moveTo(Renderer.logicalWidth / 2, 0); + Renderer.ctx.lineTo(Renderer.logicalWidth / 2, Renderer.logicalHeight); + Renderer.ctx.stroke(); + Renderer.ctx.setLineDash([]); + + // Scores + Renderer.ctx.fillStyle = 'rgba(255,255,255,0.8)'; + Renderer.ctx.font = 'bold 48px monospace'; + Renderer.ctx.textAlign = 'center'; + Renderer.ctx.fillText(gs.score1, Renderer.logicalWidth / 4, 64); + Renderer.ctx.fillText(gs.score2, Renderer.logicalWidth * 3 / 4, 64); + Renderer.ctx.textAlign = 'left'; + + // Game over overlay + if (gs.gameState === 'gameover') { + const winnerName = gs.winner === 'player1' ? 'Player 1' : + gs.winner === 'ai' ? 'AI' : 'Player 2'; + Renderer.ctx.fillStyle = 'rgba(0,0,0,0.5)'; + Renderer.ctx.fillRect(0, 0, Renderer.logicalWidth, Renderer.logicalHeight); + Renderer.ctx.fillStyle = '#fff'; + Renderer.ctx.font = 'bold 36px monospace'; + Renderer.ctx.textAlign = 'center'; + Renderer.ctx.fillText(winnerName + ' Wins!', Renderer.logicalWidth / 2, Renderer.logicalHeight / 2 - 20); + Renderer.ctx.font = '20px monospace'; + Renderer.ctx.fillText('Press R to play again', Renderer.logicalWidth / 2, Renderer.logicalHeight / 2 + 24); + Renderer.ctx.textAlign = 'left'; + } +}, +``` + +**3. Add `pauseTime` field to GameLoop object** (add alongside `stopHandle: null, lastTime: 0`): +```javascript +pauseTime: 0, +``` + +**4. Remove the Phase 1 debug speed display** — delete this block from wherever it appears in the old main(): +```javascript +// Debug: show ball speed (keep for Phase 1 verification) +Renderer.ctx.fillStyle = 'rgba(255,255,255,0.4)'; +Renderer.ctx.font = '14px monospace'; +Renderer.ctx.fillText('speed: ' + Math.round(GameState.ball.speed), 10, 20); +``` + +**5. Remove the Phase 1 automatic re-serve** from Physics.update() — the old code called `this.serveBall()` when ball went out of bounds. Replace that entire out-of-bounds block with NOTHING (Plan 02-02's GameLoop now owns scoring and serve). Remove: +```javascript +// --- Ball out of bounds (left or right) → reset --- +if (ball.x + ball.radius < 0 || ball.x - ball.radius > this.width) { + this.serveBall(); + return; +} +``` +Physics.update() should no longer call serveBall() or detect out-of-bounds. Scoring detection is GameLoop's responsibility. + +**Critical:** Do not skip this step — if Physics still auto-resets the ball, scores will never increment. + + +Open index.html in browser. +1. Verify mode select screen appears on load (title + "Press 1 — Solo vs AI / Press 2 — 2 Player Local") +2. Press 2 → 2-Player mode starts immediately; both paddles visible; W/S moves left paddle, Up/Down moves right paddle +3. Play until ball exits right edge → score on left side increments; ~1s pause; ball re-serves +4. Play until ball exits left edge → score on right side increments +5. Play to 7 points → winner overlay appears with "Player X Wins!" and "Press R to play again" +6. Press R → returns to mode select screen with scores reset +7. Press 1 → difficulty select screen appears; Press 2 → Medium difficulty; AI paddle tracks ball +8. Verify no console errors throughout + + +- Mode select screen shows on load +- 2-Player: both paddles controllable, scoring works, match ends at 7 +- Solo vs AI: difficulty selection works, AI tracks ball, match ends at 7 +- R key resets to mode select with clean state +- No Phase 1 debug speed text on screen +- No console errors + + + + + Checkpoint: Verify Phase 2 complete gameplay + index.html + Human verification of completed Phase 2 gameplay. No code changes — user plays the game and confirms all behaviors match the checklist in how-to-verify. + All items in how-to-verify checklist confirmed by user. + User types "approved" — all Phase 2 success criteria verified through manual play. + +Complete Phase 2: paddle2 (human or AI), scoring system, match-end condition, re-serve timing, mode/difficulty selection, and winner display — all in index.html. + + +Run through this checklist: + +**Player 2 Input (2-Player mode):** +1. Load index.html. Press 2 (2-Player mode). +2. Press ArrowUp — right paddle moves up smoothly, no lag. +3. Press ArrowDown — right paddle moves down. +4. Press both simultaneously — paddle stops. +5. Release — paddle stops immediately. + +**Scoring:** +6. Let ball exit left edge — right score (top-right number) increments. +7. Let ball exit right edge — left score (top-left number) increments. +8. Ball pauses ~1 second, then auto-serves. + +**Match End:** +9. Play until 7-0 — winner overlay appears: "Player X Wins!" with "Press R to play again". +10. Press R — mode select screen returns, scores show 0. + +**AI Mode:** +11. Press 1 (Solo vs AI) — difficulty select shows (Easy/Medium/Hard). +12. Press 1 — Easy. Watch AI paddle for 2 minutes — Easy AI should miss regularly. +13. Press R, press 1, press 3 (Hard). Verify Hard AI is noticeably faster and more accurate than Easy. +14. Verify AI still intercepts balls that bounce off top/bottom walls. + +**Memory and Stability:** +15. Play 3 complete matches. Open DevTools Console — no errors or warnings. +16. Scores reset correctly on each restart. + + Type "approved" if all checks pass, or describe any issues found. + + + + + +Phase 2 complete when all of the following are true: +1. Mode select screen displays on load (no mid-game start) +2. 2-Player: W/S controls paddle1, ArrowUp/ArrowDown controls paddle2 +3. AI mode: paddle2 moves autonomously toward ball intercept position +4. Ball exits left edge → score2 increments; exits right edge → score1 increments +5. 1-second pause after each score, then auto-serve +6. First to 7 points triggers gameover state with winner message +7. R key restarts cleanly (scores reset, mode = null, gameState = modeSelect) +8. No console errors after 10+ rounds + + + +All 6 Phase 2 success criteria from ROADMAP.md are met: +1. Player 2 can control paddle with Up/Down arrow keys independently from Player 1 (CORE-08) +2. AI opponent successfully tracks and intercepts the ball with behavior varying by difficulty (AI-01) +3. Easy AI is beatable by average players; Medium provides fair challenge; Hard requires skill (AI-02, AI-03, AI-04) +4. Player scores point when ball passes opponent's paddle (CORE-05) +5. Match ends when a player reaches 7 points with clear game-over state (CORE-06) +6. No memory leaks or event listener cleanup issues after multiple rounds (Input.cleanup() implemented) + + + +After completion, create `.planning/phases/02-core-gameplay/02-02-SUMMARY.md` following the template at `@/home/dabit/.claude/get-shit-done/templates/summary.md`. +