42

Space Invaders

arrows + space to shoot

The 1978 Taito arcade classic. A 5x10 grid of aliens marches side-to-side, dropping one row every time the formation hits an edge, and accelerating as you thin their ranks โ€” the original game's escalating tempo was a side-effect of the CPU rendering fewer sprites per frame, but here it is intentional. Move the cannon with ArrowLeft/ArrowRight and fire with Space. Front-row aliens fire back at random; four green bunkers absorb shots and erode pixel-by-pixel. A pink UFO occasionally crosses the top for a 50โ€“300 point bonus. Lose all three lives โ€” or let the swarm reach the ground โ€” and it is game over. Click to restart.

idle
253 lines ยท vanilla
view source
let W = 0, H = 0;
let cannon, aliens, dir, bullets, eBullets, bunkers, ufo, particles;
let score, lives, gameOver, win, stepTimer, shootTimer, ufoTimer, formationDrop;

const COLS = 10, ROWS = 5;
const ALIEN_W = 22, ALIEN_H = 14, ALIEN_SX = 32, ALIEN_SY = 22;
const ALIEN_POINTS = [40, 20, 20, 10, 10];

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

function buildFormation() {
  aliens = [];
  const startX = (W - (COLS - 1) * ALIEN_SX) / 2;
  const startY = 60;
  for (let r = 0; r < ROWS; r++) {
    for (let c = 0; c < COLS; c++) {
      aliens.push({ x: startX + c * ALIEN_SX, y: startY + r * ALIEN_SY, row: r, alive: true });
    }
  }
  dir = 1;
  stepTimer = 0;
  shootTimer = rand(0.8, 1.6);
}

function buildBunkers() {
  bunkers = [];
  const N = 4, BW = 44, BH = 22;
  const gap = (W - N * BW) / (N + 1);
  const by = H - 90;
  for (let i = 0; i < N; i++) {
    const bx = gap + i * (BW + gap);
    const cells = new Uint8Array(11 * 6);
    for (let y = 0; y < 6; y++) for (let x = 0; x < 11; x++) {
      // notch out the bottom-center to look like the original
      if (y >= 4 && x >= 4 && x <= 6) continue;
      cells[y * 11 + x] = 1;
    }
    bunkers.push({ x: bx, y: by, cells });
  }
}

function reset() {
  cannon = { x: W / 2, y: H - 30, w: 28, h: 10, cool: 0 };
  bullets = [];
  eBullets = [];
  particles = [];
  ufo = null;
  ufoTimer = rand(12, 20);
  score = 0;
  lives = 3;
  gameOver = false;
  win = false;
  formationDrop = 0;
  buildFormation();
  buildBunkers();
}

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

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

function aliveCount() { let n = 0; for (const a of aliens) if (a.alive) n++; return n; }

function hitBunker(bx, by) {
  for (const b of bunkers) {
    if (bx < b.x || bx > b.x + 44 || by < b.y || by > b.y + 22) continue;
    const cx = Math.floor((bx - b.x) / 4);
    const cy = Math.floor((by - b.y) / 4);
    for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
      const x = cx + dx, y = cy + dy;
      if (x >= 0 && x < 11 && y >= 0 && y < 6) b.cells[y * 11 + x] = 0;
    }
    return true;
  }
  return false;
}

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.55)";
  ctx.fillRect(0, 0, W, H);

  if (gameOver || win) {
    for (const c of input.consumeClicks()) { if (c) { reset(); break; } }
  } else {
    const sp = 220;
    if (input.keyDown("ArrowLeft")) cannon.x -= sp * dt;
    if (input.keyDown("ArrowRight")) cannon.x += sp * dt;
    cannon.x = Math.max(cannon.w / 2 + 4, Math.min(W - cannon.w / 2 - 4, cannon.x));
    cannon.cool -= dt;
    if (cannon.cool <= 0 && (input.justPressed(" ") || input.justPressed("Space") || input.justPressed("Spacebar"))) {
      bullets.push({ x: cannon.x, y: cannon.y - 8, vy: -380 });
      cannon.cool = 0.45;
    }

    // formation step
    const n = aliveCount();
    const speed = 0.9 - 0.75 * (1 - n / (COLS * ROWS));
    stepTimer -= dt;
    if (stepTimer <= 0) {
      stepTimer = Math.max(0.06, speed * 0.55);
      let minX = 1e9, maxX = -1e9, maxY = -1e9;
      for (const a of aliens) if (a.alive) {
        a.x += dir * 6;
        if (a.x < minX) minX = a.x;
        if (a.x + ALIEN_W > maxX) maxX = a.x + ALIEN_W;
        if (a.y + ALIEN_H > maxY) maxY = a.y + ALIEN_H;
      }
      if (minX < 12 || maxX > W - 12) {
        dir = -dir;
        for (const a of aliens) if (a.alive) a.y += 12;
        formationDrop++;
      }
      if (maxY >= cannon.y - 8) { gameOver = true; }
    }

    // alien shooting (front-row only)
    shootTimer -= dt;
    if (shootTimer <= 0) {
      shootTimer = rand(0.5, 1.4) * Math.max(0.4, n / (COLS * ROWS));
      const front = new Map();
      for (const a of aliens) if (a.alive) {
        const k = Math.round(a.x);
        const prev = front.get(k);
        if (!prev || a.y > prev.y) front.set(k, a);
      }
      const arr = [...front.values()];
      if (arr.length) {
        const s = arr[(Math.random() * arr.length) | 0];
        eBullets.push({ x: s.x + ALIEN_W / 2, y: s.y + ALIEN_H, vy: 180 + formationDrop * 8 });
      }
    }

    // UFO
    if (!ufo) {
      ufoTimer -= dt;
      if (ufoTimer <= 0) {
        const fromLeft = Math.random() < 0.5;
        ufo = { x: fromLeft ? -30 : W + 30, y: 28, vx: fromLeft ? 110 : -110, value: [50, 100, 150, 200, 300][(Math.random() * 5) | 0] };
        ufoTimer = rand(14, 24);
      }
    } else {
      ufo.x += ufo.vx * dt;
      if (ufo.x < -40 || ufo.x > W + 40) ufo = null;
    }

    if (n === 0) win = true;
  }

  // bullets up
  for (let i = bullets.length - 1; i >= 0; i--) {
    const b = bullets[i];
    b.y += b.vy * dt;
    if (b.y < 0) { bullets.splice(i, 1); continue; }
    if (hitBunker(b.x, b.y)) { bullets.splice(i, 1); explode(b.x, b.y, 4, "#9fe870"); continue; }
    let hit = false;
    for (const a of aliens) {
      if (!a.alive) continue;
      if (b.x >= a.x && b.x <= a.x + ALIEN_W && b.y >= a.y && b.y <= a.y + ALIEN_H) {
        a.alive = false;
        score += ALIEN_POINTS[a.row];
        explode(a.x + ALIEN_W / 2, a.y + ALIEN_H / 2, 12, "#9fe870");
        hit = true; break;
      }
    }
    if (hit) { bullets.splice(i, 1); continue; }
    if (ufo && b.y <= ufo.y + 8 && b.y >= ufo.y - 8 && b.x >= ufo.x - 18 && b.x <= ufo.x + 18) {
      score += ufo.value;
      explode(ufo.x, ufo.y, 24, "#ff6ab0");
      ufo = null;
      bullets.splice(i, 1);
    }
  }

  // enemy bullets down
  for (let i = eBullets.length - 1; i >= 0; i--) {
    const b = eBullets[i];
    b.y += b.vy * dt;
    if (b.y > H) { eBullets.splice(i, 1); continue; }
    if (hitBunker(b.x, b.y)) { eBullets.splice(i, 1); explode(b.x, b.y, 4, "#ffd17a"); continue; }
    if (!gameOver && b.x >= cannon.x - cannon.w / 2 && b.x <= cannon.x + cannon.w / 2 && b.y >= cannon.y - cannon.h / 2 && b.y <= cannon.y + cannon.h / 2) {
      eBullets.splice(i, 1);
      explode(cannon.x, cannon.y, 28, "#7ad1ff");
      lives -= 1;
      if (lives <= 0) gameOver = true;
    }
  }

  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.95; p.vy *= 0.95;
    p.life -= dt;
    if (p.life <= 0) particles.splice(i, 1);
  }

  // --- Render ---
  // bunkers
  ctx.fillStyle = "#9fe870";
  for (const b of bunkers) {
    for (let y = 0; y < 6; y++) for (let x = 0; x < 11; x++) {
      if (b.cells[y * 11 + x]) ctx.fillRect(b.x + x * 4, b.y + y * 4, 4, 4);
    }
  }

  // aliens
  const blink = Math.floor(stepTimer * 6) % 2 === 0;
  for (const a of aliens) {
    if (!a.alive) continue;
    ctx.fillStyle = a.row === 0 ? "#ff6ab0" : a.row < 3 ? "#7ad1ff" : "#9fe870";
    ctx.fillRect(a.x + 2, a.y + 2, ALIEN_W - 4, ALIEN_H - 4);
    ctx.fillRect(a.x, a.y + 4, ALIEN_W, ALIEN_H - 8);
    if (blink) {
      ctx.fillRect(a.x - 2, a.y + ALIEN_H, 4, 3);
      ctx.fillRect(a.x + ALIEN_W - 2, a.y + ALIEN_H, 4, 3);
    } else {
      ctx.fillRect(a.x + 4, a.y + ALIEN_H, 4, 3);
      ctx.fillRect(a.x + ALIEN_W - 8, a.y + ALIEN_H, 4, 3);
    }
    ctx.fillStyle = "#0b0b0f";
    ctx.fillRect(a.x + 6, a.y + 5, 3, 3);
    ctx.fillRect(a.x + ALIEN_W - 9, a.y + 5, 3, 3);
  }

  if (ufo) {
    ctx.fillStyle = "#ff6ab0";
    ctx.fillRect(ufo.x - 16, ufo.y - 3, 32, 6);
    ctx.fillRect(ufo.x - 10, ufo.y - 7, 20, 4);
    ctx.fillRect(ufo.x - 20, ufo.y + 3, 40, 3);
  }

  // bullets
  ctx.fillStyle = "#ffd17a";
  for (const b of bullets) ctx.fillRect(b.x - 1, b.y - 6, 2, 6);
  ctx.fillStyle = "#ff6ab0";
  for (const b of eBullets) ctx.fillRect(b.x - 1, b.y, 2, 6);

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

  // cannon
  if (!gameOver) {
    ctx.fillStyle = "#7ad1ff";
    ctx.fillRect(cannon.x - cannon.w / 2, cannon.y - 2, cannon.w, 6);
    ctx.fillRect(cannon.x - 2, cannon.y - 8, 4, 6);
  }

  // ground line
  ctx.fillStyle = "#9fe870";
  ctx.fillRect(0, H - 16, W, 2);

  // HUD
  ctx.fillStyle = "rgba(0,0,0,0.55)";
  ctx.fillRect(10, 10, 200, 40);
  ctx.fillStyle = "#fff";
  ctx.font = "14px monospace";
  ctx.textAlign = "left";
  ctx.textBaseline = "alphabetic";
  ctx.fillText(`SCORE ${score}`, 20, 28);
  ctx.fillText(`LIVES`, 20, 44);
  for (let i = 0; i < lives; i++) {
    ctx.fillStyle = "#7ad1ff";
    ctx.fillRect(80 + i * 18, 36, 12, 6);
    ctx.fillRect(84 + i * 18, 32, 4, 4);
  }

  if (gameOver || win) {
    ctx.fillStyle = "rgba(0,0,0,0.78)";
    ctx.fillRect(W / 2 - 150, H / 2 - 56, 300, 112);
    ctx.strokeStyle = win ? "#9fe870" : "#ff6ab0";
    ctx.strokeRect(W / 2 - 150, H / 2 - 56, 300, 112);
    ctx.fillStyle = "#fff";
    ctx.textAlign = "center";
    ctx.font = "bold 22px ui-sans-serif, system-ui";
    ctx.fillText(win ? "EARTH SAVED" : "GAME OVER", W / 2, H / 2 - 14);
    ctx.font = "14px monospace";
    ctx.fillText(`final score ${score}`, W / 2, H / 2 + 10);
    ctx.fillText("click to restart", W / 2, H / 2 + 32);
  }
}

Comments (0)

Log in to comment.