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>
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 |
|
true |
|
|
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.mdGameConfig (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
}
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.
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();
<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>