// ---------- CONSTANTS ----------
const CANVAS_W = 400;
const CANVAS_H = 600;
const BRICK_COLS = 8;
const BRICK_ROWS = 4;
const BRICK_W = 50;
const BRICK_H = 20;
const PADDLE_W = 60;
const PADDLE_H = 10;
const PADDLE_Y = 580; // bottom edge y
const BALL_R = 3;
const BALL_SPEED = 3.4; // px/frame constant magnitude
const MAX_DX = 4.2; // max horizontal velocity after paddle reflection
const MAX_DY = 3.2; // max vertical velocity (upward limit for wall bounces)
const BRICK_POINTS = 10;
const LIVES = 3;
// ---------- CANVAS SETUP ----------
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
canvas.width = CANVAS_W;
canvas.height = CANVAS_H;
// Scale for DPR (crisp rendering on mobile)
function dpr() { return window.devicePixelRatio || 1; }
let scale = dpr();
function resize() {
const w = Math.min(window.innerWidth, CANVAS_W * scale);
const h = Math.min(window.innerHeight, CANVAS_H * scale);
canvas.width = Math.round(w * dpr());
canvas.height = Math.round(h * dpr());
ctx.scale(scale, scale);
}
window.addEventListener('resize', resize);
resize();
// ---------- GAME STATE ----------
const keys = { left: false, right: false };
let lastTouchX = -1;
let dragging = false;
const paddleX = (CANVAS_W - PADDLE_W) / 2; // center at 200
const state = {
ball: { x: 200, y: 570, dx: 0, dy: 0 },
paddleX: 200,
score: 0,
lives: LIVES,
bricks: [], // array of {x,y,alive}
gameMode: 'playing', // playing | gameover | won
lastCollide: null, // point where collision happened this frame (for stuck-in prevention)
};
// ---------- INIT BRICKS ----------
function initBricks() {
state.bricks = [];
let idx = 0;
for (let r = 0; r < BRICK_ROWS; r++) {
for (let c = 0; c < BRICK_COLS; c++) {
const x = c * (BRICK_W + 8);
const y = r * (BRICK_H + 12); // gap between rows
state.bricks.push({ x, y, w: BRICK_W, h: BRICK_H, alive: true });
}
}
}
initBricks();
// ---------- KEY INPUT ----------
document.addEventListener('keydown', e => {
if (e.key === 'ArrowLeft' || e.key === 'KeyA') keys.left = true;
if (e.key === 'ArrowRight' || e.key === 'KeyD') keys.right = true;
});
document.addEventListener('keyup', e => {
if (e.key === 'ArrowLeft' || e.key === 'KeyA') keys.left = false;
if (e.key === 'ArrowRight' || e.key === 'KeyD') keys.right = false;
});
canvas.addEventListener('mousedown', e => {
if (state.gameMode !== 'playing') return;
dragging = true;
lastTouchX = e.clientX;
});
canvas.addEventListener('mousemove', e => {
if (!dragging) return;
movePaddle(e.clientX);
});
canvas.addEventListener('mouseleave', () => dragging = false);
// Touch support
canvas.addEventListener('touchstart', e => {
if (e.touches.length !== 1) return;
const t = e.touches[0];
if (state.gameMode !== 'playing') return;
dragging = true;
lastTouchX = t.clientX;
}, { passive: false });
canvas.addEventListener('touchmove', e => {
e.preventDefault();
if (!dragging) return;
const t = e.touches[0];
movePaddle(t.clientX);
}, { passive: false });
canvas.addEventListener('touchend', () => dragging = false);
canvas.addEventListener('touchcancel', () => dragging = false);
// ---------- PADLE MOVEMENT ----------
function movePaddle(clientX) {
const cx = canvas.getBoundingClientRect().left + CANVAS_W / 2;
const target = clientX - cx;
// clamp
const min = 0;
const max = CANVAS_W - PADDLE_W;
state.paddleX = Math.max(min, Math.min(max, target));
}
// ---------- EASING (smooth paddle follow) ----------
const EASE_K = 0.08; // smoothing factor per frame
// ---------- PHYSIC HELPER ----------
function normalizeSpeed(dx, dy) {
const len = Math.sqrt(dx * dx + dy * dy);
if (len === 0) return { dx: BALL_SPEED, dy: -BALL_SPEED };
return { dx: (dx / len) * BALL_SPEED, dy: (dy / len) * BALL_SPEED };
}
function reflectOffWall(dx, dy, wallX, wallY, w, h) {
// flip the appropriate axis depending on which side hit
if (dx > 0) dx = -dx; // right wall
else if (dx < 0) {} // left wall
if (dy < 0) dy = -dy; // top wall
// bottom handled separately
}
// ---------- COLLISION DETECTION (AABB, single per frame) ----------
function resolveCollisions() {
// Track closest brick hit this frame to avoid double-processing
let brickHit = null;
const bx = state.ball.x - BALL_R;
const by = state.ball.y - BALL_R;
const bx2 = state.ball.x + BALL_R;
const by2 = state.ball.y + BALL_R;
for (let i = state.bricks.length - 1; i >= 0; i--) {
const b = state.bricks[i];
if (!b.alive) continue;
const brx = b.x;
const bry = b.y;
const bw = b.w;
const bh = b.h;
const br2x = brx + bw;
const br2y = bry + bh;
// AABB check
if (bx < br2x && bx2 > brx && by < br2y && by2 > bry) {
// Determine which edge we hit (use overlap ratios)
const ox = (Math.min(bx2, br2x) - Math.max(bx, brx)) / (BW_PADDING);
const oy = (Math.min(by2, br2y) - Math.max(by, bry)) / (BH_PADDING);
// Prefer vertical hit if more overlap vertically -> flip dy
if (oy > ox * 0.5) {
// Flip dy and dampen slightly for realism
state.ball.dy = -state.ball.dy;
// Reduce speed a bit on brick hit to slow down gradually
const prevMag = Math.sqrt(state.ball.dx**2 + state.ball.dy**2);
state.ball.dx *= 0.98;
state.ball.dy *= 0.98;
state.ball.y = bry + (bh - BALL_R) - oy * bh; // clamp to brick top edge area
} else {
state.ball.dx = -state.ball.dx;
// Side hit - keep speed mostly, small dampen
state.ball.dy *= 0.995;
state.ball.x = brx + (bw - BALL_R) - ox * bw;
}
// Kill brick
b.alive = false;
if (brickHit === null) brickHit = { bx: bx, by: by };
state.score += BRICK_POINTS;
updateScoreDisplay();
checkWin();
}
}
// If ball is stuck inside a brick, nudge it out using last collision point
if (brickHit && !(state.ball.x > brickHit.bx + BALL_R &&
state.ball.x < brickHit.bx - BALL_R &&
state.ball.y > brickHit.by + BALL_R &&
state.ball.y < brickHit.by - BALL_R)) {
// Pull ball out of the brick using its velocity direction
const dx = state.ball.dx;
const dy = state.ball.dy;
const len = Math.sqrt(dx*dx + dy*dy) || 1;
if (state.ball.x > brickHit.bx && state.ball.x < brickHit.bx + brickHit.w) {
// inside horizontally -> push out right
state.ball.x = brickHit.x + brickHit.w + BALL_R + Math.abs(dx)/len * 2;
state.ball.dx *= -1;
} else if (state.ball.y > brickHit.by && state.ball.y < brickHit.by + brickHit.h) {
// inside vertically -> push out up
state.ball.y = brickHit.y + BALL_R + Math.abs(dy)/len * 2;
state.ball.dy *= -1;
} else if (state.ball.x < brickHit.bx) {
state.ball.x = brickHit.x - BALL_R - Math.abs(dx)/len * 2;
state.ball.dx *= -1;
} else if (state.ball.y < brickHit.by) {
state.ball.y = brickHit.y - BALL_R - Math.abs(dy)/len * 2;
state.ball.dy *= -1;
}
}
// Apply ball position from velocity to update grid
state.ball.x += state.ball.dx;
state.ball.y += state.ball.dy;
// Clamp ball to canvas bounds (prevent odd edge cases)
state.ball.x = Math.max(BALL_R, Math.min(CANVAS_W - BALL_R, state.ball.x));
state.ball.y = Math.max(BALL_R, Math.min(CANVAS_H - BALL_R, state.ball.y));
}
function BW_PADDING = 2; // used for overlap ratio calc (inline)
const BH_PADDING = 2;
// ---------- Paddle Collision ----------
function checkPaddleCollision() {
const bx = state.ball.x - BALL_R;
by = state.ball.y - BALL_R;
bx2 = state.ball.x + BALL_R;
by2 = state.ball.y + BALL_R;
const px = state.paddleX;
const py = PADDLE_Y;
const pw = PADDLE_W;
const ph = PADDLE_H;
// Only process if we haven't already collided this frame with something else
if (state.lastCollide) {
// Re-check: is ball now clear of all previously collided objects?
const cx = state.lastCollide.x + (state.lastCollide.type === 'brick' ? 0 : -pw);
const cy = state.lastCollide.y;
if (bx > cx && bx2 < cx + (state.lastCollide.type === 'brick' ? 50 : pw) &&
by < cy && by2 > cy - BALL_R + 1) {
// still overlapping -> resolve later (stuck-in-brick case)
return true; // mark as collision this frame
}
}
if (bx < px + pw && bx2 > px && by < py + ph && by2 > py) {
// Clamp ball to paddle surface to prevent sticking
state.ball.x = px + BALL_R + (state.ball.x - (px + BALL_R)) * 0; // no drift
state.ball.y = py - BALL_R;
// Compute horizontal hit position within paddle (normalized 0..1)
const hitPos = ((state.ball.x - px) / pw); // 0 = left edge, 1 = right edge
const maxAngle = Math.PI / 8; // ~22.5° max angle from vertical
let newDx = 0;
if (hitPos < 0.5) {
newDx = MAX_DX * (hitPos / 0.5);
} else {
newDx = -MAX_DX * ((1 - hitPos) / 0.5);
}
// Vertical component: ensure upward and magnitude preserved
const prevMag = Math.sqrt(state.ball.dx**2 + state.ball.dy**2);
const targetDy = -Math.sqrt(prevMag * prevMag - newDx*newDx);
if (targetDy < MAX_DY) {
// Normalize to max_dy
const factor = MAX_DY / Math.abs(targetDy);
newDx *= factor;
targetDy *= factor;
}
state.ball.dx = newDx;
state.ball.dy = -Math.max(MAX_DY, Math.abs(state.ball.dy)) * (targetDy / Math.abs(state.ball.dy) || 1);
// Simpler robust approach:
state.ball.dy = -(Math.sqrt(prevMag * prevMag - newDx*newDx) || 1);
// Clamp ball inside paddle boundary zone
state.ball.x = px + BALL_R + (state.ball.x - (px + BALL_R)); // keep at hit point
state.ball.y = py - BALL_R;
// Mark collision for this frame
state.lastCollide = { x: px + pw / 2, y: py, type: 'paddle' };
return true;
}
state.lastCollide = null;
return false;
}
// ---------- MAIN UPDATE LOOP ----------
function update() {
// Paddle movement
if (keys.left) {
state.paddleX -= 6 * EASE_K;
}
if (keys.right) {
state.paddleX += 6 * EASE_K;
}
if (dragging) {
movePaddle(lastTouchX); // touch overrides keyboard easing
}
// Ball physics
const mag = Math.sqrt(state.ball.dx**2 + state.ball.dy**2);
// Normalize speed every frame for constant magnitude
const s = BALL_SPEED;
if (mag > 0) {
state.ball.dx *= (s / mag);
state.ball.dy *= (s / mag);
}
// Wall collisions
let processedWall = false;
if (state.ball.x - BALL_R < 0) {
state.ball.dx = -state.ball.dx;
state.ball.x = BALL_R;
processedWall = true;
} else if (state.ball.x + BALL_R > CANVAS_W) {
state.ball.dx = -state.ball.dx;
state.ball.x = CANVAS_W - BALL_R;
processedWall = true;
}
if (!processedWall || state.ball.y - BALL_R < 0) {
state.ball.dy = -state.ball.dy;
state.ball.y = BALL_R;
} else if (state.ball.y + BALL_R > CANVAS_H) {
// Bottom: Game Over if below paddle
const ballCenterY = state.ball.y;
if (ballCenterY > PADDLE_Y + 8) {
gameOver();
} else {
state.ball.dy = -state.ball.dy;
state.ball.y = PADDLE_Y - BALL_R + 2; // keep near paddle for safe bounce
}
}
// Brick collisions (AABB, single per frame)
let brickHit = false;
// Use the last processed collision to avoid double-hit same frame
if (!state.lastCollide || state.lastCollide.type !== 'brick') {
if (checkBrickCollision()) brickHit = true;
}
// Paddle collision
const paddleCol = checkPaddleCollision();
// If ball is still overlapping any solid object, nudge out using velocity
if (state.lastCollide) {
const lx = state.lastCollide.x;
const ly = state.lastCollide.y;
const ox = (bx2 > lx ? 1 : 0); // was right side?
const oy = (by2 > ly ? 1 : 0); // was top side?
// Simplified: push out in direction of velocity from nearest edge
const dx = state.ball.dx;
const dy = state.ball.dy;
const len = Math.sqrt(dx*dx + dy*dy) || 1;
let nx = lx - BALL_R - (dx/len) * 4;
let ny = ly - BALL_R - (dy/len) * 4;
if (bx > lx - 2 && bx < cx + 50) nx = lx + BALL_R + (dx/len) * 4;
if (by < ly + 2) ny = ly + BALL_R + (dy/len) * 4;
state.ball.x = Math.max(BALL_R, Math.min(CANVAS_W - BALL_R, nx));
state.ball.y = Math.max(BALL_R, Math.min(CANVAS_H - BALL_R, ny));
}
}
function checkBrickCollision() {
const bx = state.ball.x - BALL_R;
by = state.ball.y - BALL_R;
bx2 = state.ball.x + BALL_R;
by2 = state.ball.y + BALL_R;
for (let i = state.bricks.length - 1; i >= 0; i--) {
const b = state.bricks[i];
if (!b.alive) continue;
const brx = b.x;
const bry = b.y;
const bw = b.w;
const bh = b.h;
// AABB
if (bx < brx + bw && bx2 > brx && by < bry + bh && by2 > bry) {
// Overlap ratio to determine edge
const ox = (Math.min(bx2, brx+bw) - Math.max(bx, brx)) / 2;
const oy = (Math.min(by2, bry+mh) - Math.max(by, bry)) / 2;
// prefer vertical if hit spans more height-wise
if (oy > ox * 0.55) {
state.ball.dy = -state.ball.dy;
// Keep speed similar, slightly dampen
const prevMag = Math.sqrt(state.ball.dx**2 + state.ball.dy**2);
state.ball.dx *= 0.99;
state.ball.y = bry + bh - BALL_R - (oy * (bh - 2*BALL_R));
state.ball.x = brx + ((brx+bw) - bx) * 0.5 + BALL_R; // center horizontally in brick
} else {
state.ball.dx = -state.ball.dx;
state.ball.dy *= 0.995;
state.ball.x = brx + bw - BALL_R - (ox * (bw - 2*BALL_R));
state.ball.y = bry + ((bry+mh) - by) * 0.5 + BALL_R;
}
b.alive = false;
state.lastCollide = { x: brx + bw/2, y: bry, type: 'brick' };
return true;
}
}
return false;
}
// ---------- RENDER ----------
function draw() {
ctx.clearRect(0, 0, CANVAS_W, CANVAS_H);
// Background
ctx.fillStyle = '#1a1a1a';
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
// Grid lines (optional subtle)
ctx.strokeStyle = '#333';
ctx.lineWidth = 1;
for (let x = 0; x <= CANVAS_W; x += 50) {
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, CANVAS_H); ctx.stroke();
}
for (let y = 0; y <= CANVAS_H; y += 60) {
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(CANVAS_W, y); ctx.stroke();
}
// Bricks
const rowGap = 12;
const colGap = 8;
for (let r = 0; r < BRICK_ROWS; r++) {
for (let c = 0; c < BRICK_COLS; c++) {
const b = state.bricks[r][c]; // re-initialize? We store flat
}
}
// Flatten bricks array properly
let brickList = [];
for (let r = 0; r < BRICK_ROWS; r++) {
for (let c = 0; c < BRICK_COLS; c++) {
const x = c * (BRICK_W + colGap);
const y = r * (BRICK_H + rowGap);
brickList.push({ x, y, w: BRICK_W, h: BRICK_H, alive: true });
}
}
// Update state.bricks from new list if needed
state.bricks = brickList;
if (state.gameMode === 'playing') {
for (const b of state.bricks) {
if (b.alive) {
ctx.fillStyle = '#00ffff';
ctx.beginPath();
ctx.roundRect(b.x, b.y, b.w, b.h, 3);
ctx.fill();
// Brick outline
ctx.strokeStyle = '#9fffff';
ctx.lineWidth = 1;
ctx.stroke();
}
}
}
// Paddle
ctx.fillStyle = '#ffffff';
ctx.beginPath();
ctx.roundRect(state.paddleX, PADDLE_Y, PADDLE_W, PADDLE_H, 5);
ctx.fill();
ctx.strokeStyle = '#999999';
ctx.stroke();
// Ball
ctx.fillStyle = '#ffff00';
ctx.beginPath();
ctx.arc(state.ball.x, state.ball.y, BALL_R, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#d4a017';
ctx.lineWidth = 1;
ctx.stroke();
// Score & Lives
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 18px Arial';
ctx.textAlign = 'left' ;
ctx.fillText('Score: ' + state.score, 16, 32);
const liveX = CANVAS_W - 200;
ctx.fillText('Lives: ' + state.lives, liveX, 32);
// Pause / game over / win overlays
if (state.gameMode === 'gameover' || state.gameMode === 'won') {
drawOverlay();
}
// Start prompt (shown initially)
if (state.gameMode === 'waiting') {
ctx.fillStyle = '#fff';
ctx.font = '30px bold Arial';
ctx.textAlign = 'center';
ctx.fillText('CLICK or TAP to Start', CANVAS_W/2, 290);
ctx.font = '16px Arial';
ctx.fillText('Breakout', CANVAS_W/2, 320);
ctx.fillText('Desktop: Mouse/Arrows Mobile: Touch Drag', CANVAS_W/2, 350);
}
}
// ---------- OVERLAY ----------
function drawOverlay() {
const overlay = document.createElement('div');
overlay.style.cssText = `
position:fixed;inset:0;background:rgba(26,26,26,0.85);
display:flex;flex-direction:center;justify-content:center;align-items:center;
z-index:10;
`;
document.body.appendChild(overlay);
const txt = document.createElement('div');
txt.style.cssText = 'color:#fff;font-family:Arial,sans-serif;font-size:48px;font-weight:bold;text-align:center;line-height:1.2';
let msg = state.gameMode === 'won' ? 'YOU WON!' : 'GAME OVER';
txt.textContent = msg;
const scoreEl = document.createElement('div');
scoreEl.style.cssText = 'color:#00ffff;font-size:24px;margin-top:20px;text-align:center;';
scoreEl.textContent = 'Score: ' + state.score;
const btn = document.createElement('button');
btn.textContent = 'Restart';
btn.style.cssText = 'margin-top:30px;padding:12px 28px;font-size:18px;border:none;border-radius:8px;background:#00ffff;color:#fff;cursor:pointer;';
btn.addEventListener('click', restartGame);
overlay.appendChild(txt);
overlay.appendChild(scoreEl);
overlay.appendChild(btn);
// Block canvas interaction during overlay
canvas.style.pointerEvents = 'none';
setTimeout(() => {
overlay.style.transition('opacity 0.3s';
overlay.style.opacity = '0';
setTimeout(() => overlay.remove(), 400);
}, 600);
}
function restartGame() {
initBricks(); // rebuild brick array (we already rebuild in draw, but safe)
state.ball.dx