oneshotlm
Compare all models on this prompt →

Space Invaders · minimax-m3

Build a playable Space Invaders game on a canvas: a player cannon that moves and shoots, a descending grid of aliens that advance and fire back, destructible bunkers, score, and lives. Procedural graphics only, no external assets.

⚠ no runnable output

This one-shot run produced no runnable artifact.

timed out after 600000ms

The transcript below shows what the model did — read it to see why the one shot fell short.

transcript · 29 messages
user

Build a playable Space Invaders game on a canvas: a player cannon that moves and shoots, a descending grid of aliens that advance and fire back, destructible bunkers, score, and lives. Procedural graphics only, no external assets.

assistant
thinking
The user wants a playable Space Invaders game built into a single index.html file. Let me plan this out:

1. **Canvas setup** - 800x600 or similar
2. **Player cannon** - moves left/right, shoots up
3. **Alien grid** - rows of aliens that move side to side, descend, shoot
4. **Bunkers** - destructible barriers
5. **Score and lives** - HUD
6. **Procedural graphics** - everything drawn with canvas API

Let me design the game:

**Player:**
- Triangle/cannon shape at bottom
- Arrow keys or A/D to move, space to shoot
- 3 lives

**Aliens:**
- 5 rows x 11 columns = 55 aliens
- 3 different alien types (top row = small, middle = medium, bottom = large squid/crab)
- Move together as a group
- Speed increases as fewer aliens remain
- Descend when hitting edge
- Random firing

**Bunkers:**
- 4 bunkers
- Pixel-based destruction (using offscreen canvas)
- Bullets erode the bunker

**Bullets:**
- Player bullets go up
- Alien bullets go down
- Only 1 player bullet on screen at a time (classic)
- Multiple alien bullets

**UFO bonus:**
- Optional - flying across top occasionally

**Game states:**
- Title screen
- Playing
- Game over
- Level complete

Let me write the code. I'll use procedural pixel art drawn via rectangles or sprite drawing functions.

Let me think about the visual style - I want it to look like the classic game with retro pixel-art feel, but drawn procedurally with filled rectangles.

Let me design pixel sprites:
- Each alien type is a 8x8 or 11x8 grid of pixels
- Two animation frames per alien
- Player cannon: 13x8 pixels

I'll use a pixel size multiplier and draw using a string representation:
```
const SPRITE = {
  alien1: [
    "..XXX..",
    "XXXXXXX",
    "XXXXXXX",
    ".XX.XX.",
    "X.XXX.X",
    "X.X.X.X"
  ]
}
```

Each 'X' becomes a filled pixel, '.' is empty. Two frames for animation.

Let me code this up:

```javascript
function drawSprite(ctx, sprite, x, y, scale, color) {
  ctx.fillStyle = color;
  for (let row = 0; row < sprite.length; row++) {
    for (let col = 0; col < sprite[row].length; col++) {
      if (sprite[row][col] === 'X') {
        ctx.fillRect(x + col * scale, y + row * scale, scale, scale);
      }
    }
  }
}
```

**Aliens:**
- Top row: small squid
- Middle rows: crab
- Bottom row: octopus

**Bullet-bunker collision:**
- Use a small grid of "blocks" per bunker
- When hit, erode in a circle around impact

Let me build this all in one file. I'll make it comprehensive and polished.

**Controls:**
- Left/Right arrow or A/D: move
- Space: shoot
- Enter: start/restart

**Sound:** Could add Web Audio API for retro sounds. Let me include those since they make it feel much better and don't need external resources. Actually the requirement is procedural graphics only - "no external assets". Audio generated with WebAudio API isn't really an asset, it's procedural. Let me add it.

Let me code this all up now.

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Space Invaders</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  html, body { height: 100%; background: #000; color: #fff; font-family: 'Courier New', monospace; overflow: hidden; }
  body { display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 12px; }
  #wrap { display: flex; flex-direction: column; align-items: center; gap: 8px; }
  #hud { display: flex; justify-content: space-between; width: 800px; color: #fff; font-size: 18px; letter-spacing: 2px; padding: 0 4px; }
  canvas { display: block; background: #000; image-rendering: pixelated; image-rendering: crisp-edges; box-shadow: 0 0 24px rgba(80, 200, 255, 0.25); border: 2px solid #1a3a5a; }
  #help { color: #8aa; font-size: 12px; letter-spacing: 1px; }
  kbd { background: #1a2a3a; border: 1px solid #345; padding: 1px 6px; border-radius: 3px; color: #cef; font-family: inherit; }
</style>
</head>
<body>
<div id="wrap">
  <div id="hud">
    <div>SCORE <span id="score">0000</span></div>
    <div>HIGH <span id="hi">0000</span></div>
    <div>LIVES <span id="lives">♥♥♥</span></div>
    <div>LEVEL <span id="level">1</span></div>
  </div>
  <canvas id="game" width="800" height="640"></canvas>
  <div id="help">
    Move: <kbd>←</kbd> <kbd>→</kbd> or <kbd>A</kbd> <kbd>D</kbd> &nbsp; Shoot: <kbd>Space</kbd> &nbsp; Start/Restart: <kbd>Enter</kbd>
  </div>
</div>
<script>
// ... game code
</script>
</body>
</html>
```

Now the game logic:

```javascript
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = false;
const W = canvas.width, H = canvas.height;

// Pixel sprites (procedural)
const SPR = {
  squid1: [
    "..XXX..",
    "XXXXXXX",
    "X.XXX.X",
    "X.X.X.X",
    "X.X.X.X",
    "..X.X.."
  ],
  squid2: [
    "..XXX..",
    "XXXXXXX",
    "X.XXX.X",
    "XX.X.XX",
    ".X...X.",
    "X.....X"
  ],
  crab1: [
    ".X...X.",
    "..X.X..",
    ".XXXXX.",
    "X.X.X.X",
    "X.X.X.X",
    "X.....X",
    "X.....X"
  ],
  crab2: [
    ".X...X.",
    "X..X..X",
    ".XXXXX.",
    "X.X.X.X",
    "X.X.X.X",
    "..X.X..",
    ".X...X."
  ],
  octo1: [
    "..X.X..",
    ".XXXXX.",
    "X.X.X.X",
    "XXXXXXXX",
    "X.XXX.X",
    "X.X.X.X",
    ".X...X."
  ],
  octo2: [
    "..X.X..",
    ".XXXXX.",
    "X.X.X.X",
    "XXXXXXXX",
    ".XXX.X.",
    "X.X.X.X",
    ".X...X."
  ],
  ufo1: [
    "..XXXXX..",
    ".X.....X.",
    "X..XXX..X",
    "XXXXXXXXX"
  ],
  ufo2: [
    "..XXXXX..",
    "XX.XXX.XX",
    "X..XXX..X",
    ".XXXXXXX."
  ],
  player: [
    "....X....",
    "...XXX...",
    "...XXX...",
    ".XX.X.XX.",
    "XXXXXXXXX",
    "XXXXXXXXX",
    "XXXXXXXXX"
  ],
  explode: [
    "X.X.X.X.",
    ".X.X.X.X",
    "X.X.X.X.",
    ".X.X.X.X",
    "X.X.X.X.",
    ".X.X.X.X"
  ]
};
```

Color schemes:
- Squid (top, 30pts): cyan
- Crab (middle, 20pts): magenta/pink
- Octo (bottom, 10pts): green
- UFO: red
- Player: white/cyan
- Bullets: white/cyan

Game loop:
```javascript
let last = 0;
let accumulator = 0;
const STEP = 1000/60; // 60 fps logic
function loop(now) {
  if (!last) last = now;
  let dt = now - last;
  if (dt > 100) dt = 100;
  last = now;
  accumulator += dt;
  while (accumulator >= STEP) {
    update();
    accumulator -= STEP;
  }
  render();
  requestAnimationFrame(loop);
}
```

Or I can just do frame-based without fixed timestep. Let me do fixed timestep for cleaner game logic.

Aliens state:
```javascript
const aliens = {
  rows: 5,
  cols: 11,
  x: 0, // grid origin
  y: 0,
  dx: 8, // direction and speed
  dy: 0,
  width: 0,
  height: 0,
  cells: [], // alive aliens, each {row,col,alive}
  tick: 0, // animation tick
  stepInterval: 1000, // ms between moves
  stepTimer: 0,
  fireInterval: 1000,
  fireTimer: 0
};
```

Actually let me think differently. I'll maintain a 2D array of alien objects:
```javascript
let grid = []; // grid[row][col] = {alive: true, type: 'squid'|'crab'|'octo'}
```

And track total alive count and step speed.

Movement logic:
- All aliens in a grid
- Move together at a fixed interval
- Interval shortens as aliens die
- When hitting edge, shift down and reverse direction

For the visual I want aliens to step pixel-by-pixel (classic look) rather than slide.

Let me sketch the full implementation now and write it.

**Bunkers:**
Each bunker is a 22x16 grid of small pixels. Drawn with rectangles. When hit, remove pixels in a small radius around the bullet.

**Sounds:**
Use WebAudio API to generate beeps:
- Player shoot: short high tone
- Alien shoot: lower tone
- Alien killed: medium tone
- Player killed: explosion noise

Let me write it all now:

```javascript
// Sound system
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
function beep(freq, duration, type='square', vol=0.05) {
  const osc = audioCtx.createOscillator();
  const gain = audioCtx.createGain();
  osc.type = type;
  osc.frequency.value = freq;
  gain.gain.value = vol;
  gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + duration);
  osc.connect(gain);
  gain.connect(audioCtx.destination);
  osc.start();
  osc.stop(audioCtx.currentTime + duration);
}
```

OK let me put it all together now. I'll write the full file.

Layout decisions:
- Canvas 800x640
- Score area at top (0-60) - showing line (the classic green line)
- Player area: y ~ 560-600
- Aliens start around y=80, descend to ~440 before game over
- Bunkers around y=480-510
- 4 bunkers across width

Let me start writing.

```javascript
const COLORS = {
  bg: '#000',
  line: '#0f0',  // the classic green ground line
  hud: '#fff',
  alienTop: '#0ff',  // squid
  alienMid: '#f0f',  // crab
  alienBot: '#0f0',  // octo
  ufo: '#f00',
  player: '#0ff',
  bulletPlayer: '#fff',
  bulletAlien: '#ff0',
  bunker: '#0f0',
  explosion: '#f80'
};
```

Actually in the classic game, the line is green and a few red lines at top. Let me think...

Original Space Invaders colors on screen:
- Black background
- White player
- Cyan/blue aliens (top row)
- Magenta/pink aliens (middle)
- Green/yellow aliens (bottom)
- Green bunkers
- Red UFO
- White player bullets, colored alien bullets

Let me make the floor lines green (classic) and add some retro CRT vibe.

Now let me think about difficulty curve:
- Step interval starts at ~800ms
- Each alien killed reduces step interval slightly
- At 1 alien: very fast (~50ms)
- Formula: stepInterval = base * (aliveCount / totalAliens)

Fire rate: each frame, pick a random alive alien and maybe fire based on difficulty.

Let me write it out.

I'm going to make this fairly complete. Let me structure:

```
- Constants & sprites
- Audio
- Game state
- Player
- Bullets
- Aliens
- Bunkers
- UFO
- Particles/explosions
- Input
- Update
- Render
- Main loop
```

Let me finalize the implementation. Here's my plan:

```javascript
// === Game state ===
const game = {
  state: 'title', // title, playing, between, gameover, win
  score: 0,
  hi: parseInt(localStorage.getItem('si_hi') || '0'),
  lives: 3,
  level: 1,
  flash: 0,
  screenShake: 0
};

// === Player ===
const player = {
  x: W/2,
  y: 560,
  w: 44,
  h: 24,
  speed: 280, // pixels per second
  cooldown: 0,
  cooldownMax: 0.35,
  alive: true,
  deathTimer: 0
};

// === Bullets ===
let bullets = []; // {x, y, vy, from: 'player'|'alien'}

// === Aliens ===
const alienState = {
  grid: [],
  rowTypes: ['octo','octo','crab','crab','squid'],
  x: 100,
  y: 80,
  cellW: 48,
  cellH: 40,
  offsetX: 0, // current step offset within cell
  offsetY: 0,
  dir: 1,
  stepInterval: 0.8,
  stepTimer: 0,
  animFrame: 0,
  fireTimer: 0,
  fireInterval: 1.0,
  aliveCount: 0
};

// === Bunkers ===
const bunkers = []; // each: {x, y, w, h, data: Int8Array of pixels}

// === UFO ===
let ufo = null; // {x, y, dir, points: 50|100|150|300}

// === Particles ===
let particles = []; // for explosions and bunker debris
```

Render:
- Draw background
- Draw score line (green)
- Draw 3 red lines at top
- Draw aliens (with anim frame)
- Draw bunkers
- Draw player
- Draw bullets
- Draw UFO if present
- Draw particles
- Draw HUD overlay text

Update:
- Move player
- Cooldowns
- Aliens step
- Aliens fire
- Bullets move
- Collisions:
  - Player bullet vs alien
  - Player bullet vs bunker
  - Player bullet vs UFO
  - Alien bullet vs player
  - Alien bullet vs bunker
  - Bullet vs bullet
- UFO spawn
- Check win (all aliens dead) -> next level
- Check lose (aliens reach bottom or all lives lost)

Let me now write the complete code. I'll be thorough but keep it concise.

One concern: bunker destruction. The classic game uses pixel-level destruction where bullets "eat" pixels. I'll use a Uint8Array for each bunker storing whether each pixel is alive. When a bullet hits, I'll clear pixels in a small circle/pattern.

Bunker shape (classic arch):
```
.XXXXXX.
XXXXXXXX
XXXXXXXX
XXXXXXXX
XXXXXXXX
XXXXXXXX
XXXXXXXX
XXXXXX..
```

Actually the classic bunker is more like:
```
.....XX.....
....XXXX....
...XXXXXX...
..XXXXXXXX..
.XXXXXXXXXX.
XXXXXXXXXXXX
XXXXXXXXXXXX
XXXXXXXXXXXX
XXXXXXXXXXXX
XXXXXXXXXXXX
XXXXXXXXXXXX
XXXXXXXXXXXX
XXXX....XXXX
XXX......XXX
```

It's a curved arch. Width ~22, height ~16.

Each bunker pixel is say 3x3 actual pixels, total 66x48 pixels for the bunker.

For destruction, when a bullet hits a pixel, we erode in a radius of ~3 pixels around it.

Let me code:

```javascript
function createBunker(x, y) {
  const w = 22, h = 16, scale = 3;
  const data = new Uint8Array(w * h);
  // Draw the bunker shape
  for (let row = 0; row < h; row++) {
    for (let col = 0; col < w; col++) {
      // Arch shape
      const cx = (w - 1) / 2;
      // ...
      let inBunker = false;
      if (row < 4) {
        // Top arch
        const arch = (row + 1) * 2; // narrow at top
        if (col >= (w - arch) / 2 && col < (w + arch) / 2) inBunker = true;
      } else if (row < 12) {
        inBunker = true; // full width
      } else {
        // Legs
        const archW = (row - 11) * 2;
        if (col < archW || col >= w - archW) inBunker = true;
      }
      if (inBunker) data[row * w + col] = 1;
    }
  }
  return { x, y, w, h, scale, data };
}
```

Actually let me define the shape explicitly:

```javascript
const BUNKER_SHAPE = [
  "......XX......",
  ".....XXXX.....",
  "....XXXXXX....",
  "...XXXXXXXX...",
  "..XXXXXXXXXX..",
  ".XXXXXXXXXXXX.",
  "XXXXXXXXXXXXXX",
  "XXXXXXXXXXXXXX",
  "XXXXXXXXXXXXXX",
  "XXXXXXXXXXXXXX",
  "XXXXXXXXXXXXXX",
  "XXXXXXXXXXXXXX",
  "XXXXXXXX..XXXX",
  "XXXXXX....XXXX",
  "XXXXX......XXX",
  "XXX.........XX"
];
```

That's 16 rows x 14 cols. Let me check: 14 cols x 16 rows. The arch is 2 wide at top widening down.

For each bullet hit, destroy a 3x3 area or so centered on the impact.

OK enough planning, let me write the full code.

Hmm I realize I should make aliens smooth-step but classic feel. Original game step timing varies. Let me do this:

- When all aliens alive: step every 800ms
- When 1 alien alive: step every ~50ms
- Speed = base / sqrt(aliveCount) roughly

I think I'll just use linear: stepMs = max(50, 800 * aliveCount / totalAliens).

Or: stepMs = 50 + (aliveCount * 750 / totalAliens).

Hmm classic game has 55 aliens. So:
- 55 alive: 50 + 750 = 800ms
- 1 alive: 50 + 13.6 = ~64ms

That works.

Now aliens move as a group. They start at position (x, y). Each cell is 48x40 pixels. Within a cell, they're at the top-left + offsetX. When they hit a wall, dx reverses, y increases, and they step back into view.

Actually in classic Space Invaders, the aliens move pixel-by-pixel, not cell-by-cell. So if cellW=48 and they have ~30px wide sprite, they move 4-8px per step.

Let me do: each step, they move 4-8 pixels (depending on direction: 8 horizontal, 16 vertical). They also have a small animation between steps.

Actually for simplicity, let me just do stepped animation: aliens jump from one position to the next every stepInterval ms, with a leg animation toggle.

For sprite scale: sprite is 7x6 pixels. Scale 3 = 21x18 pixels. Cells 36x30 with some spacing. Let me use scale 4 for sprites so each is 28x24. Cells 44x36.

Let me figure positioning:
- 11 aliens wide, cell 44px = 484px total
- Canvas 800, margin 100 each side, so 600px usable
- 600/11 = 54px per cell. OK let's say cellW = 54.
- Sprite width = 7 * 3 = 21. Centered in cell: 16.5 padding each side.

Actually let me use scale 3 and cellW=44:
- 11 * 44 = 484px
- xStart = (800-484)/2 = 158
- That works.

For y: 5 rows, cellH=40. Total 200. Start at y=100.
- yStart = 100

Movement: when at edge, descend by say 24px and reverse.

UFO: flies across top at y=60. Spawns every 25 seconds or so.

Let me finalize and write the code. I want to write it all in one go.

```javascript
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
ctx.imageSmoothingEnabled = false;

// Pixel sprites (7x6 ish, rendered at scale 3)
const SPR = {
  squid1: ["..XXX..","XXXXXXX","X.XXX.X","X.X.X.X","X.X.X.X","..X.X.."],
  squid2: ["..XXX..","XXXXXXX","X.XXX.X","XX.X.XX","X.....X","X.X.X.X"],
  crab1: [".X...X.","..X.X..",".XXXXX.","X.X.X.X","X.X.X.X","X.....X","X.....X"],
  crab2: [".X...X.","X..X..X",".XXXXX.","X.X.X.X","X.X.X.X","..X.X..",".X...X."],
  octo1: ["..X.X..",".XXXXX.","X.X.X.X","XXXXXXXX","X.XXX.X","X.X.X.X",".X...X."],
  octo2: ["..X.X..",".XXXXX.","X.X.X.X","XXXXXXXX",".XXX.X.","X.X.X.X","X.X.X.X"],
  ufo1:   ["..XXXXX..",".X.....X.","X..XXX..X","XXXXXXXXX"],
  ufo2:   ["..XXXXX..","XX.XXX.XX","X..XXX..X",".XXXXXXX."],
  player: ["....X....","...XXX...","...XXX...",".XX.X.XX.","XXXXXXXXX","XXXXXXXXX","XXXXXXXXX"],
  playerExp: ["X..X..X..","..X..X..X","X..XXXX..","..X..X..X","X..X..X..","..X..X..X","X..X..X.."],
  explode:["X.X.X.X.",".X.X.X.X","X.X.X.X.",".X.X.X.X","X.X.X.X.",".X.X.X.X"]
};

function drawSprite(name, x, y, scale, color) {
  const s = SPR[name];
  if (!s) return;
  ctx.fillStyle = color;
  for (let r = 0; r < s.length; r++) {
    for (let c = 0; c < s[r].length; c++) {
      if (s[r][c] === 'X') {
        ctx.fillRect(x + c * scale, y + r * scale, scale, scale);
      }
    }
  }
}

function spriteSize(name, scale=1) {
  const s = SPR[name];
  return { w: s[0].length * scale, h: s.length * scale };
}
```

OK I'm going to write this all now. Final code:

Let me think about UFO scoring:
- Classic: shows 50, 100, 150, 200, 300 (random)
- We can do similar

Bonus particles for player explosion - 12 particles flying out.

Alien bullet movement: speed ~ 200 px/s
Player bullet speed: ~ 500 px/s

Let me write it.

I realize I should also consider: aliens only fire when they have direct line of sight to player (no other alien below them in the same column). That's the classic behavior. Let me implement that:

```javascript
function pickAlienToFire() {
  // For each column, find the bottom-most alive alien
  const cols = {};
  for (let r = 4; r >= 0; r--) {
    for (let c = 0; c < 11; c++) {
      if (alienState.grid[r][c].alive && !cols[c]) {
        cols[c] = r;
      }
    }
  }
  const candidates = Object.values(cols);
  if (candidates.length === 0) return null;
  return candidates[Math.floor(Math.random() * candidates.length)];
}
```

Then we fire from that alien at random intervals.

OK, writing it now. Let me commit to the design.

Final color palette (retro CRT feel):
- Background: #000
- Player: white-cyan #aef
- Squid: #0ff (cyan)
- Crab: #f4f (pink)
- Octo: #0f4 (green)
- UFO: #f44 (red)
- Player bullet: #fff (white)
- Alien bullet: #ff0 (yellow)
- Bunker: #0f0 (green)
- Ground line: #0a0 / #f00 mix
- Particles: yellow/orange

Let me code it.

I'm also going to add:
- Stars in background (static, drawn once)
- Ground line at the bottom (classic red)
- Three red lines at top (classic)

These enhance the feel.

Let me write the full file now.

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Space Invaders</title>
<style>...</style>
</head>
<body>
<div id="wrap">
  <div id="hud">...</div>
  <canvas id="game" width="800" height="640"></canvas>
  <div id="help">...</div>
</div>
<script>
'use strict';

const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = false;
const W = canvas.width, H = canvas.height;

// === Pixel sprites ===
const SPR = { ... };

// === Helpers ===
function drawSprite(...) {...}
function rectOverlap(...) {...}

// === Audio ===
let audioCtx = null;
function initAudio() {
  if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
function beep(...) {...}
function noise(...) {...}

// === Game state ===
const game = {
  state: 'title',
  score: 0,
  hi: parseInt(localStorage.getItem('si_hi') || '0', 10),
  lives: 3,
  level: 1,
  screenShake: 0,
  waveDelay: 0,
  particles: [],
  stars: []
};

// Generate stars
for (let i = 0; i < 80; i++) {
  game.stars.push({
    x: Math.random() * W,
    y: Math.random() * (H - 100),
    b: Math.random() * 0.5 + 0.3
  });
}

// === Player ===
const player = {
  x: W/2, y: 580, w: 39, h: 21,
  speed: 320,
  cooldown: 0,
  cooldownMax: 0.32,
  alive: true,
  deathTimer: 0,
  invuln: 0,
  scale: 3
};

// === Bullets ===
let bullets = [];

// === Aliens ===
const ROWS = 5, COLS = 11;
const alienState = {
  grid: [],
  x: 0, y: 0,         // top-left of grid
  cellW: 44,
  cellH: 36,
  scale: 3,
  dir: 1,
  stepTimer: 0,
  stepInterval: 0.7,
  animFrame: 0,
  fireTimer: 0,
  fireInterval: 1.1,
  aliveCount: 0
};
function makeAliens() {
  alienState.grid = [];
  const types = ['octo','octo','crab','crab','squid'];
  for (let r = 0; r < ROWS; r++) {
    const row = [];
    for (let c = 0; c < COLS; c++) {
      row.push({ alive: true, type: types[r] });
    }
    alienState.grid.push(row);
  }
  alienState.aliveCount = ROWS * COLS;
  alienState.stepInterval = 0.7;
  alienState.fireInterval = 1.1;
  alienState.dir = 1;
  alienState.stepTimer = 0;
  alienState.fireTimer = 0.3;
  alienState.animFrame = 0;
  // Center the grid
  alienState.x = (W - COLS * alienState.cellW) / 2;
  alienState.y = 80;
}
makeAliens();

// === Bunkers ===
const BUNKER_W = 22, BUNKER_H = 16, BUNKER_SCALE = 3;
const BUNKER_SHAPE = [...];
const bunkers = [];
function makeBunkers() {
  bunkers.length = 0;
  const by = 480;
  const bw = BUNKER_W * BUNKER_SCALE;
  const count = 4;
  const spacing = (W - count * bw) / (count + 1);
  for (let i = 0; i < count; i++) {
    const x = spacing + i * (bw + spacing);
    const data = new Uint8Array(BUNKER_W * BUNKER_H);
    for (let r = 0; r < BUNKER_H; r++) {
      for (let c = 0; c < BUNKER_W; c++) {
        if (BUNKER_SHAPE[r][c] === 'X') data[r * BUNKER_W + c] = 1;
      }
    }
    bunkers.push({ x, y: by, data });
  }
}
makeBunkers();

// === UFO ===
let ufo = null;
let ufoTimer = 25;

// === Input ===
const keys = {};
window.addEventListener('keydown', e => {
  keys[e.code] = true;
  if (['Space','ArrowLeft','ArrowRight','ArrowUp','ArrowDown'].includes(e.code)) e.preventDefault();
  if (e.code === 'Enter') {
    initAudio();
    if (game.state === 'title' || game.state === 'gameover' || game.state === 'win') {
      startGame();
    }
  }
});
window.addEventListener('keyup', e => { keys[e.code] = false; });

// === Start game ===
function startGame() {
  game.state = 'playing';
  game.score = 0;
  game.lives = 3;
  game.level = 1;
  resetLevel();
}
function resetLevel() {
  player.x = W/2;
  player.y = 580;
  player.alive = true;
  player.deathTimer = 0;
  player.invuln = 0;
  player.cooldown = 0;
  bullets = [];
  particles = [];
  ufo = null;
  ufoTimer = 25;
  makeAliens();
  makeBunkers();
  updateHUD();
}

function nextLevel() {
  game.level++;
  game.state = 'between';
  game.waveDelay = 1.5;
  resetLevel();
  game.state = 'playing';
}
function gameOver() {
  game.state = 'gameover';
  if (game.score > game.hi) {
    game.hi = game.score;
    localStorage.setItem('si_hi', String(game.hi));
  }
  updateHUD();
}

// === Collision ===
function rectsOverlap(a, b) {
  return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
}

function eraseBunker(bunker, bx, by, radius) {
  // bx, by in pixel coords within bunker (bunker scale)
  const cx = bx / BUNKER_SCALE;
  const cy = by / BUNKER_SCALE;
  const r2 = radius * radius;
  for (let py = 0; py < BUNKER_H; py++) {
    for (let px = 0; px < BUNKER_W; px++) {
      if (!bunker.data[py * BUNKER_W + px]) continue;
      const dx = px - cx, dy = py - cy;
      if (dx*dx + dy*dy <= r2) bunker.data[py * BUNKER_W + px] = 0;
    }
  }
}

function bunkerHit(bunker, bullet) {
  // Check if bullet rect overlaps any pixel in bunker
  const bx = bullet.x, by = bullet.y, bw = bullet.w, bh = bullet.h;
  // Bunker pixel data coords
  const px0 = Math.floor((bx - bunker.x) / BUNKER_SCALE);
  const py0 = Math.floor((by - bunker.y) / BUNKER_SCALE);
  const px1 = Math.ceil((bx + bw - bunker.x) / BUNKER_SCALE);
  const py1 = Math.ceil((by + bh - bunker.y) / BUNKER_SCALE);
  let hit = false;
  let hitX = 0, hitY = 0;
  for (let py = py0; py <= py1; py++) {
    for (let px = px0; px <= px1; px++) {
      if (px < 0 || px >= BUNKER_W || py < 0 || py >= BUNKER_H) continue;
      if (bunker.data[py * BUNKER_W + px]) {
        hit = true;
        hitX = px * BUNKER_SCALE + bunker.x + BUNKER_SCALE/2;
        hitY = py * BUNKER_SCALE + bunker.y + BUNKER_SCALE/2;
      }
    }
  }
  if (hit) {
    eraseBunker(bunker, hitX - bunker.x, hitY - bunker.y, 2);
  }
  return hit;
}

// === Update ===
let lastTime = performance.now();
function update(dt) {
  if (game.state === 'title') {
    // animate stars subtle, etc.
    return;
  }
  if (game.state === 'gameover' || game.state === 'win') {
    return;
  }
  
  // Playing
  // Player movement
  if (player.alive) {
    if (keys['ArrowLeft'] || keys['KeyA']) player.x -= player.speed * dt;
    if (keys['ArrowRight'] || keys['KeyD']) player.x += player.speed * dt;
    player.x = Math.max(20, Math.min(W - 20 - player.w, player.x));
    
    // Shooting
    if (player.cooldown > 0) player.cooldown -= dt;
    if ((keys['Space'] || keys['KeyZ']) && player.cooldown <= 0 && countPlayerBullets() === 0) {
      bullets.push({
        x: player.x + player.w/2 - 1,
        y: player.y - 8,
        w: 2, h: 8,
        vy: -500,
        from: 'player'
      });
      player.cooldown = player.cooldownMax;
      beep(880, 0.08, 'square', 0.05);
    }
  } else {
    player.deathTimer -= dt;
    if (player.deathTimer <= 0) {
      if (game.lives > 0) {
        player.alive = true;
        player.x = W/2;
        player.invuln = 2;
      } else {
        gameOver();
      }
    }
  }
  if (player.invuln > 0) player.invuln -= dt;
  
  // Aliens step
  if (alienState.aliveCount > 0) {
    alienState.stepTimer += dt;
    const baseStep = 0.05;
    const maxStep = 0.7;
    alienState.stepInterval = baseStep + (maxStep - baseStep) * (alienState.aliveCount / (ROWS*COLS));
    if (alienState.stepTimer >= alienState.stepInterval) {
      alienState.stepTimer = 0;
      alienState.animFrame = 1 - alienState.animFrame;
      stepAliens();
    }
    
    // Alien fire
    alienState.fireTimer -= dt;
    if (alienState.fireTimer <= 0) {
      alienState.fireTimer = Math.max(0.3, alienState.fireInterval * (0.5 + Math.random()));
      fireAlienBullet();
    }
    
    // Check if aliens reach player line
    if (alienState.y + ROWS * alienState.cellH >= player.y - 20) {
      // Game over, aliens invaded
      game.lives = 0;
      gameOver();
    }
  } else {
    // Wave complete
    nextLevel();
  }
  
  // Bullets update
  for (let i = bullets.length - 1; i >= 0; i--) {
    const b = bullets[i];
    b.y += b.vy * dt;
    if (b.y < 0 || b.y > H) {
      bullets.splice(i, 1);
      continue;
    }
    
    if (b.from === 'player') {
      // vs aliens
      for (let r = 0; r < ROWS; r++) {
        for (let c = 0; c < COLS; c++) {
          const cell = alienState.grid[r][c];
          if (!cell.alive) continue;
          const ax = alienState.x + c * alienState.cellW;
          const ay = alienState.y + r * alienState.cellH;
          if (rectsOverlap(b, { x: ax, y: ay, w: alienState.cellW, h: alienState.cellH })) {
            cell.alive = false;
            alienState.aliveCount--;
            bullets.splice(i, 1);
            addScore(cell.type);
            explode(ax + alienState.cellW/2, ay + alienState.cellH/2, cell.type === 'squid' ? '#0ff' : cell.type === 'crab' ? '#f4f' : '#0f4');
            beep(440, 0.1, 'square', 0.04);
            break;
          }
        }
        if (!bullets[i]) break;
      }
      if (!bullets[i]) continue;
      // vs UFO
      if (ufo) {
        const ux = ufo.x, uy = ufo.y, uw = SPR.ufo1[0].length * 3, uh = SPR.ufo1.length * 3;
        if (rectsOverlap(b, { x: ux, y: uy, w: uw, h: uh })) {
          const bonus = [50,100,150,200,300][Math.floor(Math.random()*5)];
          addScore({ type: 'ufo', bonus });
          explode(ux + uw/2, uy + uh/2, '#f44');
          showFloatingScore(ux, uy, bonus);
          ufo = null;
          bullets.splice(i, 1);
          beep(220, 0.4, 'sawtooth', 0.05);
          continue;
        }
      }
      // vs bunkers
      for (const bunker of bunkers) {
        const bw = BUNKER_W * BUNKER_SCALE;
        const bh = BUNKER_H * BUNKER_SCALE;
        if (rectsOverlap(b, { x: bunker.x, y: bunker.y, w: bw, h: bh })) {
          if (bunkerHit(bunker, b)) {
            bullets.splice(i, 1);
            break;
          }
        }
      }
      if (!bullets[i]) continue;
    } else {
      // alien bullet
      // vs player
      if (player.alive && player.invuln <= 0) {
        if (rectsOverlap(b, { x: player.x, y: player.y, w: player.w, h: player.h })) {
          killPlayer();
          bullets.splice(i, 1);
          continue;
        }
      }
      // vs bunkers
      for (const bunker of bunkers) {
        const bw = BUNKER_W * BUNKER_SCALE;
        const bh = BUNKER_H * BUNKER_SCALE;
        if (rectsOverlap(b, { x: bunker.x, y: bunker.y, w: bw, h: bh })) {
          if (bunkerHit(bunker, b)) {
            bullets.splice(i, 1);
            break;
          }
        }
      }
      if (!bullets[i]) continue;
      // vs player bullets
      for (let j = i - 1; j >= 0; j--) {
        if (bullets[j].from === 'player') {
          if (rectsOverlap(bullets[j], b)) {
            bullets.splice(j, 1);
            bullets.splice(i, 1);
            break;
          }
        }
      }
    }
  }
  
  // UFO spawn
  if (!ufo) {
    ufoTimer -= dt;
    if (ufoTimer <= 0) {
      ufo = {
        x: -60,
        y: 50,
        dir: 1,
        speed: 80
      };
      ufoTimer = 20 + Math.random() * 15;
    }
  } else {
    ufo.x += ufo.dir * ufo.speed * dt;
    ufo.animFrame = (ufo.animFrame || 0);
    if (ufo.x > W + 10) ufo = null;
  }
  
  // Particles
  for (let i = particles.length - 1; i >= 0; i--) {
    const p = particles[i];
    p.x += p.vx * dt;
    p.y += p.vy * dt;
    p.vy += 200 * dt; // gravity
    p.life -= dt;
    if (p.life <= 0) particles.splice(i, 1);
  }
  
  // Floating scores
  for (let i = floatingScores.length - 1; i >= 0; i--) {
    floatingScores[i].y -= 30 * dt;
    floatingScores[i].life -= dt;
    if (floatingScores[i].life <= 0) floatingScores.splice(i, 1);
  }
  
  // Screen shake decay
  if (game.screenShake > 0) game.screenShake = Math.max(0, game.screenShake - dt * 10);
}

function countPlayerBullets() {
  let c = 0;
  for (const b of bullets) if (b.from === 'player') c++;
  return c;
}

function stepAliens() {
  // Check if at edge
  const sprW = 7 * alienState.scale;
  const margin = 16;
  let leftEdge = Infinity, rightEdge = -Infinity;
  for (let r = 0; r < ROWS; r++) {
    for (let c = 0; c < COLS; c++) {
      const cell = alienState.grid[r][c];
      if (!cell.alive) continue;
      const ax = alienState.x + c * alienState.cellW;
      leftEdge = Math.min(leftEdge, ax);
      rightEdge = Math.max(rightEdge, ax + sprW);
    }
  }
  const stepX = 8;
  const stepY = 16;
  let newX = alienState.x + stepX * alienState.dir;
  if ((alienState.dir === 1 && rightEdge + stepX > W - margin) ||
      (alienState.dir === -1 && leftEdge - stepX < margin)) {
    alienState.dir *= -1;
    alienState.x += stepX * alienState.dir; // bounce
    alienState.y += stepY;
  } else {
    alienState.x = newX;
  }
  beep(120, 0.04, 'square', 0.02);
}

function fireAlienBullet() {
  // Find bottommost alien in each column
  const cols = {};
  for (let r = ROWS - 1; r >= 0; r--) {
    for (let c = 0; c < COLS; c++) {
      if (alienState.grid[r][c].alive) cols[c] = r;
    }
  }
  const candidates = Object.keys(cols);
  if (candidates.length === 0) return;
  const c = parseInt(candidates[Math.floor(Math.random() * candidates.length)]);
  const r = cols[c];
  const ax = alienState.x + c * alienState.cellW;
  const ay = alienState.y + r * alienState.cellH;
  const sprH = SPR[alienState.grid[r][c].type + (alienState.animFrame + 1)].length * alienState.scale;
  bullets.push({
    x: ax + 21, y: ay + sprH, w: 2, h: 8, vy: 280, from: 'alien'
  });
  beep(180, 0.06, 'square', 0.03);
}

function killPlayer() {
  player.alive = false;
  player.deathTimer = 1.8;
  game.lives--;
  explode(player.x + player.w/2, player.y + player.h/2, '#fff');
  beep(100, 0.5, 'sawtooth', 0.06);
  beep(80, 0.6, 'sawtooth', 0.06);
  game.screenShake = 0.5;
  updateHUD();
}

function explode(x, y, color) {
  for (let i = 0; i < 14; i++) {
    const a = Math.random() * Math.PI * 2;
    const s = 50 + Math.random() * 100;
    particles.push({
      x, y,
      vx: Math.cos(a) * s,
      vy: Math.sin(a) * s - 50,
      life: 0.6 + Math.random() * 0.4,
      maxLife: 1,
      color
    });
  }
}

const floatingScores = [];
function showFloatingScore(x, y, n) {
  floatingScores.push({ x, y, text: String(n), life: 1.2 });
}

function addScore(what) {
  let s = 0;
  if (what.type === 'squid' || what === 'squid') s = 30;
  else if (what.type === 'crab' || what === 'crab') s = 20;
  else if (what.type === 'octo' || what === 'octo') s = 10;
  else if (what.bonus) s = what.bonus;
  if (s) {
    game.score += s;
    if (game.score > game.hi) {
      game.hi = game.score;
      localStorage.setItem('si_hi', String(game.hi));
    }
    updateHUD();
  }
}

// === Render ===
function render() {
  // Background
  ctx.fillStyle = '#000';
  ctx.fillRect(0, 0, W, H);
  
  ctx.save();
  if (game.screenShake > 0) {
    const sx = (Math.random() - 0.5) * game.screenShake * 8;
    const sy = (Math.random() - 0.5) * game.screenShake * 8;
    ctx.translate(sx, sy);
  }
  
  // Stars
  for (const s of game.stars) {
    ctx.fillStyle = `rgba(120,160,200,${s.b * 0.5})`;
    ctx.fillRect(s.x, s.y, 2, 2);
  }
  
  // Top red lines (classic)
  ctx.fillStyle = '#f00';
  ctx.fillRect(0, 30, W, 4);
  ctx.fillRect(0, 40, W, 2);
  ctx.fillRect(0, 48, W, 2);
  
  // Ground line
  ctx.fillStyle = '#0a0';
  ctx.fillRect(0, H - 30, W, 4);
  ctx.fillStyle = '#080';
  ctx.fillRect(0, H - 24, W, 2);
  
  // Aliens
  for (let r = 0; r < ROWS; r++) {
    for (let c = 0; c < COLS; c++) {
      const cell = alienState.grid[r][c];
      if (!cell.alive) continue;
      const ax = alienState.x + c * alienState.cellW + (alienState.cellW - 21) / 2;
      const ay = alienState.y + r * alienState.cellH;
      const color = cell.type === 'squid' ? '#0ff' : cell.type === 'crab' ? '#f4f' : '#0f4';
      drawSprite(cell.type + (alienState.animFrame + 1), ax, ay, alienState.scale, color);
    }
  }
  
  // Bunkers
  for (const bunker of bunkers) {
    ctx.fillStyle = '#0f0';
    for (let r = 0; r < BUNKER_H; r++) {
      for (let c = 0; c < BUNKER_W; c++) {
        if (bunker.data[r * BUNKER_W + c]) {
          ctx.fillRect(bunker.x + c * BUNKER_SCALE, bunker.y + r * BUNKER_SCALE, BUNKER_SCALE, BUNKER_SCALE);
        }
      }
    }
    // Outline for visibility
    ctx.strokeStyle = '#0a0';
    // Skip outline to keep simple
  }
  
  // Player
  if (player.alive) {
    if (player.invuln <= 0 || Math.floor(player.invuln * 20) % 2 === 0) {
      drawSprite('player', player.x, player.y, player.scale, '#fff');
    }
  }
  
  // Bullets
  for (const b of bullets) {
    ctx.fillStyle = b.from === 'player' ? '#fff' : '#ff0';
    ctx.fillRect(b.x, b.y, b.w, b.h);
    // Glow
    if (b.from === 'player') {
      ctx.fillStyle = '#aef';
      ctx.fillRect(b.x - 1, b.y + 1, b.w + 2, b.h - 2);
    }
  }
  
  // UFO
  if (ufo) {
    drawSprite('ufo' + ((Math.floor(performance.now()/200) % 2) + 1), ufo.x, ufo.y, 3, '#f44');
  }
  
  // Particles
  for (const p of particles) {
    ctx.fillStyle = p.color;
    const a = p.life / p.maxLife;
    ctx.globalAlpha = a;
    ctx.fillRect(p.x, p.y, 3, 3);
  }
  ctx.globalAlpha = 1;
  
  // Floating scores
  ctx.font = 'bold 14px Courier New';
  for (const f of floatingScores) {
    ctx.fillStyle = `rgba(255,255,255,${f.life/1.2})`;
    ctx.fillText(f.text, f.x, f.y);
  }
  
  ctx.restore();
  
  // Overlay text
  if (game.state === 'title') {
    drawCentered('SPACE INVADERS', H/2 - 60, 32, '#0ff');
    drawCentered('PRESS ENTER TO START', H/2 + 30, 16, '#fff');
    drawCentered('← → MOVE   SPACE SHOOT', H/2 + 70, 14, '#aaa');
  } else if (game.state === 'gameover') {
    drawCentered('GAME OVER', H/2 - 20, 36, '#f44');
    drawCentered('PRESS ENTER TO RESTART', H/2 + 30, 14, '#fff');
  } else if (game.state === 'win') {
    drawCentered('CONGRATULATIONS!', H/2 - 20, 32, '#0f0');
  }
}

function drawCentered(text, y, size, color) {
  ctx.font = `bold ${size}px Courier New`;
  ctx.fillStyle = color;
  ctx.textAlign = 'center';
  ctx.fillText(text, W/2, y);
  ctx.textAlign = 'left';
}

function updateHUD() {
  document.getElementById('score').textContent = String(game.score).padStart(4, '0');
  document.getElementById('hi').textContent = String(game.hi).padStart(4, '0');
  document.getElementById('lives').textContent = '♥'.repeat(Math.max(0, game.lives));
  document.getElementById('level').textContent = game.level;
}

// === Loop ===
function loop(now) {
  const dt = Math.min(0.05, (now - lastTime) / 1000);
  lastTime = now;
  update(dt);
  render();
  requestAnimationFrame(loop);
}

resetLevel();
requestAnimationFrame(loop);

</script>
```

OK there are still some issues to work out. Let me reconsider:

1. When game starts, I call resetLevel which sets up aliens/bunkers and shows them. But the initial state should be 'title' to show the title screen. Let me handle this: maybe make initial state 'title' but still draw everything in background.

Actually let me show the aliens moving in the background of the title screen too. That looks cool.

2. The player should be hidden on title screen.

3. `animFrame + 1` is wrong. I want either frame 0 or 1. Let me fix that.

4. `addScore(cell.type)` should pass the cell object not the type string. Let me check my code: I pass `cell.type` so in addScore I check `what === 'squid'`. That's fine.

5. Bunker pixel scale: 3. Total bunker size: 22*3=66 wide, 16*3=48 tall. Hmm, 4 bunkers at 66 wide = 264. Plus margins. (800-264)/5 = 107 spacing. So bunkers at x=107, 280, 453, 626. OK that works.

Actually wait, (W - count * bw) / (count + 1) where bw = 66, count = 4. (800 - 264)/5 = 107.2. So bunkers at x=107, 107+66+107=280, 280+66+107=453, 453+66+107=626. Yes.

6. Player width: 9 cols * 3 scale = 27. With player.x centered. Player x range: from 20 to W-20-27=753. OK.

7. The ufo sprite is 9x4. At scale 3 = 27x12. Fine.

8. The alien y position descending check: alienState.y + ROWS * cellH >= player.y - 20. ROWS=5, cellH=36, so 180. player.y = 580. So aliens at y=400 trigger game over. That seems too early. Let me think... when aliens get close to the bunkers/player line.

Actually aliens advance from y=80 down to y=540 ideally (player line). So 460 pixels of vertical movement. With stepY=16, that's 28 steps. At stepInterval 0.7, that's ~20 seconds for the wave. Reasonable.

The check `alienState.y + ROWS * cellH >= player.y - 20` = y + 180 >= 560. So y >= 380. At that point aliens are at y=380, occupying y=380 to y=560. Player at y=580. So aliens are touching player line. Good.

Wait, but cellH=36 and the actual sprite is only 7 rows tall = 21 pixels at scale 3. So most of the cell is empty. Let me make sure aliens don't overlap with bunkers (at y=480). 

Aliens descend to ~380. Bunkers at 480. So aliens stay above bunkers. Good.

But aliens will collide with bunkers on their way down? No - they move down step by step, and they'd start hitting bunkers at alienState.y = ~432 (since ROWS*cellH = 180, alien bottom = y+180). Hmm so when y = 300, bottom = 480 = bunker top. So they could collide visually with bunkers.

Actually in the original game, aliens don't interact with bunkers directly. They just descend until they're at player level. Let me make the game over check stricter - when any alien sprite reaches the bunker top. Actually, you know what, let me keep it simple: when aliens touch player line, game over. The bunkers are between aliens and player.

If aliens descend to player line at y=580 - sprite height = 580-21=559. So alienState.y + (ROWS-1)*cellH + sprH >= 559. SprH = 7*3 = 21. So 380 + 21 = 401? Wait. ROWS=5, last row at r=4, so y(alien) = alienState.y + 4*cellH + sprH = alienState.y + 144 + 21 = alienState.y + 165. For this to equal 559, alienState.y = 394.

Hmm that's earlier than I thought. Let me adjust: make player line game over when alienState.y + (ROWS-1)*cellH + sprH >= 540 or so. Or make game over when alien touches player.y.

Actually let me think: player is at y=580, sprite height 21, so player is at y=580 to 601. The ground line is at H-30 = 610. Aliens shouldn't reach the ground line. When alien sprite bottom reaches player top - 10, game over.

alienState.y + (ROWS-1)*cellH + sprH >= player.y - 10
alienState.y + 144 + 21 >= 570
alienState.y >= 405

So when alienState.y = 405, last row of aliens at y=549, sprite bottom at y=570. Player at y=580. They're close to player.

Actually let me just use a simple check: when any alien bottom exceeds a threshold line, game over. Threshold = 540 (above bunkers).

alienState.y + ROWS * cellH >= 540 means alienState.y >= 540-180 = 360.

Hmm let me think again. The grid origin is top-left. cellH=36. So if alienState.y = 360, the bottom-most cell starts at y=360+4*36=504. Sprite within cell: top-aligned, so sprite at y=504 to 525. Still above bunkers (y=480-528). Hmm conflict.

OK let me bump cellH or change y start. Or just let aliens descend into bunkers - they're game over anyway.

Let me set: game over when alienState.y > 380. That's a fixed y value where they cross the player line.

Actually you know what, the classic game has aliens reach the player's row and game over. Let me just do:

```javascript
if (alienState.y + ROWS * alienState.cellH >= player.y - 4) {
  gameOver();
}
```

So when bottom of grid (y + 180) >= 576 (player.y - 4), game over. y = 396.

I think that's fine. Let me proceed.

9. Wave complete: when alienState.aliveCount <= 0, we call nextLevel. That sets state to 'between' with delay, then back to 'playing'. The between state doesn't have rendering logic, so it would just flash. Let me show a "WAVE COMPLETE" message.

Actually let me skip the between state and just go straight to next level with a brief flash.

10. Audio context should be unlocked on first user interaction.

11. The startGame function should set state to 'playing'. Let me check... yes.

12. When the title is showing, we don't want aliens to step. Let me make sure the state check handles that. Currently in update I return early if state is 'title'. Good.

But then aliens won't move on title screen. Let me let them move on title screen for atmosphere.

Hmm actually the title screen is just before startGame. After game over, we go back to title-like state. Let me have two states:
- 'menu' (title screen, but game world is rendered behind)
- 'playing'
- 'gameover'
- 'wave' (between waves)

Let me make menu and gameover similar in terms of world rendering.

OK I'll just have update run the world logic always (unless game over), and overlay text when in menu/gameover.

Let me revise:

```javascript
function update(dt) {
  // Always run game logic
  // ...
  
  // But skip if state is gameover
  if (game.state === 'gameover') return;
  
  // ... rest of logic
}
```

And on title, the player doesn't show, but aliens move.

Actually let me simplify: only run game logic when state === 'playing'. Show static game world in title/gameover.

For now, let me make the game functional. I can polish later.

Let me also make sure rendering works for title. The render function draws aliens etc. unconditionally (only when alive). So that works.

For title, I want to show "PRESS ENTER TO START". For gameover, "GAME OVER, PRESS ENTER". After win, "CONGRATULATIONS".

13. Player is initially shown but should be hidden on title screen. Let me make `if (player.alive && game.state !== 'title')` or similar.

Hmm or just don't reset player until startGame. Let me say player.alive = false initially.

14. The `countPlayerBullets` function looks at bullets array. OK.

15. The alien fire: when bullet fired, sprite height varies. Let me make it a constant: 7 rows = 21 pixels at scale 3.

Wait, the alien type has different sprite heights: squid is 6 rows, crab is 7, octo is 7. Let me just compute based on type.

Actually I realize my code: `cell.type + (alienState.animFrame + 1)`. That gives "squid1" or "squid2". Good.

But for fire position, I compute sprH using `SPR[alienState.grid[r][c].type + (alienState.animFrame + 1)].length`. Wait that gives "squid1" - 6 rows. Let me make sure that matches.

Actually the column index for animFrame: animFrame is 0 or 1. `+1` gives 1 or 2. So animFrame 0 -> sprite "1" (frame 1), animFrame 1 -> sprite "2" (frame 2). OK.

Let me fix that to be cleaner: just pass animFrame + 1 as suffix.

16. UFO anim: I want alternating ufo1/ufo2 every 200ms. Let me use:
```javascript
const ufoFrame = Math.floor(performance.now() / 200) % 2 + 1;
drawSprite('ufo' + ufoFrame, ufo.x, ufo.y, 3, '#f44');
```

17. Player invuln: when player respawns, give 2 seconds invuln with blinking. Done.

18. Bunker destruction: I check each pixel in the bullet's bounding rect. If any are alive, I hit. Then I erase a circle around the impact.

For visual variety, I can vary the erase radius based on impact (1-2 pixels). Let me use a small explosion effect.

Actually, let me just do a 2-pixel radius erase. That gives nice chunky destruction.

19. Particle rendering: use 3x3 squares. Color from explosion. Gravity pulls them down. Good.

20. Let me also add the highscore in HUD and storage.

OK I think I have enough. Let me write the final file now.

Wait, one more thing: when firing alien bullet, I should make sure not to fire if there's already an alien bullet on screen or limit to a few. Classic game limits to 1-3 alien bullets. Let me limit to 3.

Actually let me not limit and just have low fire rate. Simpler.

21. Bullet-bullet collision: player bullets cancel alien bullets. Already handled.

22. Aliens shoot from bottom-most alive alien in their column. Yes done.

23. Special handling: when the very last alien is killed, we transition to next level. Let me make this smooth - flash, show message, advance
assistant
thinking
The user wants a playable Space Invaders game built as a single-page web app in `index.html`. Let me plan this out:

**Requirements:**
1. Player cannon that moves and shoots
2. Descending grid of aliens that advance and fire back
3. Destructible bunkers
4. Score system
5. Lives system
6. Procedural graphics only (no external assets)
7. Single self-contained `index.html` with inline CSS and JS
8. No network/external resources

**Design Plan:**

**Layout:**
- Canvas-based game (e.g., 800x600 or scaled)
- Top: Score, high score, lives
- Middle: Play area with aliens, player, bunkers, bullets
- Bottom: Maybe ground line or just the player

**Game entities:**
- Player cannon: rectangle/triangle shape, moves left/right with arrow keys or A/D, shoots with space
- Aliens: 3-5 rows × 8-11 columns, different types (squid, crab, octopus shapes drawn procedurally)
- Aliens shoot bullets occasionally
- Bunkers: 4 destructible bunkers between player and aliens, made of small pixels
- UFO bonus enemy that occasionally flies across top
- Score, lives display
- Game over screen, restart capability

**Mechanics:**
- Player movement: smooth left/right
- Bullets: only one bullet on screen at a time (classic style)
- Aliens: march side to side, drop down when hitting edge, speed up as fewer remain
- Alien shooting: random aliens fire
- Bunkers: erode when hit by bullets
- Score values: different points for different alien types
- Lives: start with 3, lose one when alien bullet hits player
- Game over: when aliens reach player level or player loses all lives
- Win: clear all aliens
- Levels: increase difficulty

**Procedural Graphics:**
- Draw aliens using canvas rectangles/paths
- Animate alien sprites by toggling between two frames
- Player: classic cannon shape
- Bullets: simple lines/rectangles
- Explosions: particle effects
- Bunkers: pixel-by-pixel destruction

**Code structure:**
- Constants and configuration
- Game state object
- Input handling (keyboard)
- Update loop (movement, collision, AI)
- Render loop (draw everything)
- main game loop with requestAnimationFrame
- Init/restart functions

Let me build this now. I'll make it polished with:
- Classic green-on-black retro feel (but with some color)
- Smooth animations
- Sound effects? — could do Web Audio API for simple beeps (procedural)
- Particle explosions
- UFO bonus enemy
- Animated alien sprites (two-frame animation)
- Star field background for atmosphere
- High score persistence with localStorage
- Difficulty progression between waves

Let me start writing the code.

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Space Invaders</title>
<style>
  body { margin: 0; background: #000; color: #fff; font-family: 'Courier New', monospace; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; overflow: hidden; }
  canvas { background: #000; image-rendering: pixelated; box-shadow: 0 0 30px rgba(0, 255, 100, 0.3); border: 2px solid #1a3; }
  .info { margin: 10px; color: #6c6; font-size: 14px; }
  kbd { background: #222; border: 1px solid #444; padding: 2px 6px; border-radius: 3px; color: #fff; }
</style>
</head>
<body>
<div class="info">Arrow keys / <kbd>A</kbd> <kbd>D</kbd> to move &nbsp; • &nbsp; <kbd>Space</kbd> to shoot &nbsp; • &nbsp; <kbd>P</kbd> to pause &nbsp; • &nbsp; <kbd>R</kbd> to restart</div>
<canvas id="game" width="800" height="700"></canvas>
<script>
// ... game code
</script>
</body>
</html>
```

Now let me write the full game code:

```js
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;

// Sounds via Web Audio API
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
function beep(freq, dur, type='square', vol=0.05) {
  const osc = audioCtx.createOscillator();
  const gain = audioCtx.createGain();
  osc.type = type;
  osc.frequency.value = freq;
  gain.gain.setValueAtTime(vol, audioCtx.currentTime);
  gain.gain.exponentialRampToValueAtTime(0.0001, audioCtx.currentTime + dur);
  osc.connect(gain);
  gain.connect(audioCtx.destination);
  osc.start();
  osc.stop(audioCtx.currentTime + dur);
}

// Game state
let player, aliens, bullets, alienBullets, bunkers, particles, ufo;
let score, hiScore, lives, level, state, frame, invuln;
let stars;

function initStars() {
  stars = [];
  for (let i = 0; i < 80; i++) {
    stars.push({ x: Math.random()*W, y: Math.random()*H, s: Math.random()*2+0.5 });
  }
}

function newGame() {
  player = { x: W/2 - 22, y: H - 80, w: 44, h: 32, cooldown: 0 };
  bullets = [];
  alienBullets = [];
  particles = [];
  ufo = null;
  score = 0;
  hiScore = parseInt(localStorage.getItem('si_hi') || '0');
  lives = 3;
  level = 1;
  state = 'playing'; // 'playing', 'paused', 'gameover', 'wavebreak'
  frame = 0;
  invuln = 0;
  buildAliens();
  buildBunkers();
}

function buildAliens() {
  aliens = [];
  const rows = 5;
  const cols = 11;
  const types = [3, 3, 2, 2, 1]; // point values per row (top=highest)
  const colors = ['#0ff', '#0ff', '#f0f', '#f0f', '#ff0'];
  const startX = 80;
  const startY = 80;
  const cellW = 50;
  const cellH = 40;
  for (let r = 0; r < rows; r++) {
    for (let c = 0; c < cols; c++) {
      aliens.push({
        x: startX + c * cellW,
        y: startY + r * cellH,
        w: 36,
        h: 24,
        type: types[r],
        color: colors[r],
        row: r,
        col: c,
        alive: true,
        frame: 0,
      });
    }
  }
  aliens.dx = 8; // step
  aliens.dy = 16;
  aliens.stepDelay = 60; // frames between steps (reduces as fewer alive)
  aliens.tick = 0;
  aliens.dir = 1;
}

function buildBunkers() {
  bunkers = [];
  const count = 4;
  const bunkerW = 88;
  const bunkerH = 56;
  const spacing = (W - count * bunkerW) / (count + 1);
  for (let i = 0; i < count; i++) {
    const bx = spacing + i * (bunkerW + spacing);
    const by = H - 160;
    // Pixel grid (each "pixel" is 4x4)
    const pixelSize = 4;
    const gridW = bunkerW / pixelSize;
    const gridH = bunkerH / pixelSize;
    const grid = [];
    // Define bunker shape - classic invader bunker
    for (let y = 0; y < gridH; y++) {
      grid[y] = [];
      for (let x = 0; x < gridW; x++) {
        // Build the classic bunker shape
        const cx = x - gridW/2;
        const cy = y - gridH/2;
        // Top arch
        let inside = false;
        // simple rectangular with arch on top, opening on bottom
        if (y < 2) {
          // top arch
          const r = gridW/2 - 2;
          const dx = Math.abs(cx);
          inside = dx*dx + (cy + 2)*(cy + 2) < r*r && y > 0;
        } else if (y >= gridH - 6) {
          // bottom opening
          inside = !(Math.abs(cx) > 3 && y >= gridH - 8);
        } else {
          inside = true;
        }
        grid[y][x] = inside;
      }
    }
    bunkers.push({ x: bx, y: by, w: bunkerW, h: bunkerH, grid, pixelSize });
  }
}
```

Hmm, the bunker shape definition is getting complex. Let me simplify with a predefined pattern or a cleaner algorithm.

Classic Space Invaders bunkers are basically:
- Top half: rectangular with rounded top
- Bottom half: rectangular with a wide opening at the bottom center

Let me use a simpler definition:

```js
function buildBunkers() {
  bunkers = [];
  const count = 4;
  const bunkerW = 88;
  const bunkerH = 56;
  const spacing = (W - count * bunkerW) / (count + 1);
  for (let i = 0; i < count; i++) {
    const bx = spacing + i * (bunkerW + spacing);
    const by = H - 160;
    const pixelSize = 4;
    const gridW = bunkerW / pixelSize; // 22
    const gridH = bunkerH / pixelSize; // 14
    const grid = [];
    for (let y = 0; y < gridH; y++) {
      grid[y] = [];
      for (let x = 0; x < gridW; x++) {
        const cx = x - (gridW-1)/2;
        const cy = y - (gridH-1)/2;
        let inside = false;
        // Top rounded arch
        const archR = gridW/2 - 1;
        if (cy < 0) {
          // use distance from top center
          const dy = -cy;
          inside = (cx*cx + (dy-1)*(dy-1)) < archR*archR;
        } else {
          // Body
          inside = true;
          // Bottom center opening (legs)
          if (cy > gridH/2 - 3 && Math.abs(cx) < 4) {
            inside = false;
          }
        }
        grid[y][x] = inside;
      }
    }
    bunkers.push({ x: bx, y: by, w: bunkerW, h: bunkerH, grid, pixelSize });
  }
}
```

This should create nice rounded bunkers with an arch opening at the bottom.

Now for the alien sprites. Let me draw them procedurally with small pixel grids:

**Squid (top, 3 points):**
A 8x8 grid with a particular pattern.

**Crab (middle, 2 points):**
An 11x8 grid pattern.

**Octopus (bottom, 1 point):**
A 12x8 grid pattern.

I'll define each alien as a 2D pixel pattern, and have two frames that alternate.

```js
const ALIEN_PATTERNS = {
  // Squid (3 pts)
  3: [
    [
      '  XXXX  ',
      ' XXXXXX ',
      'XXXXXXXX',
      'XX XX XX',
      'XXXXXXXX',
      '  X  X  ',
      ' X    X ',
      'X      X',
    ],
    [
      '  XXXX  ',
      ' XXXXXX ',
      'XXXXXXXX',
      'XX XX XX',
      'XXXXXXXX',
      ' X    X ',
      '  X  X  ',
      ' X XX X ',
    ],
  ],
  // Crab (2 pts)
  2: [
    [
      '   XXXXXX   ',
      '  XXXXXXXX  ',
      ' XXXXXXXXXX ',
      'XX XX  XX XX',
      'XXXXXXXXXXXX',
      ' XXXXXXXXXX ',
      '  XX    XX  ',
      ' XX      XX ',
    ],
    [
      '   XXXXXX   ',
      '  XXXXXXXX  ',
      ' XXXXXXXXXX ',
      'XX XX  XX XX',
      'XXXXXXXXXXXX',
      ' XXXXXXXXXX ',
      '    XXXX    ',
      '   XX  XX   ',
    ],
  ],
  // Octopus (1 pt)
  1: [
    [
      '    XXXX    ',
      ' XXXXXXXXXX ',
      'XXXXXXXXXXXX',
      'XXX  XX  XXX',
      'XXXXXXXXXXXX',
      '  XX XX XX  ',
      ' XX  XX  XX ',
      '      XX    ',
    ],
    [
      '    XXXX    ',
      ' XXXXXXXXXX ',
      'XXXXXXXXXXXX',
      'XXX  XX  XXX',
      'XXXXXXXXXXXX',
      '  XX XX XX  ',
      '    XXXX    ',
      '   XX  XX   ',
    ],
  ],
};
```

Wait, I had the type values confused. Let me make:
- Top row (squid): 30 points
- Middle rows (crab): 20 points
- Bottom rows (octopus): 10 points

The type stored is the points value.

Actually for simplicity let me use type as index: 0=squid, 1=crab, 2=octopus. Then assign points.

Let me reorganize:

```js
function buildAliens() {
  aliens = [];
  const rows = 5;
  const cols = 11;
  // 0 = squid (30 pts), 1 = crab (20), 2 = octopus (10)
  const typesByRow = [0, 0, 1, 1, 2];
  const colorsByType = ['#00ffff', '#00ffff', '#ff00ff', '#ff00ff', '#ffff00'];
  // ...
}
```

Hmm, but I want different colors. Let me say:
- Row 0,1: Squid - cyan
- Row 2,3: Crab - magenta
- Row 4: Octopus - yellow

OK let me code this out properly.

For player drawing, classic cannon shape:
```
   X
  XXX
  XXX
XXXXXXX
XXXXXXXX
```
Like a triangle on top of a wider base.

```js
const PLAYER_PATTERN = [
  '   X   ',
  '  XXX  ',
  '  XXX  ',
  'XXXXXXX',
  'XXXXXXXX',
];

const UFO_PATTERN = [
  '   XXXXX   ',
  ' XXXXXXXXX ',
  'XXXXXXXXXXX',
  'X XXXXXXX X',
];
```

Now bullets:
- Player bullet: vertical line/rectangle
- Alien bullet: similar but different color (or zigzag pattern)

Let me also add:
- Player bullet: 3px wide, 12px tall, white
- Alien bullets: simple, 3 different types? Let me keep simple — just one straight bullet, 4px wide, 12px tall, color varies.

For drawing bullets I can use simple rectangles or use a "lightning" zigzag.

OK let me also think about input:
- Left/Right arrows: move
- Space: shoot
- P: pause
- R: restart

Collision detection:
- Bullet vs alien
- Bullet vs player
- Bullet vs bunker
- Alien vs bunker (destroys bunker when touching)
- Alien vs player (game over)

Let me also add:
- UFO appears randomly at top, moves across, gives bonus points (50, 100, 150, 300 random)
- Score display, lives display (small player icons)
- High score

Let me write the full code now. I'll structure it cleanly.

```js
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const W = canvas.width;
const H = canvas.height;

// ===== Audio =====
let audioEnabled = false;
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
function beep(freq, dur, type='square', vol=0.04) {
  if (!audioEnabled) return;
  try {
    const osc = audioCtx.createOscillator();
    const gain = audioCtx.createGain();
    osc.type = type;
    osc.frequency.setValueAtTime(freq, audioCtx.currentTime);
    gain.gain.setValueAtTime(vol, audioCtx.currentTime);
    gain.gain.exponentialRampToValueAtTime(0.0001, audioCtx.currentTime + dur);
    osc.connect(gain);
    gain.connect(audioCtx.destination);
    osc.start();
    osc.stop(audioCtx.currentTime + dur);
  } catch (e) {}
}

// Noise burst for explosions
function noiseBurst(dur=0.15, vol=0.06) {
  if (!audioEnabled) return;
  try {
    const bufSize = audioCtx.sampleRate * dur;
    const buffer = audioCtx.createBuffer(1, bufSize, audioCtx.sampleRate);
    const data = buffer.getChannelData(0);
    for (let i = 0; i < bufSize; i++) {
      data[i] = (Math.random() * 2 - 1) * (1 - i/bufSize);
    }
    const src = audioCtx.createBufferSource();
    src.buffer = buffer;
    const gain = audioCtx.createGain();
    gain.gain.setValueAtTime(vol, audioCtx.currentTime);
    src.connect(gain);
    gain.connect(audioCtx.destination);
    src.start();
  } catch (e) {}
}

// Invader march sound (alternating tones)
let marchToggle = 0;
function marchSound() {
  if (!audioEnabled) return;
  beep(marchToggle ? 110 : 90, 0.08, 'square', 0.03);
  marchToggle = !marchToggle;
}

// ===== Constants =====
const PLAYER_SPEED = 5;
const PLAYER_BULLET_SPEED = 9;
const ALIEN_BULLET_SPEED = 5;
const MAX_PLAYER_BULLETS = 1;

// ===== State =====
let player, aliens, playerBullets, alienBullets, bunkers, particles, ufo;
let score, hiScore, lives, level, state, frame, invuln, marchTimer, ufoTimer;
let stars;

const KEY = {};
window.addEventListener('keydown', e => {
  KEY[e.code] = true;
  if (e.code === 'Space') e.preventDefault();
  if (['ArrowLeft','ArrowRight','ArrowUp','ArrowDown','Space'].includes(e.code)) e.preventDefault();
  if (state === 'gameover' && e.code === 'KeyR') newGame();
  if (state === 'gameover' && e.code === 'Enter') newGame();
  if (e.code === 'KeyP') {
    if (state === 'playing') state = 'paused';
    else if (state === 'paused') state = 'playing';
  }
  if (e.code === 'KeyR' && state !== 'gameover') newGame();
  // enable audio on first key
  audioEnabled = true;
  if (audioCtx.state === 'suspended') audioCtx.resume();
});
window.addEventListener('keyup', e => { KEY[e.code] = false; });

// ===== Patterns =====
const ALIEN_PATTERNS = [
  // 0: Squid (top row, 30 pts)
  [
    [
      '..XXXX..',
      '.XXXXXX.',
      'XXXXXXXX',
      'XX.XX.XX',
      'XXXXXXXX',
      '..X..X..',
      '.X....X.',
      'X......X',
    ],
    [
      '..XXXX..',
      '.XXXXXX.',
      'XXXXXXXX',
      'XX.XX.XX',
      'XXXXXXXX',
      '.X....X.',
      '..X..X..',
      '.X.XX.X.',
    ],
  ],
  // 1: Crab (middle rows, 20 pts)
  [
    [
      '...XXXXXX...',
      '..XXXXXXXX..',
      '.XXXXXXXXXX.',
      'XX.XX..XX.XX',
      'XXXXXXXXXXXX',
      '.XXXXXXXXXX.',
      '..XX....XX..',
      '.XX......XX.',
    ],
    [
      '...XXXXXX...',
      '..XXXXXXXX..',
      '.XXXXXXXXXX.',
      'XX.XX..XX.XX',
      'XXXXXXXXXXXX',
      '.XXXXXXXXXX.',
      '....XXXX....',
      '...XX..XX...',
    ],
  ],
  // 2: Octopus (bottom row, 10 pts)
  [
    [
      '....XXXX....',
      '.XXXXXXXXXX.',
      'XXXXXXXXXXXX',
      'XXX..XX..XXX',
      'XXXXXXXXXXXX',
      '..XX.XX.XX..',
      '.XX..XX..XX.',
      '......XX....',
    ],
    [
      '....XXXX....',
      '.XXXXXXXXXX.',
      'XXXXXXXXXXXX',
      'XXX..XX..XXX',
      'XXXXXXXXXXXX',
      '..XX.XX.XX..',
      '....XXXX....',
      '...XX..XX...',
    ],
  ],
];
const ALIEN_POINTS = [30, 20, 10];

const PLAYER_PATTERN = [
  '...X...',
  '..XXX..',
  '..XXX..',
  'XXXXXXX',
  'XXXXXXXX',
];
const UFO_PATTERN = [
  '...XXXXX...',
  '.XXXXXXXXX.',
  'XXXXXXXXXXX',
  'X.XXXXXXX.X',
];
const UFO_BONUSES = [50, 100, 150, 200, 300];

function drawPattern(pat, x, y, scale, color) {
  ctx.fillStyle = color;
  for (let r = 0; r < pat.length; r++) {
    for (let c = 0; c < pat[r].length; c++) {
      if (pat[r][c] === 'X') {
        ctx.fillRect(x + c*scale, y + r*scale, scale, scale);
      }
    }
  }
}

function patternWidth(pat, scale) { return pat[0].length * scale; }
function patternHeight(pat, scale) { return pat.length * scale; }

// ===== Init =====
function initStars() {
  stars = [];
  for (let i = 0; i < 100; i++) {
    stars.push({
      x: Math.random()*W,
      y: Math.random()*H,
      s: Math.random()*1.5 + 0.3,
      b: Math.random(),
      sp: Math.random() * 0.3 + 0.05,
    });
  }
}

function newGame() {
  player = { x: W/2 - 24, y: H - 80, w: 56, h: 40, cooldown: 0 };
  playerBullets = [];
  alienBullets = [];
  particles = [];
  ufo = null;
  score = 0;
  hiScore = parseInt(localStorage.getItem('si_hi') || '0');
  lives = 3;
  level = 1;
  state = 'playing';
  frame = 0;
  invuln = 60;
  marchTimer = 0;
  ufoTimer = 60 * 15;
  buildAliens();
  buildBunkers();
}

function nextWave() {
  level++;
  playerBullets = [];
  alienBullets = [];
  particles = [];
  ufo = null;
  invuln = 60;
  buildAliens();
  buildBunkers();
  state = 'playing';
  ufoTimer = 60 * 15;
}

function buildAliens() {
  aliens = [];
  const rows = 5;
  const cols = 11;
  const typesByRow = [0, 0, 1, 1, 2];
  const startX = 80;
  const startY = 80;
  const cellW = 50;
  const cellH = 42;
  for (let r = 0; r < rows; r++) {
    for (let c = 0; c < cols; c++) {
      aliens.push({
        x: startX + c * cellW,
        y: startY + r * cellH,
        w: cellW - 10,
        h: cellH - 10,
        type: typesByRow[r],
        alive: true,
        row: r,
        col: c,
      });
    }
  }
  aliens.dx = 6 + (level - 1) * 1.5;
  aliens.dy = 14;
  aliens.dir = 1;
  aliens.tick = 0;
  aliens.animFrame = 0;
  aliens.animTick = 0;
}

function buildBunkers() {
  bunkers = [];
  const count = 4;
  const bunkerW = 88;
  const bunkerH = 56;
  const totalW = count * bunkerW;
  const spacing = (W - totalW) / (count + 1);
  for (let i = 0; i < count; i++) {
    const bx = spacing + i * (bunkerW + spacing);
    const by = H - 170;
    const pixelSize = 4;
    const gridW = Math.floor(bunkerW / pixelSize);
    const gridH = Math.floor(bunkerH / pixelSize);
    const grid = [];
    const cxC = (gridW - 1) / 2;
    const cyC = (gridH - 1) / 2;
    const archR = gridW/2 - 1;
    for (let y = 0; y < gridH; y++) {
      grid[y] = [];
      for (let x = 0; x < gridW; x++) {
        const dx = x - cxC;
        const dy = y - cyC;
        let inside = false;
        // Top arch
        if (y < 2) {
          // distance from arch center
          const ax = dx;
          const ay = dy + 2;
          inside = (ax*ax + ay*ay) < archR*archR && y >= 0;
          // fill solid inside arch
          inside = inside || (y >= 1);
        } else if (y >= gridH - 7) {
          // bottom legs
          inside = Math.abs(dx) >= 4;
        } else {
          inside = true;
        }
        grid[y][x] = inside;
      }
    }
    bunkers.push({ x: bx, y: by, w: bunkerW, h: bunkerH, grid, pixelSize, gridW, gridH });
  }
}
```

Hmm the bunker shape is tricky. Let me just use a simple approach with a predefined pattern.

Actually let me use a different approach - draw the bunker shape using math operations clearly:

Classic bunker shape:
- Bottom is open with legs on each side and a tall arched opening in the middle
- Top is rounded

Let me try:
```
const bunkerShape = [
  '...XXXXXXXXXX...',
  '..XXXXXXXXXXXX..',
  '.XXXXXXXXXXXXXX.',
  'XXXXXXXXXXXXXXXX',
  'XXXXXXXXXXXXXXXX',
  'XXXXXXXXXXXXXXXX',
  'XXXXXXXXXXXXXXXX',
  'XXXXXXXXXXXXXXXX',
  'XXXXXXXXXXXXXXXX',
  'XXXXXXXX  XXXXXX',
  'XXXXXXX    XXXXX',
  'XXXXXX      XXXX',
  'XXXXX        XXX',
  'XXXX          XX',
];
```

Hmm. Let me just do a simpler classic bunker shape:

```js
function buildBunkerShape(gridW, gridH) {
  const grid = [];
  for (let y = 0; y < gridH; y++) {
    grid[y] = [];
    for (let x = 0; x < gridW; x++) {
      const cx = x - (gridW-1)/2;
      const cy = y - (gridH-1)/2;
      let inside = false;
      
      const archR = gridW * 0.5 - 1;
      // Top arch (rounded dome)
      if (cy < 0) {
        const dx = cx;
        const dy = -cy - 1;
        inside = (dx*dx + dy*dy) <= archR*archR;
      }
      // Body - solid block
      else if (cy < gridH/2 - 3) {
        inside = true;
      }
      // Bottom - has an arched opening
      else {
        const dy = cy - (gridH/2 - 3);
        const openR = Math.max(2, gridW * 0.25 - dy);
        inside = Math.abs(cx) > openR;
      }
      
      grid[y][x] = inside;
    }
  }
  return grid;
}
```

Wait, let me think about this more carefully. The classic Space Invaders bunker looks like:

```
    XXXXXXXX
  XXXXXXXXXXXX
 XXXXXXXXXXXXXX
XXXXXXXXXXXXXX
XXXXXXXXXXXXXX
XXXXXX  XXXXXX
XXXXX    XXXXX
XXX        XXX
```

So:
- Top: rounded dome
- Middle: solid
- Bottom: arched opening in middle, with thicker legs on sides

Let me try:

```js
function buildBunkerShape(gridW, gridH) {
  const grid = [];
  for (let y = 0; y < gridH; y++) {
    grid[y] = [];
    for (let x = 0; x < gridW; x++) {
      const cx = x - (gridW-1)/2;
      const cy = y - (gridH-1)/2;
      let inside = false;
      
      const halfW = gridW/2 - 0.5;
      const halfH = gridH/2 - 0.5;
      
      // Top: dome shape using circle
      // The dome covers top portion
      if (cy < -2) {
        const dx = cx;
        const dy = -cy - 2; // distance from top
        const r = halfW;
        inside = (dx*dx + dy*dy) <= r*r && y > 0;
      }
      // Middle: solid
      else if (cy <= 0) {
        inside = true;
      }
      // Bottom portion: arched opening
      else {
        // The opening is an inverted arch starting from center bottom
        const dy = cy - 1;
        const maxR = halfW - 2;
        const openR = maxR - dy;
        // Inside is where we are outside the opening
        inside = openR <= 0 || Math.abs(cx) > openR;
      }
      
      grid[y][x] = inside;
    }
  }
  return grid;
}
```

Hmm let me think differently. Let me make a bunker pattern as a string array, like I did for aliens.

```js
const BUNKER_PATTERNS = [
  [
    '....XXXXXXXX....',
    '...XXXXXXXXXX...',
    '..XXXXXXXXXXXX..',
    '.XXXXXXXXXXXXXX.',
    'XXXXXXXXXXXXXXXX',
    'XXXXXXXXXXXXXXXX',
    'XXXXXXXXXXXXXXXX',
    'XXXXXXXXXXXXXXXX',
    'XXXXXXXXXXXXXXXX',
    'XXXXXXX..XXXXXXX',
    'XXXXXX....XXXXXX',
    'XXXXX......XXXXX',
    'XXXX..........XX',  // hmm 12 cols
  ],
];
```

Actually let me just code it carefully. I'll make a 16x12 pixel pattern:

```js
const BUNKER_PATTERN = [
  '....XXXXXXXX....',
  '...XXXXXXXXXX...',
  '..XXXXXXXXXXXX..',
  '.XXXXXXXXXXXXXX.',
  'XXXXXXXXXXXXXXXX',
  'XXXXXXXXXXXXXXXX',
  'XXXXXXXXXXXXXXXX',
  'XXXXXXXXXXXXXXXX',
  'XXXXXXXXXXXXXXXX',
  'XXXXXXX..XXXXXXX',
  'XXXXXX....XXXXXX',
  'XXXX..........XX',  // bad
];
```

Hmm. Let me use width 16 properly:

```
....XXXXXXXX....    16 chars
...XXXXXXXXXX...    16 chars
..XXXXXXXXXXXX..    16 chars
.XXXXXXXXXXXXXX.    16 chars
XXXXXXXXXXXXXXXX    16 chars
XXXXXXXXXXXXXXXX    16 chars
XXXXXXXXXXXXXXXX    16 chars
XXXXXXXXXXXXXXXX    16 chars
XXXXXXXXXXXXXXXX    16 chars
XXXXXXX..XXXXXXX    16 chars (3 + 2 + 3 + ... = wait 16)
XXXXXX....XXXXXX    16 chars (6+4+6=16)
XXXXX......XXXXX    16 chars (5+6+5=16)
XXXX........XXXX    16 chars (4+8+4=16)
```

OK:

```js
const BUNKER_PATTERN = [
  '....XXXXXXXX....',
  '...XXXXXXXXXX...',
  '..XXXXXXXXXXXX..',
  '.XXXXXXXXXXXXXX.',
  'XXXXXXXXXXXXXXXX',
  'XXXXXXXXXXXXXXXX',
  'XXXXXXXXXXXXXXXX',
  'XXXXXXXXXXXXXXXX',
  'XXXXXXXXXXXXXXXX',
  'XXXXXXX..XXXXXXX',
  'XXXXXX....XXXXXX',
  'XXXXX......XXXXX',
  'XXXX........XXXX',
];
```

13 rows × 16 cols. With pixelSize=4, that's 52×64 pixels. Pretty good.

But I want the opening to have a curved bottom. Let me adjust:

Actually a flat-bottomed opening looks fine. Let me use this.

Actually let me think about it more. I want a curved arch opening at the bottom center. So:

```
....XXXXXXXX....   top of dome
...XXXXXXXXXX...
..XXXXXXXXXXXX..   dome ends, start of body
.XXXXXXXXXXXXXX.
XXXXXXXXXXXXXXXX   full body
XXXXXXXXXXXXXXXX
XXXXXXXXXXXXXXXX
XXXXXXXXXXXXXXXX
XXXXXXXXXXXXXXXX   start of opening
XXXXXXX..XXXXXXX   opening begins, 2 wide
XXXXXX....XXXXXX   4 wide
XXXXX......XXXXX   6 wide
XXXX........XXXX   8 wide
XXX..........XXX   10 wide
```

Hmm but we want it to be more dome-shaped at the top too:

Actually let me just keep it simple. Let me define the pattern explicitly:

```js
const BUNKER_PATTERN = [
  '....XXXXXXXX....',
  '...XXXXXXXXXX...',
  '..XXXXXXXXXXXX..',
  '.XXXXXXXXXXXXXX.',
  'XXXXXXXXXXXXXXXX',
  'XXXXXXXXXXXXXXXX',
  'XXXXXXXXXXXXXXXX',
  'XXXXXXXXXXXXXXXX',
  'XXXXXXXXXXXXXXXX',
  'XXXXXXX..XXXXXXX',
  'XXXXXX....XXXXXX',
  'XXXXX......XXXXX',
  'XXXX........XXXX',
];
```

This is fine.

Now to draw the bunker and destroy pixels on bullet hit:

```js
function drawBunker(b) {
  ctx.fillStyle = '#0f0';
  for (let y = 0; y < b.gridH; y++) {
    for (let x = 0; x < b.gridW; x++) {
      if (b.grid[y][x]) {
        ctx.fillRect(b.x + x * b.pixelSize, b.y + y * b.pixelSize, b.pixelSize, b.pixelSize);
      }
    }
  }
}

function damageBunker(b, bx, by, radius) {
  // Convert bullet position to grid coords
  const gx = Math.floor((bx - b.x) / b.pixelSize);
  const gy = Math.floor((by - b.y) / b.pixelSize);
  const r2 = radius * radius;
  for (let y = gy - radius; y <= gy + radius; y++) {
    for (let x = gx - radius; x <= gx + radius; x++) {
      if (y < 0 || y >= b.gridH || x < 0 || x >= b.gridW) continue;
      const dx = x - gx, dy = y - gy;
      if (dx*dx + dy*dy <= r2) {
        b.grid[y][x] = false;
      }
    }
  }
}

function bulletHitsBunker(bullet) {
  for (const b of bunkers) {
    if (bullet.x >= b.x && bullet.x < b.x + b.w && bullet.y >= b.y && bullet.y < b.y + b.h) {
      const gx = Math.floor((bullet.x - b.x) / b.pixelSize);
      const gy = Math.floor((bullet.y - b.y) / b.pixelSize);
      if (gx >= 0 && gx < b.gridW && gy >= 0 && gy < b.gridH && b.grid[gy][gx]) {
        damageBunker(b, bullet.x, bullet.y, 2);
        return true;
      }
    }
  }
  return false;
}
```

OK now let me put it all together. Let me also think about the UFO:

```js
function spawnUfo() {
  ufo = { x: -60, y: 50, w: 60, h: 20, dx: 2 };
}

function updateUfo() {
  if (!ufo) {
    ufoTimer--;
    if (ufoTimer <= 0 && aliens.some(a => a.alive)) {
      spawnUfo();
      ufoTimer = 60 * 20 + Math.random() * 60 * 10;
    }
    return;
  }
  ufo.x += ufo.dx;
  if (ufo.x > W + 60) ufo = null;
}
```

When player bullet hits UFO, award random bonus and play sound.

OK let me put it all together:

```js
function update() {
  if (state !== 'playing') return;
  frame++;
  
  // Stars
  for (const s of stars) {
    s.y += s.sp;
    if (s.y > H) { s.y = 0; s.x = Math.random()*W; }
  }
  
  // Player movement
  if (KEY['ArrowLeft'] || KEY['KeyA']) player.x -= PLAYER_SPEED;
  if (KEY['ArrowRight'] || KEY['KeyD']) player.x += PLAYER_SPEED;
  player.x = Math.max(10, Math.min(W - 10 - player.w, player.x));
  
  // Player shooting
  if (player.cooldown > 0) player.cooldown--;
  if ((KEY['Space'] || KEY['ArrowUp']) && player.cooldown <= 0 && playerBullets.length < MAX_PLAYER_BULLETS) {
    playerBullets.push({ x: player.x + player.w/2 - 1.5, y: player.y - 10, w: 3, h: 12 });
    player.cooldown = 15;
    beep(440, 0.08, 'square', 0.03);
  }
  
  // Invuln tick
  if (invuln > 0) invuln--;
  
  // Update bullets
  for (let i = playerBullets.length - 1; i >= 0; i--) {
    const b = playerBullets[i];
    b.y -= PLAYER_BULLET_SPEED;
    if (b.y < 30) { playerBullets.splice(i, 1); continue; }
    if (bulletHitsBunker(b)) { playerBullets.splice(i, 1); continue; }
    // Check alien hit
    let hit = false;
    for (const a of aliens) {
      if (!a.alive) continue;
      if (b.x < a.x + a.w && b.x + b.w > a.x && b.y < a.y + a.h && b.y + b.h > a.y) {
        a.alive = false;
        score += ALIEN_POINTS[a.type];
        spawnExplosion(a.x + a.w/2, a.y + a.h/2, ALIEN_COLORS[a.type]);
        beep(800, 0.1, 'square', 0.04);
        playerBullets.splice(i, 1);
        hit = true;
        break;
      }
    }
    if (hit) continue;
    // Check UFO
    if (ufo && b.x < ufo.x + ufo.w && b.x + b.w > ufo.x && b.y < ufo.y + ufo.h && b.y + b.h > ufo.y) {
      const bonus = UFO_BONUSES[Math.floor(Math.random() * UFO_BONUSES.length)];
      score += bonus;
      spawnUfoExplosion(ufo.x + ufo.w/2, ufo.y + ufo.h/2, bonus);
      ufo = null;
      playerBullets.splice(i, 1);
      beep(660, 0.3, 'triangle', 0.05);
    }
  }
  
  // Alien bullets
  for (let i = alienBullets.length - 1; i >= 0; i--) {
    const b = alienBullets[i];
    b.y += ALIEN_BULLET_SPEED;
    if (b.y > H) { alienBullets.splice(i, 1); continue; }
    if (bulletHitsBunker(b)) { alienBullets.splice(i, 1); continue; }
    // Hit player?
    if (invuln <= 0 && b.x < player.x + player.w && b.x + b.w > player.x && b.y < player.y + player.h && b.y + b.h > player.y) {
      playerHit();
      alienBullets.splice(i, 1);
      continue;
    }
    // Hit other alien bullets (clear both for visual)
    // skip for simplicity
  }
  
  // Alien shooting
  if (Math.random() < 0.012 + level * 0.002) {
    const shooters = aliens.filter(a => a.alive);
    if (shooters.length > 0) {
      const shooter = shooters[Math.floor(Math.random() * shooters.length)];
      // Find lowest alien in this column
      let lowest = null;
      for (const a of aliens) {
        if (a.alive && a.col === shooter.col && (!lowest || a.y > lowest.y)) {
          lowest = a;
        }
      }
      if (lowest) {
        alienBullets.push({
          x: lowest.x + lowest.w/2 - 2,
          y: lowest.y + lowest.h,
          w: 4,
          h: 12,
          color: '#fff'
        });
        beep(180, 0.1, 'sawtooth', 0.025);
      }
    }
  }
  
  // Alien movement
  const aliveAliens = aliens.filter(a => a.alive);
  if (aliveAliens.length === 0) {
    nextWave();
    return;
  }
  
  // Check if any alien would hit edge on next step
  let minX = Infinity, maxX = -Infinity, maxY = -Infinity;
  for (const a of aliveAliens) {
    if (a.x < minX) minX = a.x;
    if (a.x + a.w > maxX) maxX = a.x + a.w;
    if (a.y + a.h > maxY) maxY = a.y + a.h;
  }
  
  const leftEdge = 20;
  const rightEdge = W - 20;
  
  aliens.tick++;
  const speedFactor = Math.max(1, Math.floor(aliens.length / Math.max(1, aliveAliens.length)) * 0.5);
  // Actually the classic speed: faster as fewer aliens
  const stepDelay = Math.max(2, Math.floor(60 * aliveAliens.length / aliens.length * 0.8));
  // Hmm let me simplify:
  // stepDelay decreases as fewer aliens
  // total aliens = 55
  // when 55 alive, delay = 60
  // when 1 alive, delay = 1
  // Linear: delay = 1 + (aliveAliens.length / 55) * 50 = roughly
  
  // Better: classic formula
  const totalAliens = 55;
  const aliveCount = aliveAliens.length;
  const stepFrames = Math.max(1, Math.floor(aliveCount * 55 / totalAliens));
  // Actually the classic is: step = 55 - aliveCount + 5? Or similar
  
  // Let me use a simpler approach
  const baseDelay = 50;
  const speedup = (totalAliens - aliveCount) / totalAliens; // 0 to ~1
  const currentDelay = Math.max(3, Math.floor(baseDelay * (1 - speedup * 0.95)));
  
  if (aliens.tick >= currentDelay) {
    aliens.tick = 0;
    aliens.animFrame = 1 - aliens.animFrame;
    
    let shiftDown = false;
    if ((aliens.dir === 1 && maxX + aliens.dx > rightEdge) || (aliens.dir === -1 && minX - aliens.dx < leftEdge)) {
      aliens.dir = -aliens.dir;
      shiftDown = true;
    }
    
    if (shiftDown) {
      for (const a of aliens) {
        if (a.alive) a.y += aliens.dy;
      }
      marchSound();
    } else {
      for (const a of aliens) {
        if (a.alive) a.x += aliens.dx * aliens.dir;
      }
      marchSound();
    }
  }
  
  // Check if aliens reached player/bunker
  for (const a of aliveAliens) {
    if (a.y + a.h >= player.y) {
      gameOver();
      return;
    }
  }
  
  // Check if aliens touching bunkers
  for (const a of aliveAliens) {
    for (const b of bunkers) {
      if (a.x + a.w > b.x && a.x < b.x + b.w && a.y + a.h > b.y && a.y < b.y + b.h) {
        // Destroy entire bunker
        for (let y = 0; y < b.gridH; y++) {
          for (let x = 0; x < b.gridW; x++) b.grid[y][x] = false;
        }
      }
    }
  }
  
  // Update UFO
  updateUfo();
  
  // Update particles
  for (let i = particles.length - 1; i >= 0; i--) {
    const p = particles[i];
    p.x += p.vx;
    p.y += p.vy;
    p.life--;
    if (p.life <= 0) particles.splice(i, 1);
  }
}

function spawnExplosion(x, y, color) {
  for (let i = 0; i < 12; i++) {
    const angle = Math.random() * Math.PI * 2;
    const speed = Math.random() * 3 + 1;
    particles.push({
      x, y,
      vx: Math.cos(angle) * speed,
      vy: Math.sin(angle) * speed,
      life: 30 + Math.random() * 15,
      color,
      size: 2 + Math.random() * 2,
    });
  }
}

function spawnUfoExplosion(x, y, bonus) {
  for (let i = 0; i < 20; i++) {
    const angle = Math.random() * Math.PI * 2;
    const speed = Math.random() * 4 + 1.5;
    particles.push({
      x, y,
      vx: Math.cos(angle) * speed,
      vy: Math.sin(angle) * speed,
      life: 40 + Math.random() * 20,
      color: '#f00',
      size: 2 + Math.random() * 3,
    });
  }
  // Floating bonus text
  particles.push({
    x, y,
    vx: 0, vy: -1,
    life: 90,
    color: '#ff0',
    size: 0,
    text: bonus + '',
  });
}

function playerHit() {
  lives--;
  invuln = 120;
  spawnExplosion(player.x + player.w/2, player.y + player.h/2, '#0f0');
  noiseBurst(0.2, 0.08);
  if (lives <= 0) {
    gameOver();
  }
}

function gameOver() {
  state = 'gameover';
  if (score > hiScore) {
    hiScore = score;
    localStorage.setItem('si_hi', hiScore);
  }
}
```

Now the rendering:

```js
function render() {
  // Background
  ctx.fillStyle = '#000';
  ctx.fillRect(0, 0, W, H);
  
  // Stars
  for (const s of stars) {
    s.b = (s.b + 0.02) % 1;
    const brightness = 0.4 + 0.6 * Math.abs(Math.sin(s.b * Math.PI));
    ctx.fillStyle = `rgba(255,255,200,${brightness})`;
    ctx.fillRect(s.x, s.y, s.s, s.s);
  }
  
  // Top line / play area boundary
  // (none in classic)
  
  // HUD top
  ctx.fillStyle = '#fff';
  ctx.font = '18px "Courier New", monospace';
  ctx.textAlign = 'left';
  ctx.fillText('SCORE: ' + score, 30, 30);
  ctx.fillText('HI: ' + hiScore, W/2 - 30, 30);
  ctx.textAlign = 'right';
  ctx.fillText('WAVE ' + level, W - 30, 30);
  
  // Lives (small player icons)
  for (let i = 0; i < lives - 1; i++) {
    drawPattern(PLAYER_PATTERN, 30 + i * 28, H - 30, 3, '#0f0');
  }
  
  // Player
  if (state !== 'gameover') {
    if (invuln <= 0 || Math.floor(frame / 6) % 2 === 0) {
      drawPattern(PLAYER_PATTERN, player.x + player.w/2 - patternWidth(PLAYER_PATTERN, 5)/2, player.y + player.h - patternHeight(PLAYER_PATTERN, 5), 5, '#0f0');
    }
  }
  
  // Aliens
  for (const a of aliens) {
    if (!a.alive) continue;
    const pat = ALIEN_PATTERNS[a.type][aliens.animFrame];
    const scale = 3;
    drawPattern(pat, a.x + a.w/2 - patternWidth(pat, scale)/2, a.y + a.h/2 - patternHeight(pat, scale)/2, scale, ALIEN_COLORS[a.type]);
  }
  
  // UFO
  if (ufo) {
    drawPattern(UFO_PATTERN, ufo.x, ufo.y, 3, '#f00');
  }
  
  // Bunkers
  for (const b of bunkers) {
    drawBunker(b);
  }
  
  // Bullets
  ctx.fillStyle = '#fff';
  for (const b of playerBullets) {
    ctx.fillRect(b.x, b.y, b.w, b.h);
  }
  for (const b of alienBullets) {
    ctx.fillStyle = b.color || '#fff';
    // Draw as zigzag for visual interest
    ctx.fillRect(b.x, b.y, b.w, b.h);
  }
  
  // Particles
  for (const p of particles) {
    if (p.text) {
      ctx.fillStyle = p.color;
      ctx.font = 'bold 16px "Courier New", monospace';
      ctx.textAlign = 'center';
      ctx.fillText(p.text, p.x, p.y);
    } else {
      ctx.fillStyle = p.color;
      ctx.fillRect(p.x, p.y, p.size, p.size);
    }
  }
  
  // Ground line
  ctx.strokeStyle = '#f00';
  ctx.lineWidth = 2;
  ctx.beginPath();
  ctx.moveTo(0, H - 20);
  ctx.lineTo(W, H - 20);
  ctx.stroke();
  
  // Game over screen
  if (state === 'gameover') {
    ctx.fillStyle = 'rgba(0,0,0,0.7)';
    ctx.fillRect(0, 0, W, H);
    ctx.fillStyle = '#f00';
    ctx.font = 'bold 64px "Courier New", monospace';
    ctx.textAlign = 'center';
    ctx.fillText('GAME OVER', W/2, H/2 - 30);
    ctx.fillStyle = '#fff';
    ctx.font = '24px "Courier New", monospace';
    ctx.fillText('Final Score: ' + score, W/2, H/2 + 10);
    ctx.fillText('Press R or ENTER to restart', W/2, H/2 + 50);
  }
  
  if (state === 'paused') {
    ctx.fillStyle = 'rgba(0,0,0,0.7)';
    ctx.fillRect(0, 0, W, H);
    ctx.fillStyle = '#ff0';
    ctx.font = 'bold 48px "Courier New", monospace';
    ctx.textAlign = 'center';
    ctx.fillText('PAUSED', W/2, H/2);
  }
}

function loop() {
  update();
  render();
  requestAnimationFrame(loop);
}

initStars();
newGame();
loop();
```

Hmm wait, I need to think about the colors. Classic Space Invaders:
- Player: white (or green in the original arcade with color overlay)
- Aliens: white with colored overlays

Let me make it look more colorful:
- Player: green (#0f0)
- Squid: cyan (#0ff)
- Crab: magenta (#f0f)
- Octopus: yellow (#ff0)
- UFO: red (#f00)

Looks good.

Let me also fix some issues:
1. The `marchSound()` should only fire on actual step, and the speed/delay calculation should be smoother.
2. Need to ensure player can shoot but aliens don't fire too often.
3. The patternWidth calculation should not be confused with player.w.

Actually wait, I have `patternWidth(PLAYER_PATTERN, 5)/2` for centering. Player pattern is 7 wide, so 7*5=35 pixels. Player.w is 56. So we need to add `player.x + player.w/2 - 35/2` = `player.x + 28 - 17.5` = `player.x + 10.5`. OK fine.

For aliens, the w is 40. The patterns are 8, 12, 12 chars wide. Scaled by 3: 24, 36, 36. So:
- Squid: 24 wide, centered in 40 box
- Crab: 36 wide, centered
- Octopus: 36 wide, centered

That should look OK.

Let me also think about the bunker pattern more carefully. I want it to look right.

BUNKER_PATTERN:
```
....XXXXXXXX....   row 0
...XXXXXXXXXX...   row 1
..XXXXXXXXXXXX..   row 2
.XXXXXXXXXXXXXX.   row 3
XXXXXXXXXXXXXXXX   row 4
XXXXXXXXXXXXXXXX   row 5
XXXXXXXXXXXXXXXX   row 6
XXXXXXXXXXXXXXXX   row 7
XXXXXXXXXXXXXXXX   row 8
XXXXXXX..XXXXXXX   row 9
XXXXXX....XXXXXX   row 10
XXXXX......XXXXX   row 11
XXXX........XXXX   row 12
```

That's 13 rows × 16 cols. With pixelSize=4: 52 wide × 52 tall. Good size.

Hmm but I'd like the bottom opening to be a bit more arched. Let me try:

```
....XXXXXXXX....   row 0
...XXXXXXXXXX...   row 1
..XXXXXXXXXXXX..   row 2
.XXXXXXXXXXXXXX.   row 3
XXXXXXXXXXXXXXXX   row 4
XXXXXXXXXXXXXXXX   row 5
XXXXXXXXXXXXXXXX   row 6
XXXXXXXXXXXXXXXX   row 7
XXXXXXXXXXXXXXXX   row 8
XXXXXXXXXXXXXXXX   row 9
XXXXXXX..XXXXXXX   row 10
XXXXXX....XXXXXX   row 11
XXXXX......XXXXX   row 12
XXX..........XXX   row 13
```

14 rows × 16 cols. With pixelSize=4: 56 wide × 56 tall. OK.

Actually I realize my pattern should be defined once outside the buildBunkers function so it's not recreated. Let me adjust.

OK let me also reconsider the entire game. Let me write the final version.

Let me think about potential issues:
1. Bullet limit - classic Space Invaders only allows 1 player bullet at a time. Good.
2. Alien collision with bunkers - destroy them.
3. UFO - random bonus.
4. Lives display - small player icons at bottom.
5. Score display - at top.
6. Level progression.
7. Game over conditions.
8. Restart.

Let me also add some visual polish:
- Particle explosions
- Screen flash on player death
- Score popup on alien kill
- Smooth transitions

I think the design is solid. Let me finalize the code.

One thing to fix: when player has 0 lives remaining, the player should still be drawn or not. Let me handle that — if lives <= 0, game over.

For the lives display, I'll draw small player cannons at bottom-left.

Let me also add a "wave" indicator showing the next wave when all aliens are cleared.

Let me think about timing. With requestAnimationFrame at 60fps:
- frame++ each call
- Player cooldown in frames
- March delay in frames

The march delay should be relative to the framerate. With more aliens, slower. With fewer, faster.

Classic formula approximately:
- 55 aliens: ~55 frames per step
- 1 alien: ~1 frame per step

So `stepFrames = aliveCount` (which is 1 to 55). When aliveCount=1, every frame. When 55, every 55 frames.

But that means with 1 alien it moves every frame which is super fast. Let me check the actual arcade... I think it's more like 1-55 frames. Let me just use that.

Actually a common modern implementation uses: `speed = 1 + (totalAliens - aliveCount) * speedup`. The speedup constant makes it ramp up.

Let me use: `stepFrames = Math.max(1, Math.floor(aliveCount * 0.9))`. With 55 aliens, ~49 frames per step. With 1 alien, 1 frame per step.

Actually I'll use the classic formula: `tick = Math.max(1, aliveCount)`. Simple.

But to make the initial state not too slow, let me start at faster: `tick = Math.max(1, Math.floor(aliveCount * 0.7))`. With 55, ~38 frames. With 1, 1 frame. Reasonable.

Or even simpler: `1 + Math.floor((aliveCount-1) * 50 / 54)`. With 55 alive: 50 frames. With 1 alive: 1 frame. Linear from 1 to 50.

Let me just hard-code a simple mapping:
```js
function stepDelay(aliveCount) {
  if (aliveCount >= 55) return 50;
  if (aliveCount >= 40) return 40;
  if (aliveCount >= 30) return 30;
  if (aliveCount >= 20) return 20;
  if (aliveCount >= 10) return 12;
  if (aliveCount >= 5) return 7;
  if (aliveCount >= 2) return 4;
  return 1;
}
```

This creates a nice progression.

Actually let me just compute it dynamically:
```js
const aliveCount = aliveAliens.length;
const total = aliens.length;
const ratio = aliveCount / total;  // 1 to ~0
const step = Math.max(1, Math.floor(50 * ratio * ratio));  // Quadratic speedup
```

So with 55 alive (ratio=1): 50 frames. With 27 alive (ratio=0.5): 12 frames. With 1 alive (ratio~0): 1 frame.

Hmm actually classic is more linear. Let me try:
```js
const step = Math.max(1, Math.floor(50 * ratio));
```

With 55: 50, 27: 24, 5: 4, 1: 0→1.

Or use the exact classic formula. From the original Space Invaders code, the step delay was:
```
delay = 55 - numAlive + 5? 
```

Hmm. Let me just use a simple approach that feels right:
```js
const stepFrames = Math.max(1, Math.floor(aliveCount * 1.0));
```

With 55 alive, 55 frames per step (slow). With 1 alive, 1 frame per step (super fast).

OK let me just go with `Math.max(1, aliveCount)` which is the classic feel.

Actually I want it to feel fast enough even early on. Let me do:
```js
const stepFrames = Math.max(1, Math.floor(aliveCount * 0.6 + 5));
```

With 55 alive: 38 frames. With 27 alive: 21 frames. With 1 alive: 5 frames. Hmm not great.

Let me just use a simple step function:
```js
function stepFrames(alive) {
  if (alive >= 50) return 45;
  if (alive >= 40) return 35;
  if (alive >= 30) return 25;
  if (alive >= 20) return 18;
  if (alive >= 10) return 10;
  if (alive >= 5) return 6;
  if (alive >= 2) return 3;
  return 1;
}
```

This is clean. Let me use this.

Now let me put together the full HTML file. I'll write it out completely.

Actually I realize I need to think about one more thing: the bunkers can be partially destroyed. As bullets hit them, chunks should be removed. My approach with `damageBunker` using a circular radius is good but might create unnatural shapes. Let me adjust:

Actually circular damage looks fine and feels good for a game. Let me keep it.

One concern: the bullet hit position might be just at the edge of a pixel. Let me make the bullet check slightly more forgiving by checking a small radius.

Also, I realize for bullet collision with aliens, I should use the alien's bounding box (which is larger than the drawn sprite). This feels more responsive and forgiving.

Let me write the final code now. I'll be careful to make it work correctly.

Also for audio, since I'm using Web Audio API, I need to make sure it works without user interaction (or at least start it on first click/key). I'll initialize on first keydown.

Actually I see I have `audioEnabled = false` initially and set it true on first key. The audio context also needs to be resumed on first user interaction. I'll handle both.

Let me also add an indicator that audio is enabled (or just don't worry about it).

OK final code coming up. Let me write the whole thing.

One more thought: I want to add a "wave complete" message and brief pause before next wave. Let me add a `state = 'wavebreak'` state.

```js
function nextWave() {
  level++;
  state = 'wavebreak';
  waveBreakTimer = 120; // 2 seconds
  // ... or just immediately rebuild
}

function update() {
  if (state === 'wavebreak') {
    waveBreakTimer--;
    if (waveBreakTimer <= 0) {
      // Build new aliens and bunkers
      playerBullets = [];
      alienBullets = [];
      particles = [];
      ufo = null;
      invuln = 60;
      buildAliens();
      buildBunkers();
      state = 'playing';
    }
    return;
  }
  // ... rest of update
}
```

And render the wave message.

Actually let me simplify — just rebuild immediately and use a flash effect.

OK enough planning. Let me write the final code.

Let me also handle the edge case where the player gets stuck on edge. Also handle continuous keypress for shooting (with cooldown).

Wait, I have `(KEY['Space'] || KEY['ArrowUp']) && player.cooldown <= 0`. With cooldown, you can hold space and it'll fire every 15 frames. That's good for rapid fire but limits the rate. Actually classic only allows one bullet on screen at a time, so it's not rapid fire — you wait for the bullet to either hit or leave the screen.

Let me adjust: cooldown applies only when there's a bullet on screen. Actually the simplest is: only shoot if no bullet exists.

```js
if (KEY['Space'] && playerBullets.length === 0) {
  playerBullets.push(...);
}
```

But this requires the player to release and press again. That's classic. Let me allow hold-down with a slight cooldown for forgiveness, but limit bullets.

Actually the classic only allows 1 bullet on screen. If you hold space, the bullet fires once, then you have to release and press again after the bullet is gone. That's how it works in the original.

But for modern feel, let me allow press-and-hold with a small cooldown (like 8 frames). This makes the game more playable.

OK let me write the code. I'll be done thinking.

Let me start by writing the file:

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Space Invaders</title>
<style>
  ...
</style>
</head>
<body>
  ...
<script>
  ...
</script>
</body>
</html>
```

Let me put together the full file. I'll write it all out.

Actually let me make a slight refinement to the alien patterns to make them look more authentic. The classic Space Invaders has very specific shapes. Let me use the actual sprites:

**Squid (top):**
```
  XXXX  
 XXXXXX 
XXXXXXXX
XX XX XX
XXXXXXXX
  X  X  
 X    X 
X      X
```
That's 8 rows × 8 cols.

**Crab (middle):**
```
   XXXXXX   
  XXXXXXXX  
 XXXXXXXXXX 
XX XX  XX XX
XXXXXXXXXXXX
 XXXXXXXXXX 
  XX    XX  
 XX      XX 
```
That's 8 rows × 12 cols.

**Octopus (bottom):**
```
    XXXX    
 XXXXXXXXXX 
XXXXXXXXXXXX
XXX  XX  XXX
XXXXXXXXXXXX
  XX XX XX  
 XX  XX  XX 
      XX    
```
That's 8 rows × 12 cols.

OK these are the same patterns. Let me code them up.

Let me finalize the code now. I'll write it all out at once.

Actually let me also think about visual polish:
1. Star field background - twinkle effect
2. Aliens have a glow effect when they get close
3. Player has a flashing effect during invulnerability
4. Score popups
5. Particle explosions
6. UFO bonus display
7. Wave indicator

Let me make sure I include all these.

Also one thing I missed: the player can move while shooting. Good.

And: when game is over due to aliens reaching bottom, show "INVASION" or similar message.

Let me write it.

One concern: the alien bullet collision. I have `bulletHitsBunker` which returns true if hit. But alien bullets should also be able to hit player bullets (in some games). Let me skip this — keep it simple, only player bullets destroy alien bullets... actually no, let me not have bullets collide with each other. Just let them pass through each other.

OK writing now.

Let me also make sure the game has a proper start state. When the page loads, the game starts immediately. The first keypress enables audio. Or I could have a "PRESS START" screen. Let me have a start screen actually for nicer feel:

```js
function newGame() {
  // ... reset state
  state = 'start'; // Start screen
}

function update() {
  if (state === 'start') {
    if (KEY['Space'] || KEY['Enter']) {
      state = 'playing';
    }
    return;
  }
  // ...
}
```

Hmm but then the player needs to press space to start. Let me add a start screen with "PRESS SPACE TO START" message.

Actually let me just start playing immediately. Players can figure it out from the instructions.

Wait — for audio to be enabled, I need user interaction. So if they don't interact, no audio. That's fine. The game works without audio.

OK, writing the final code now.

Wait, I should think about the canvas size. Let me use 800x700 which gives a nice play area. Or 720x780? Let me go with 800x720 to give a bit more vertical space.

Actually classic Space Invaders has a 2:3 aspect ratio. 800x600? Let me use that and put the HUD at top, player at bottom. Actually let me use 800x720 to give more room.

Hmm, let me just use 800x600 for the canvas, then the game area is smaller. Or 800x700.

Let me use 800x700.

Player at y=560 (bottom 100 reserved).
Aliens start at y=80.
Bunkers at y=460 (above player).
Ground line at y=620.

Hmm let me lay this out:
- Canvas: 800x700
- HUD area: top 50px
- Aliens start: y=80 (rows take 5*42=210, ending at y=290)
- Bunkers: y=480 (height 56)
- Player: y=580 (height 40)
- Ground line: y=640
- Bottom (lives): y=660

Player firing range: from y=580 to top, hitting aliens at y=80 to 290.
Aliens firing range: from y=290 down to y=640 (ground).

OK let me code this up.

```js
const W = 800;
const H = 700;
const GROUND_Y = 640;
const PLAYER_Y = 590;
const BUNKER_Y = 470;
```

Now let me write the final HTML. Going to write it all now.

Actually I realize 700 height might be cramped. Let me use 720 height. Or 768. Let me use 720.

Canvas: 800x720
HUD: 0-50
Aliens: 80-290 (5 rows × 42)
Bunkers: 470-526
Player: 590-630
Ground: 660
Lives: 690

OK let me write it.

One more consideration: the speed at which the alien march tick happens. With 55 aliens and step=45, that's 0.75 steps per second, which is too slow. Let me check the actual game...

Actually classic Space Invaders:
- 55 aliens: 1 step per ~55 frames (about 1 second)
- 1 alien: 1 step per frame (very fast)

But that's slow at start. Modern versions are faster. Let me use a faster initial speed.

step = max(1, aliveCount / 2) ?

55 → 27
27 → 13
1 → 0 → 1

Or:
step = max(2, aliveCount)

55 → 55 frames
1 → 2 frames

This is what I had before. Let me check classic game feel. In the original, 55 aliens move every ~0.9 sec. So at 60fps that's 54 frames. Close to my formula.

OK I'll use step = max(1, aliveCount). For 55, step=55 frames (~0.9s). For 1, step=1 frame.

But the screen also wraps with shift down. So the movement pattern is:
- Move right every step
- When right edge hit: shift down, move left
- Etc.

The "speed up" happens because step frames decrease.

OK writing now.

Hmm one more thing: the wave reset. After clearing all aliens, the game should show "WAVE COMPLETE" briefly then start next wave with bunkers rebuilt and aliens in formation again.

```js
function nextWave() {
  level++;
  playerBullets = [];
  alienBullets = [];
  particles = [];
  ufo = null;
  invuln = 90;
  buildAliens();
  buildBunkers();
}
```

Simple. Just rebuild.

For visual feedback, I'll show a flash and a brief "WAVE X" message at top.

OK let me finalize.

Actually for the alien's `tick` property, I'm using it as a counter for steps. But each frame I increment it. Once it reaches stepFrames, I do a step. So with stepFrames=55, it takes 55 frames to step. Good.

But wait, if I want the aliens to take 55 frames with 55 aliens, that's about 1 second per step at 60fps. That matches the classic.

OK writing now.

Let me also handle: what if the player kills all aliens very fast (before they descend much)? Then on next wave, they start at top again. That works with my current setup.

What about high score? Use localStorage. Good.

What about pause? Press P to toggle.

What about restart? Press R at any time.

OK final code. Writing.

Hmm one more thing. I realize when the player gets hit, they should respawn after a brief invulnerability period. Or they should immediately restart the wave. In classic Space Invaders, the player respawns at center bottom with brief invulnerability.

I have `invuln = 120` which is 2 seconds at 60fps. That's good. But the player position needs to reset to center.

```js
function playerHit() {
  lives--;
  invuln = 120;
  spawnExplosion(player.x + player.w/2, player.y + player.h/2, '#0f0');
  player.x = W/2 - player.w/2;
  playerBullets = [];
  alienBullets = [];
  noiseBurst(0.2, 0.08);
  if (lives <= 0) {
    gameOver();
  }
}
```

OK now let me write the complete file.

I want to use `requestAnimationFrame` but also have a deterministic game loop. Actually RAF is fine — 60fps is consistent enough on most displays.

```js
let lastTime = 0;
function loop(t) {
  update();
  render();
  requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
```

OK writing the file now.

Let me also think about adding a high score reset feature (maybe hold R for 2 sec?). Nah, keep it simple.

Let me also add screen shake on player hit. Nah, keep it simple.

Just particle explosions for now.

OK writing the file.

Wait, the bullet collision with bunker — I should also remove the bullet from screen, not just damage the bunker.

```js
function bulletHitsBunker(bullet) {
  for (const b of bunkers) {
    if (bullet.x + bullet.w >= b.x && bullet.x <= b.x + b.w && 
        bullet.y + bullet.h >= b.y && bullet.y <= b.y + b.h) {
      const gx = Math.floor((bullet.x - b.x) / b.pixelSize);
      const gy = Math.floor((bullet.y - b.y) / b.pixelSize);
      // Check bullet path
      let hit = false;
      for (let dy = 0; dy < Math.ceil(bullet.h / b.pixelSize); dy++) {
        for (let dx = 0; dx < Math.ceil(bullet.w / b.pixelSize); dx++) {
          const px = gx + dx, py = gy + dy;
          if (px >= 0 && px < b.gridW && py >= 0 && py < b.gridH && b.grid[py][px]) {
            damageBunker(b, b.x + px * b.pixelSize + b.pixelSize/2, b.y + py * b.pixelSize + b.pixelSize/2, 2);
            hit = true;
          }
        }
      }
      return hit;
    }
  }
  return false;
}
```

Hmm this is getting complex. Let me simplify:

```js
function bulletHitsBunker(bullet) {
  for (const b of bunkers) {
    // Quick AABB check
    if (bullet.x + bullet.w < b.x || bullet.x > b.x + b.w) continue;
    if (bullet.y + bullet.h < b.y || bullet.y > b.y + b.h) continue;
    // Check pixel overlap
    const startX = Math.max(0, Math.floor((bullet.x - b.x) / b.pixelSize));
    const endX = Math.min(b.gridW - 1, Math.floor((bullet.x + bullet.w - b.x) / b.pixelSize));
    const startY = Math.max(0, Math.floor((bullet.y - b.y) / b.pixelSize));
    const endY = Math.min(b.gridH - 1, Math.floor((bullet.y + bullet.h - b.y) / b.pixelSize));
    for (let py = startY; py <= endY; py++) {
      for (let px = startX; px <= endX; px++) {
        if (b.grid[py][px]) {
          // Damage at this point
          damageBunker(b, px, py, 2);
          return true;
        }
      }
    }
  }
  return false;
}

function damageBunker(b, px, py, radius) {
  for (let dy = -radius; dy <= radius; dy++) {
    for (let dx = -radius; dx <= radius; dx++) {
      const nx = px + dx, ny = py + dy;
      if (nx < 0 || nx >= b.gridW || ny < 0 || ny >= b.gridH) continue;
      if (dx*dx + dy*dy <= radius*radius) {
        b.grid[ny][nx] = false;
      }
    }
  }
}
```

OK that's cleaner.

Let me also think about: the alien bullets should also be destructible by bunker damage. When alien bullet hits a bunker pixel, damage it. And the bullet is consumed. Good.

Let me finalize and write the file. I'll start by checking the workspace and then writing index.html.

Let me first check the workspace.

I'll build a complete Space Invaders game with all the requested features. Let me start by checking the workspace and then create the file.

bash {"command":"ls -la /workspace/"}
toolResult

toolResult

total 4 drwxrwxrwx 2 node node 64 Jul 22 18:25 . drwxr-xr-x 1 root root 4096 Jul 22 18:25 ..

assistant
thinking
Empty workspace. Let me create the index.html file with the complete game.

Let me carefully construct the complete game now. I'll make sure it includes:
1. Player cannon with movement and shooting
2. Descending grid of aliens that advance and fire back
3. Destructible bunkers
4. Score system
5. Lives system
6. Procedural graphics (pixel-based alien/player/bunker patterns)
7. Sound effects (Web Audio API)
8. UFO bonus enemy
9. Particles/explosions
10. Multiple waves with increasing difficulty

Let me write the complete code carefully.Now

I'll write the complete Space Invaders game with all features in a single self-contained `index.html`:

write /workspace/index.html (25611 bytes)
toolResult

toolResult

Successfully wrote 25611 bytes to /workspace/index.html

assistant
thinking
Let me verify the file was created and looks good. Let me also test it by checking for any obvious syntax issues.

Let me check the file briefly and then do a sanity check by looking at the code logic.

Issues I want to double-check:
1. The buildBunkers uses `BUNKER_PATTERN[y][x] === "X"` - the pattern is defined as strings of length 16. Each row has 16 characters. ✓
2. The bullet collision logic - I have `bullet.y + bullet.h - 1 - b.x`. Wait, let me re-check this. For player bullets moving up (negative y), the bullet is at top and we want to check from `bullet.y` (top) to `bullet.y + bullet.h` (bottom). For alien bullets moving down (positive y), we want from `bullet.y` (top) to `bullet.y + bullet.h` (bottom). So `bullet.y` is always the top. ✓
3. Player drawing - `player.x + player.w/2 - patW(pat, scale)/2` centers it. ✓
4. Aliens drawing - centered within their bounding box. ✓
5. Game over on aliens reaching player - `a.y + a.h >= PLAYER_Y - 2`. PLAYER_Y is 600. When alien bottom is at 598 or so, game over. ✓
6. Bunker y position - BUNKER_Y is 490, bunker height ~56, so bunker ends at 546. Player at 600. Plenty of room.
7. Player can shoot - one bullet at a time, cooldown 12 frames.
8. Aliens shoot - probability-based, max 4 bullets.
9. Score, lives, high score all tracked.

Let me also verify the initial alien positions are within the canvas:
- 11 cols × 48 = 528 wide
- startX = (800 - 528) / 2 + 24 = 136 + 24 = 160
- aliens span from 160 to 160 + 528 = 688 (well within 800)
- 5 rows × 40 = 200 tall
- startY = 90
- aliens span from 90 to 290 (well within bounds)

Bullet drawing:
- player bullet: white with cream tip ✓
- alien bullet: red or white with bottom tip ✓

Let me also verify the bunker is properly drawn and destructible. The pattern is 14 rows × 16 cols × 4 pixels = 56×64 pixels. 4 bunkers × 56 = 224, spacing = (800-224)/5 = 115.2. So bunker X positions:
- i=0: 115.2 + 0 = 115
- i=1: 115.2 + 56 + 115.2 = 286
- i=2: 115.2 + 112 + 230 = 458
- i=3: 115.2 + 168 + 346 = 629

Hmm, gap between bunkers = 115.2, bunker width = 56. So each bunker has 115 pixels of space, then 56 of bunker. Reasonable.

Let me also check that the bullet-y for player bullet starts above player (at `player.y - 8 = 592`). That's fine.

Actually wait, I'm checking `b.y + b.h < 30` to remove player bullets when they leave the top. But the HUD line is at 28, so bullets should disappear just above that. Good.

Let me verify the file is valid HTML by reading it back.

Actually let me just double-check by viewing a few key parts. Let me also make sure the title and instructions work.

I think the game should be playable. Let me do a final read-through of the code.

Wait, I see an issue with the `ufo.x` initial value when direction is -1:
```js
ufo = {
  x: dir === 1 ? -80 : W + 10,
  ...
  dx: dir * (1.5 + Math.random() * 0.8),
};
```

When `dir === -1`, dx is negative and x starts at W+10. Then x decreases. OK that's fine.

When `dir === 1`, dx is positive and x starts at -80. Then x increases. OK.

But `if (ufo.x > W + 80) ufo = null` - this only handles the case when UFO is moving right. For leftward UFOs, we need `ufo.x < -80`. Let me add that:

```js
if (ufo.x > W + 80 || ufo.x < -100) ufo = null;
```

Let me fix this.

Also let me verify a few other things and make small tweaks.

Actually, let me also check that the bunker drawing and damage works. In `drawBunker`:
```js
ctx.fillStyle = "#42ff7a";
for (let y = 0; y < b.gridH; y++) {
  for (let x = 0; x < b.gridW; x++) {
    if (b.grid[y][x]) {
      ctx.fillRect(b.x + x * b.pixelSize, b.y + y * b.pixelSize, b.pixelSize, b.pixelSize);
    }
  }
}
```

This draws intact bunker pixels. Good.

In `bulletHitsBunker`, I check overlap and find first hit pixel, then damage around it. Good.

Let me also add a UFO removal condition.

Let me also reconsider: when player loses all lives, what happens?
- `playerHit()` is called
- `lives--` → could go to 0 or negative
- `if (lives <= 0) gameOver()`
- `gameOver()` sets state to 'gameover'
- `update()` returns early if state is 'gameover'
- But wait, `update()` checks `state === "paused" || state === "gameover"` first. So game over state stops updates. Good.

But then `invuln` keeps being checked. Actually `invuln` is decremented inside the playing state only. That's fine.

Let me also verify the lives display. With 3 lives, I show `lives - 1 = 2` icons. That seems off — classic shows `lives` icons where lives is remaining lives including current. So if you have 3 lives, you see 2 extra lives icons. With 1 life, you see 0 extra icons. With 0 lives, game over.

Actually classic Space Invaders shows your remaining lives including the current player. So if you have 3 lives, you see 3 icons? No wait, in classic you see 2 icons for 3 lives. Because you're playing with 1 life now.

Hmm, conventions vary. Let me just show `lives - 1` which means 2 icons for 3 lives, 1 icon for 2 lives, 0 icons for 1 life. That matches classic.

Actually classic shows the number of remaining lives including current. So if you have N lives total, you see N icons, with the current one being played. So with 3 lives, you see 3 icons.

But in the classic Space Invaders, the bottom row shows the player's remaining lives (not including current). So 3 lives → 2 extra icons.

Let me check: classic shows 2 small icons when you start with 3 lives. So `lives - 1` icons is correct. ✓

OK let me fix the UFO issue.

Let me verify the file and fix a small issue with UFO removal when moving left:

edit {"edits":[{"newText":" // ---- UFO ----\n if (ufo) {\n ufo.x += ufo.dx;\n if (ufo.x > W + 80 || ufo.x < -100) ufo = null;\n } else
toolResult

toolResult

Successfully replaced 1 block(s) in /workspace/index.html.

assistant
thinking
Let me also do some additional checks by viewing parts of the file:

Let me verify the file is syntactically valid by running a quick HTML check:

bash {"command":"wc -l index.html && head -1 index.html && tail -3 index.html"}
toolResult

toolResult

934 index.html <!DOCTYPE html> </script> </body> </html>

assistant
bash {"command":"node -e \"\nconst fs = require('fs');\nconst html = fs.readFileSync('index.html', 'utf8');\n// Extract script content\nconst mat
toolResult

toolResult

Script syntax OK (24079 chars)

assistant
thinking
The script syntax is valid. Now let me also do a quick sanity check on the HTML structure.

Let me also think if there are any other potential issues:

1. Player speed feels OK
2. Alien step delay - the speedup as fewer aliens work
3. Bullet limit - 1 for player, 4 for aliens
4. Lives display - shows lives - 1 icons
5. Score display - 5 digit zero-padded
6. High score persistence - localStorage
7. Game over - aliens reaching player OR losing all lives
8. Wave transition - show "WAVE X" message

Let me also think about edge cases:
- What if the player presses Space during game over? I have `state === "gameover" && (e.code === "Enter" || e.code === "Space") newGame()`. Good.
- What if a wave starts with no aliens left over from previous wave? My logic clears bullets and rebuilds. Good.

Let me also verify the alien collision with bunkers — when an alien reaches a bunker, the entire bunker should be erased. My code does this in the update loop. Good.

Let me also verify the visual layout by inspecting positions:
- HUD at top: y=0-28
- Stars throughout
- Aliens: y=90-290 (5 rows × 40 + 30 padding)
- Bunkers: y=490-554 (4 rows × 64)
- Player: y=600-640
- Ground: y=660
- Lives: bottom

Actually wait, the ground line at GROUND_Y = 660. But the lives icons are drawn at `H - 32 - lifeH + 6 = 720 - 32 - 40 + 6 = 654`. So lives icons are at y=654, just above the ground line at 660. Hmm, they might overlap with the ground line.

Let me check the player position. player.y = 600, player.h = 40. So player is from y=600 to y=640. That overlaps with the ground line at y=660? No, 640 < 660, so player is above the ground line. Good.

Lives icons at y=654, just above ground line. With lifeH=40 (8 rows × 5), they extend from 654 to 694. The ground line is at 660, and the canvas bottom is at 720. So lives icons span 654 to 694, overlapping the ground line at 660. That's OK visually — lives icons sit on the ground.

Hmm wait, lives icons at y=654 to y=694, but H=720. So there's 26 pixels of space below. That's fine.

Actually the player draws from y=600 (player.y) to y=640 (player.y + player.h). The ground line is at y=660. So there's a 20 pixel gap between the player and the ground line. That looks weird — player should be ON the ground.

Let me move the player to sit on the ground. Player bottom at y=660. So player.y = 660 - player.h = 660 - 40 = 620. Hmm but I set player.y = 600.

Actually, the player's draw position is: `player.y + player.h - patH(pat, scale)`. With player.y=600, player.h=40, patH=40 (8 rows × 5 scale), so draw y = 600 + 40 - 40 = 600. So the player is drawn at y=600 to y=640. That's 20 pixels above the ground line at 660.

Let me adjust PLAYER_Y to 620 so player draws at 620 to 660, sitting on the ground line.

Or maybe keep player.y=600 since the visual height (40) is just the bounding box and the player sprite is drawn at the bottom of that box. So player visually at y=600-640, with ground at 660, leaves 20px gap.

Let me fix this. PLAYER_Y = 620, so player.y=620, player draws at y=620-660.

Wait, my code says `player.y + player.h - patH(pat, scale)`. So the sprite's top is at `player.y + player.h - patH`. The sprite bottom is at `player.y + player.h`. So the sprite's bottom is at `player.y + player.h = player.y + 40`. With player.y=600, sprite bottom at y=640. Ground at y=660.

To have sprite on ground (bottom at y=660), I need `player.y + 40 = 660`, so `player.y = 620`.

Let me fix PLAYER_Y to 620. Also need to check the alien reaches player check: `a.y + a.h >= PLAYER_Y - 2`. With PLAYER_Y=620, that becomes `a.y + a.h >= 618`. So when alien bottom is at 618 or below, game over. That's when alien is at the player level. Good.

Actually I realize my alien.y is the top of the bounding box, and alien.h = 34 (cellH-6 = 40-6 = 34). So alien bottom is alien.y + 34. When alien.y = 618, bottom = 652. Game over triggers at a.y + a.h >= 618, so when bottom is 618+. Hmm that's quite high up.

Let me make it more strict: game over when alien bottom reaches the player top. Player top is at player.y = 620. So game over when a.y + a.h >= 620.

Let me change `PLAYER_Y - 2` to `PLAYER_Y`. That works.

Actually I want game over to trigger when alien is touching the player's level, not just when it crosses a threshold. So when a.y + a.h >= player.y (620), game over.

Let me also reconsider the player.y. Let me set PLAYER_Y = 610 so the player draws from y=610 to y=650 (with sprite height 40). Then ground at 660. There's a 10px gap which is OK visually.

And alien game over check: `a.y + a.h >= PLAYER_Y` = 610. When alien bottom reaches 610, game over. That seems reasonable.

Hmm but the bunkers are at y=490-554. Aliens start at y=90 and move down 18px per shift. They need to reach y=610 (or 618) to trigger game over. That's 520 pixels down. With 5 rows × 18 = 90px per "level" descent, plus the lateral movement, that's a lot of movement. Should take a while.

But the bunkers at y=490-554 are in the way. When aliens touch bunkers, bunkers are destroyed. So aliens can keep descending.

OK let me just adjust PLAYER_Y to 620 and ground to 660, with alien game over check at PLAYER_Y.

Actually let me reconsider the layout entirely:

Canvas: 800 × 720
- HUD: 0-30 (text labels at y=6 and y=24)
- Top boundary line: y=30
- Aliens start: y=90
- 5 rows × 40 = 200 tall
- Aliens span: y=90 to y=290
- Bunkers: y=480, 14 rows × 4 = 56 tall, span y=480 to y=536
- Player: y=620, sprite height 40, draws from y=620 to y=660
- Ground line: y=660
- Lives area: y=680+

Let me check the alien reach game over condition. PLAYER_Y=620. `a.y + a.h >= 620`. 

With aliens at y=290 max initially, they need to descend 330px. Each descent step is 18px, so 18-19 steps down. But aliens also march sideways, with shifts every stepFrames. With stepFrames=55 (55 alive), they take a while.

OK let me adjust the code:

PLAYER_Y = 620
a.y + a.h >= PLAYER_Y - 2

Or PLAYER_Y = 610, a.y + a.h >= PLAYER_Y

Actually let me think about the ground line. If the ground line is at y=660, the player should sit on it (visual bottom at y=660). So player.y + player.h = 660, player.y = 620.

But for game over, I want aliens to reach the player level. The player's sprite is at y=620 to y=660. When alien bottom (a.y + a.h) reaches y=620 (top of player), that's game over.

OK final values:
- PLAYER_Y = 620
- game over: a.y + a.h >= 620 (i.e., >= PLAYER_Y)

Let me update.

Actually, looking at my current code, PLAYER_Y is 600 and the check is `a.y + a.h >= PLAYER_Y - 2 = 598`. So game over triggers when alien bottom reaches 598.

With current PLAYER_Y=600, player.y = 600. The player draws at y=600-640. So the check is "alien reaches y=598" which is just barely above the player. That's correct — game over happens just as alien touches player.

But the player is drawn at y=600-640, with ground at 660. There's a 20px gap between player bottom and ground. That looks weird visually.

Let me just adjust PLAYER_Y to 620 so player sits on the ground line.

Let me also reconsider where the player draws. The sprite is drawn from `player.y + player.h - patH` to `player.y + player.h`. So sprite bottom is at `player.y + player.h`.

With player.y=600, sprite bottom at 640. Ground at 660. Gap of 20px.

Let me change PLAYER_Y=620, player draws at 620-660, on the ground.

Let me update.

Also for game over: `a.y + a.h >= PLAYER_Y - 2` = 618. When alien bottom reaches 618 (which is just above player top of 620), game over.

Actually let me make it `>= PLAYER_Y` so it triggers exactly when alien reaches player top. Or `>= PLAYER_Y - 4` for slightly earlier.

OK final values:
- PLAYER_Y = 620
- GROUND_Y = 660 (unchanged)

Let me update.Let

me adjust the player position so it sits properly on the ground line:

edit {"edits":[{"newText":"const GROUND_Y = 660;\nconst PLAYER_Y = 620;\nconst BUNKER_Y = 480;","oldText":"const GROUND_Y = 660;\nconst PLAYER_Y
toolResult

toolResult

Successfully replaced 1 block(s) in /workspace/index.html.

assistant
thinking
Now let me also verify the game over check and adjust it if needed. Currently it's `a.y + a.h >= PLAYER_Y - 2`. With PLAYER_Y=620, that's 618. The player top is at y=620, so when alien bottom is 618 (just 2px above player top), game over. That's slightly early — let me make it `>= PLAYER_Y` so it triggers when alien reaches player level.

Actually `PLAYER_Y - 2` is fine, it triggers just as alien touches player. Let me leave it.

Now let me also verify a few other things by checking the code. Let me look at the file:

Let me do a final review of the file by examining the key sections:

bash {"command":"grep -n \"function\\|const \\|let \" /workspace/index.html | head -60"}
toolResult

toolResult

78:const canvas = document.getElementById("game"); 79:const ctx = canvas.getContext("2d"); 80:const W = canvas.width; 81:const H = canvas.height; 88:let audioReady = false; 89:let audioCtx = null; 90:function ensureAudio() { 93: const AC = window.AudioContext || window.webkitAudioContext; 102:function tone(freq, dur, type = "square", vol = 0.04, sweep = 0) { 105: const t0 = audioCtx.currentTime; 106: const osc = audioCtx.createOscillator(); 107: const gain = audioCtx.createGain(); 118:function noise(dur = 0.2, vol = 0.06) { 121: const t0 = audioCtx.currentTime; 122: const bufSize = Math.floor(audioCtx.sampleRate * dur); 123: const buf = audioCtx.createBuffer(1, bufSize, audioCtx.sampleRate); 124: const data = buf.getChannelData(0); 125: for (let i = 0; i < bufSize; i++) data[i] = (Math.random() * 2 - 1) * (1 - i / bufSize); 126: const src = audioCtx.createBufferSource(); 128: const gain = audioCtx.createGain(); 134:let marchAlt = false; 135:function march() { 144:const ALIEN_PATTERNS = [ 215:const ALIEN_POINTS = [30, 20, 10]; 216:const ALIEN_COLORS = ["#22f7ff", "#22f7ff", "#ff5ce0", "#ff5ce0", "#ffe23a"]; 218:const PLAYER_PATTERN = [ 225:const PLAYER_COLOR = "#42ff7a"; 227:const UFO_PATTERN = [ 233:const UFO_BONUSES = [50, 100, 150, 200, 300]; 236:const BUNKER_PATTERN = [ 256:function drawPattern(pat, x, y, scale, color) { 258: for (let r = 0; r < pat.length; r++) { 259: const row = pat[r]; 260: for (let c = 0; c < row.length; c++) { 267:function patW(pat, scale) { return pat[0].length * scale; } 268:function patH(pat, scale) { return pat.length * scale; } 273:let player, aliens, playerBullets, alienBullets, bunkers, particles, ufo; 274:let score, hiScore, lives, level, state, frame, invuln, ufoTimer, waveMsg; 275:let stars = []; 277:const PLAYER_SPEED = 6; 278:const PLAYER_BULLET_SPEED = 10; 279:const ALIEN_BULLET_SPEED = 5; 280:const PLAYER_FIRE_COOLDOWN = 12; 281:const GROUND_Y = 660; 282:const PLAYER_Y = 620; 283:const BUNKER_Y = 480; 285:function rand(min, max) { return Math.random() * (max - min) + min; } 286:function randi(min, max) { return Math.floor(rand(min, max + 1)); } 288:function initStars() { 290: for (let i = 0; i < 90; i++) { 301:function newGame() { 320:function buildAliens() { 322: const rows = 5; 323: const cols = 11; 324: const typesByRow = [0, 0, 1, 1, 2]; 325: const cellW = 48; 326: const cellH = 40; 327: const startX = (W - cols * cellW) / 2 + cellW / 2; 328: const startY = 90; 329: for (let r = 0; r < rows; r++) {

assistant
thinking
Looks good. Let me also do a final check by looking at the bunkers and aliens rendering. Let me view a portion of the render code
read {"limit":120,"offset":680,"path":"/workspace/index.html"}
toolResult

toolResult

max: 40, color, size: randi(2, 4), gravity: 0.05, }); } noise(0.15, 0.05); } function scorePopup(x, y, value, color) { particles.push({ x, y, vx: 0, vy: -0.9, gravity: 0, life: 70, max: 70, color, size: 0, text: "+" + value, }); } function playerHit() { lives--; invuln = 150; spawnExplosion(player.x + player.w / 2, player.y + player.h / 2, PLAYER_COLOR); player.x = W / 2 - player.w / 2; playerBullets = []; alienBullets = []; noise(0.3, 0.09); if (lives <= 0) gameOver(); } function gameOver() { state = "gameover"; if (score > hiScore) { hiScore = score; localStorage.setItem("si_hi", hiScore); } noise(0.5, 0.08); tone(220, 0.3, "sawtooth", 0.04, -100); setTimeout(() => tone(110, 0.5, "sawtooth", 0.04, -60), 200); } // ===================================================================== // Render // ===================================================================== function render() { // Clear with subtle scanline tint ctx.fillStyle = "#000"; ctx.fillRect(0, 0, W, H); // Stars for (const s of stars) { const b = 0.3 + 0.7 * Math.abs(Math.sin(s.p)); ctx.fillStyle = "rgba(200,220,255," + (b * 0.8) + ")"; ctx.fillRect(s.x, s.y, s.s, s.s); } // HUD background line ctx.fillStyle = "#0a140a"; ctx.fillRect(0, 28, W, 2); ctx.fillStyle = "#0a140a"; ctx.fillRect(0, GROUND_Y - 4, W, 2); // HUD ctx.fillStyle = "#6f6"; ctx.font = 'bold 18px "Courier New", monospace'; ctx.textAlign = "left"; ctx.textBaseline = "top"; ctx.fillText("SCORE", 30, 6); ctx.fillStyle = "#fff"; ctx.fillText(score.toString().padStart(5, "0"), 30, 24); ctx.textAlign = "center"; ctx.fillStyle = "#6f6"; ctx.fillText("HI-SCORE", W / 2, 6); ctx.fillStyle = "#fff"; ctx.fillText(hiScore.toString().padStart(5, "0"), W / 2, 24); ctx.textAlign = "right"; ctx.fillStyle = "#6f6"; ctx.fillText("WAVE", W - 30, 6); ctx.fillStyle = "#fff"; ctx.fillText(level.toString().padStart(2, "0"), W - 30, 24); // Bunkers for (const b of bunkers) drawBunker(b); // Aliens for (const a of aliens) { if (!a.alive) continue; const pat = ALIEN_PATTERNS[a.type][aliens.frame]; const scale = 3; const px = a.x + a.w / 2 - patW(pat, scale) / 2; const py = a.y + a.h / 2 - patH(pat, scale) / 2; // Subtle glow ctx.fillStyle = ALIEN_COLORS[a.type] + "22"; ctx.fillRect(a.x - 2, a.y - 2, a.w + 4, a.h + 4); drawPattern(pat, px, py, scale, ALIEN_COLORS[a.type]); } // UFO if (ufo) { const pat = UFO_PATTERN; const scale = 3; const px = ufo.x + ufo.w / 2 - patW(pat, scale) / 2; const py = ufo.y + ufo.h / 2 - patH(pat, scale) / 2; if (Math.floor(frame / 4) % 2 === 0) { drawPattern(pat, px, py, scale, "#ff3a3a"); } else { ctx.fillStyle = "#ff3a3a"; ctx.fillRect(px, py + patH(pat, scale) / 2 - 2, patW(pat, scale), 4); } } // Player if (state !== "gameover" && (invuln <= 0 || Math.floor(frame / 6) % 2 === 0)) { const pat = PLAYER_PATTERN; const scale = 5; const px = player.x + player.w / 2 - patW(pat, scale) / 2; const py = player.y + player.h - patH(pat, scale); drawPattern(pat, px, py, scale, PLAYER_COLOR); } // Bullets [136 more lines in file. Use offset=800 to continue.]

assistant
thinking
Looks good. Let me also verify the bunker draw function is at the right place:
bash {"command":"grep -n \"drawBunker\\|function drawBunker\" /workspace/index.html"}
toolResult

toolResult

761: for (const b of bunkers) drawBunker(b); 900:function drawBunker(b) {

TAKE IT TO CODE

This run produced no artifact — reuse the prompt with a different model or your own pi-agent run.

RUN

modelminimax-m3
vendorMiniMax
harnesspi-agent
generated2026-07-22

METRICS

runtime
tokens in35k
tokens out31k
cached201k
cost / run$0.0592