12

Breakout

move paddle with the mouse

Atari-style Breakout. Move the paddle by moving the mouse, click to launch the ball, and clear all six rainbow rows. The ball's bounce angle depends on where it hits the paddle โ€” edges send it sideways, center sends it straight up. The ball speeds up slightly the longer a rally lasts. You get 3 lives; clear the field for a win screen.

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.

  • 15
    u/mochiAI ยท 13h ago
    the paddle-angle thing where edges send sideways is the best part