36

Asteroids

arrows steer + thrust ยท space shoots

The 1979 vector-graphics arcade classic. Your triangular ship drifts with Newtonian inertia through a field of tumbling asteroids โ€” there is no friction, only the slow exponential drag of empty space. Rotate with ArrowLeft/ArrowRight, fire the thruster with ArrowUp, and shoot with Space. Every asteroid you hit splits into two smaller ones (big medium small), and the playfield wraps toroidally on all four edges. Clear the wave to spawn a denser one; collide with a rock and you lose one of three lives. Click to restart after game over.

idle
238 lines ยท vanilla
view source
let W = 0, H = 0;
let ship, asteroids, bullets, particles;
let score, lives, gameOver, respawnTimer;

function rand(a, b) { return a + Math.random() * (b - a); }

function makeShip() {
  return { x: W / 2, y: H / 2, vx: 0, vy: 0, a: -Math.PI / 2, thrust: false, invuln: 2.0 };
}

function makeAsteroid(x, y, r) {
  const a = rand(0, Math.PI * 2);
  const sp = rand(20, 60) * (40 / Math.max(20, r));
  const verts = 8 + ((Math.random() * 5) | 0);
  const shape = [];
  for (let i = 0; i < verts; i++) shape.push(rand(0.75, 1.15));
  return {
    x, y,
    vx: Math.cos(a) * sp,
    vy: Math.sin(a) * sp,
    r,
    rot: rand(-1, 1),
    ang: rand(0, Math.PI * 2),
    shape,
  };
}

function spawnWave(n) {
  asteroids.length = 0;
  for (let i = 0; i < n; i++) {
    let x, y;
    do {
      x = Math.random() * W;
      y = Math.random() * H;
    } while (Math.hypot(x - W / 2, y - H / 2) < 120);
    asteroids.push(makeAsteroid(x, y, 40));
  }
}

function reset() {
  ship = makeShip();
  bullets = [];
  particles = [];
  score = 0;
  lives = 3;
  gameOver = false;
  respawnTimer = 0;
  spawnWave(4);
}

function init({ width, height }) {
  W = width; H = height;
  reset();
}

function wrap(p) {
  if (p.x < 0) p.x += W; else if (p.x > W) p.x -= W;
  if (p.y < 0) p.y += H; else if (p.y > H) p.y -= H;
}

function explode(x, y, n, col) {
  for (let i = 0; i < n; i++) {
    const a = rand(0, Math.PI * 2);
    const s = rand(40, 180);
    particles.push({ x, y, vx: Math.cos(a) * s, vy: Math.sin(a) * s, life: rand(0.4, 1.0), col });
  }
}

function splitAsteroid(i) {
  const a = asteroids[i];
  score += a.r > 30 ? 20 : a.r > 18 ? 50 : 100;
  explode(a.x, a.y, 14, "#fff");
  asteroids.splice(i, 1);
  if (a.r > 18) {
    const nr = a.r * 0.55;
    asteroids.push(makeAsteroid(a.x, a.y, nr));
    asteroids.push(makeAsteroid(a.x, a.y, nr));
  }
}

function tick({ ctx, dt, width, height, input }) {
  if (width !== W || height !== H) { W = width; H = height; }
  if (dt > 0.05) dt = 0.05;

  ctx.fillStyle = "rgba(0,0,0,0.35)";
  ctx.fillRect(0, 0, W, H);

  if (gameOver) {
    for (const c of input.consumeClicks()) { if (c) { reset(); break; } }
  } else {
    if (input.keyDown("ArrowLeft")) ship.a -= 3.5 * dt;
    if (input.keyDown("ArrowRight")) ship.a += 3.5 * dt;
    ship.thrust = input.keyDown("ArrowUp");
    if (ship.thrust) {
      const ax = Math.cos(ship.a) * 180;
      const ay = Math.sin(ship.a) * 180;
      ship.vx += ax * dt;
      ship.vy += ay * dt;
      particles.push({
        x: ship.x - Math.cos(ship.a) * 10,
        y: ship.y - Math.sin(ship.a) * 10,
        vx: -Math.cos(ship.a) * 100 + rand(-30, 30),
        vy: -Math.sin(ship.a) * 100 + rand(-30, 30),
        life: 0.4, col: "#ff9a3c",
      });
    }
    ship.vx *= 0.995;
    ship.vy *= 0.995;
    ship.x += ship.vx * dt;
    ship.y += ship.vy * dt;
    wrap(ship);
    if (ship.invuln > 0) ship.invuln -= dt;

    if (input.justPressed(" ") || input.justPressed("Space") || input.justPressed("Spacebar")) {
      bullets.push({
        x: ship.x + Math.cos(ship.a) * 12,
        y: ship.y + Math.sin(ship.a) * 12,
        vx: Math.cos(ship.a) * 420 + ship.vx,
        vy: Math.sin(ship.a) * 420 + ship.vy,
        life: 1.0,
      });
    }
  }

  for (let i = bullets.length - 1; i >= 0; i--) {
    const b = bullets[i];
    b.x += b.vx * dt; b.y += b.vy * dt;
    wrap(b);
    b.life -= dt;
    if (b.life <= 0) bullets.splice(i, 1);
  }

  for (const a of asteroids) {
    a.x += a.vx * dt; a.y += a.vy * dt;
    a.ang += a.rot * dt;
    wrap(a);
  }

  for (let i = particles.length - 1; i >= 0; i--) {
    const p = particles[i];
    p.x += p.vx * dt; p.y += p.vy * dt;
    p.vx *= 0.96; p.vy *= 0.96;
    p.life -= dt;
    if (p.life <= 0) particles.splice(i, 1);
  }

  // Bullet vs asteroid
  for (let i = asteroids.length - 1; i >= 0; i--) {
    const a = asteroids[i];
    for (let j = bullets.length - 1; j >= 0; j--) {
      const b = bullets[j];
      if (Math.hypot(a.x - b.x, a.y - b.y) < a.r) {
        bullets.splice(j, 1);
        splitAsteroid(i);
        break;
      }
    }
  }

  // Ship vs asteroid
  if (!gameOver && ship.invuln <= 0) {
    for (const a of asteroids) {
      if (Math.hypot(a.x - ship.x, a.y - ship.y) < a.r + 8) {
        explode(ship.x, ship.y, 30, "#7ad1ff");
        lives -= 1;
        if (lives <= 0) {
          gameOver = true;
        } else {
          ship = makeShip();
        }
        break;
      }
    }
  }

  if (!gameOver && asteroids.length === 0) {
    spawnWave(Math.min(8, 4 + ((score / 500) | 0)));
  }

  // --- Render ---
  ctx.lineWidth = 1.5;
  ctx.strokeStyle = "#cfd6e4";

  for (const a of asteroids) {
    ctx.beginPath();
    for (let k = 0; k < a.shape.length; k++) {
      const th = a.ang + (k / a.shape.length) * Math.PI * 2;
      const r = a.r * a.shape[k];
      const px = a.x + Math.cos(th) * r;
      const py = a.y + Math.sin(th) * r;
      if (k === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
    }
    ctx.closePath();
    ctx.stroke();
  }

  for (const b of bullets) {
    ctx.fillStyle = "#ffd17a";
    ctx.fillRect(b.x - 1.5, b.y - 1.5, 3, 3);
  }

  for (const p of particles) {
    ctx.fillStyle = p.col;
    ctx.globalAlpha = Math.max(0, Math.min(1, p.life));
    ctx.fillRect(p.x - 1, p.y - 1, 2, 2);
  }
  ctx.globalAlpha = 1;

  if (!gameOver) {
    const blink = ship.invuln > 0 ? (Math.floor(ship.invuln * 12) % 2 === 0) : true;
    if (blink) {
      ctx.save();
      ctx.translate(ship.x, ship.y);
      ctx.rotate(ship.a);
      ctx.strokeStyle = "#7ad1ff";
      ctx.beginPath();
      ctx.moveTo(12, 0);
      ctx.lineTo(-8, 7);
      ctx.lineTo(-4, 0);
      ctx.lineTo(-8, -7);
      ctx.closePath();
      ctx.stroke();
      if (ship.thrust && Math.random() < 0.7) {
        ctx.strokeStyle = "#ff9a3c";
        ctx.beginPath();
        ctx.moveTo(-4, 0);
        ctx.lineTo(-12 - Math.random() * 4, 0);
        ctx.stroke();
      }
      ctx.restore();
    }
  }

  // HUD
  ctx.fillStyle = "rgba(0,0,0,0.55)";
  ctx.fillRect(10, 10, 180, 56);
  ctx.fillStyle = "#fff";
  ctx.font = "14px monospace";
  ctx.textAlign = "left";
  ctx.textBaseline = "alphabetic";
  ctx.fillText(`SCORE  ${score}`, 20, 30);
  ctx.fillText(`LIVES`, 20, 52);
  for (let i = 0; i < lives; i++) {
    const cx = 80 + i * 18, cy = 47;
    ctx.strokeStyle = "#7ad1ff";
    ctx.beginPath();
    ctx.moveTo(cx + 7, cy);
    ctx.lineTo(cx - 5, cy + 5);
    ctx.lineTo(cx - 2, cy);
    ctx.lineTo(cx - 5, cy - 5);
    ctx.closePath();
    ctx.stroke();
  }

  if (gameOver) {
    ctx.fillStyle = "rgba(0,0,0,0.7)";
    ctx.fillRect(W / 2 - 140, H / 2 - 50, 280, 100);
    ctx.strokeStyle = "#7ad1ff";
    ctx.strokeRect(W / 2 - 140, H / 2 - 50, 280, 100);
    ctx.fillStyle = "#fff";
    ctx.textAlign = "center";
    ctx.font = "bold 22px ui-sans-serif, system-ui";
    ctx.fillText("GAME OVER", W / 2, H / 2 - 10);
    ctx.font = "14px monospace";
    ctx.fillText(`final score ${score}`, W / 2, H / 2 + 14);
    ctx.fillText("click to restart", W / 2, H / 2 + 34);
  }
}

Comments (0)

Log in to comment.