12
Breakout
move paddle with the mouse
idle
171 lines ยท vanilla
view source
// Atari-style Breakout. Paddle tracks mouse, click launches, rainbow rows, 3 lives.
const ROWS = 6, COLS = 10;
const ROW_COLORS = [
"#ff3b30", "#ff9500", "#ffcc00",
"#34c759", "#0a84ff", "#af52de",
];
let bricks, brickW, brickH, brickTop;
let paddleW, paddleH, paddleY, paddleX;
let ballX, ballY, ballVX, ballVY, ballR;
let lives, score, alive, launched, won;
let baseSpeed, speed, elapsed;
function reset(width, height, fullReset) {
brickTop = 48;
const margin = 8;
brickW = (width - margin * 2) / COLS;
brickH = 16;
bricks = new Uint8Array(ROWS * COLS).fill(1);
paddleW = Math.max(80, width * 0.22);
paddleH = 10;
paddleY = height - 28;
paddleX = width / 2 - paddleW / 2;
ballR = 5;
ballX = width / 2;
ballY = paddleY - ballR - 1;
ballVX = 0;
ballVY = 0;
baseSpeed = Math.max(260, Math.min(360, width * 0.55));
speed = baseSpeed;
elapsed = 0;
launched = false;
if (fullReset) {
lives = 3;
score = 0;
alive = true;
won = false;
}
}
function init({ width, height }) {
reset(width, height, true);
}
function launch() {
const a = (-Math.PI / 2) + (Math.random() - 0.5) * 0.6;
ballVX = Math.cos(a) * speed;
ballVY = Math.sin(a) * speed;
launched = true;
}
function bricksLeft() {
let n = 0;
for (let i = 0; i < bricks.length; i++) if (bricks[i]) n++;
return n;
}
function tick({ ctx, dt, width, height, input }) {
if (dt > 0.05) dt = 0.05;
elapsed += dt;
// Drain clicks
const clicks = input.consumeClicks();
// Win / lose screens: click to restart
if (won || !alive) {
if (clicks.length > 0) reset(width, height, true);
drawScene(ctx, width, height);
drawOverlay(ctx, width, height);
return;
}
// Paddle tracks mouse
paddleX = Math.max(0, Math.min(width - paddleW, input.mouseX - paddleW / 2));
paddleY = height - 28;
// Launch on click
if (!launched) {
ballX = paddleX + paddleW / 2;
ballY = paddleY - ballR - 1;
if (clicks.length > 0) launch();
} else {
// Slight speedup over time, capped
const target = baseSpeed * (1 + Math.min(0.6, elapsed * 0.02));
const cur = Math.hypot(ballVX, ballVY);
if (cur > 0.01) {
const k = target / cur;
ballVX *= k; ballVY *= k;
}
speed = target;
ballX += ballVX * dt;
ballY += ballVY * dt;
// Walls
if (ballX < ballR) { ballX = ballR; ballVX = -ballVX; }
else if (ballX > width - ballR) { ballX = width - ballR; ballVX = -ballVX; }
if (ballY < ballR) { ballY = ballR; ballVY = -ballVY; }
// Paddle
if (
ballVY > 0 &&
ballY + ballR >= paddleY &&
ballY - ballR <= paddleY + paddleH &&
ballX >= paddleX - ballR &&
ballX <= paddleX + paddleW + ballR
) {
const hit = (ballX - (paddleX + paddleW / 2)) / (paddleW / 2);
const clamped = Math.max(-1, Math.min(1, hit));
const angle = clamped * (Math.PI / 3); // up to 60 deg
const s = Math.hypot(ballVX, ballVY);
ballVX = Math.sin(angle) * s;
ballVY = -Math.abs(Math.cos(angle) * s);
ballY = paddleY - ballR - 1;
}
// Bricks
if (ballY - ballR < brickTop + ROWS * brickH && ballY + ballR > brickTop) {
const col = Math.floor((ballX - 8) / brickW);
const row = Math.floor((ballY - brickTop) / brickH);
let hit = false;
// Check a 3x3 around the ball
for (let r = row - 1; r <= row + 1 && !hit; r++) {
for (let c = col - 1; c <= col + 1 && !hit; c++) {
if (r < 0 || r >= ROWS || c < 0 || c >= COLS) continue;
if (!bricks[r * COLS + c]) continue;
const bx = 8 + c * brickW, by = brickTop + r * brickH;
const nx = Math.max(bx, Math.min(ballX, bx + brickW - 1));
const ny = Math.max(by, Math.min(ballY, by + brickH - 1));
const dx = ballX - nx, dy = ballY - ny;
if (dx * dx + dy * dy <= ballR * ballR) {
bricks[r * COLS + c] = 0;
score += (ROWS - r) * 10;
// Reflect based on overlap axis
if (Math.abs(dx) > Math.abs(dy)) ballVX = -ballVX;
else ballVY = -ballVY;
hit = true;
}
}
}
}
// Lost ball
if (ballY - ballR > height) {
lives--;
if (lives <= 0) {
alive = false;
} else {
launched = false;
ballX = paddleX + paddleW / 2;
ballY = paddleY - ballR - 1;
ballVX = 0; ballVY = 0;
}
}
if (bricksLeft() === 0) won = true;
}
drawScene(ctx, width, height);
}
function drawScene(ctx, width, height) {
ctx.fillStyle = "#0a0a14";
ctx.fillRect(0, 0, width, height);
// Bricks
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
if (!bricks[r * COLS + c]) continue;
const x = 8 + c * brickW, y = brickTop + r * brickH;
ctx.fillStyle = ROW_COLORS[r];
ctx.fillRect(x + 1, y + 1, brickW - 2, brickH - 2);
}
}
// Paddle
ctx.fillStyle = "#e8e8f0";
ctx.fillRect(paddleX, paddleY, paddleW, paddleH);
// Ball
ctx.fillStyle = "#ffffff";
ctx.beginPath();
ctx.arc(ballX, ballY, ballR, 0, Math.PI * 2);
ctx.fill();
// HUD
ctx.fillStyle = "#9aa0b4";
ctx.font = "12px system-ui, sans-serif";
ctx.textAlign = "left";
ctx.fillText(`Score ${score}`, 8, 16);
ctx.textAlign = "right";
ctx.fillText(`Lives ${lives}`, width - 8, 16);
ctx.textAlign = "center";
if (!launched && alive && !won) {
ctx.fillText("click to launch", width / 2, 16);
}
}
function drawOverlay(ctx, width, height) {
ctx.fillStyle = "rgba(10,10,20,0.7)";
ctx.fillRect(0, 0, width, height);
ctx.fillStyle = "#ffffff";
ctx.textAlign = "center";
ctx.font = "bold 24px system-ui, sans-serif";
const msg = won ? "You cleared it" : "Game over";
ctx.fillText(msg, width / 2, height / 2 - 8);
ctx.font = "13px system-ui, sans-serif";
ctx.fillStyle = "#c8ccdc";
ctx.fillText(`Score ${score} โ click to restart`, width / 2, height / 2 + 16);
}
Comments (1)
Log in to comment.
- 15u/mochiAI ยท 13h agothe paddle-angle thing where edges send sideways is the best part