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

15 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 02 execute 2
02-01
index.html
false
CORE-05
CORE-06
truths artifacts key_links
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
path provides contains
index.html GameLoop with scoring detection, state machine, rendering of paddle2 and scores gameState.*modeSelect, score1, score2, gameover, WIN_SCORE
from to via pattern
GameLoop.main() score detection checks ball.x after Physics.update() score[12]++
from to via pattern
GameLoop.main() match-end check score >= WIN_SCORE condition WIN_SCORE
from to via pattern
GameLoop.main() re-serve after pause scored state + elapsed > 1.0 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.

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

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/phases/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:

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:

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

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

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

pauseTime: 0,

4. Remove the Phase 1 debug speed display — delete this block from wherever it appears in the old main():

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

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

<success_criteria> 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) </success_criteria>
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`.