• 01 - Critter Models 02
  • 02 - VantaJS Shaders
  • 03 - App Bundle 02
  • 04 - Cathedrals
  • 05 - App Bundle 01
  • 06 - Retroscopic
  • 07 - Games
  • 08 - Voronoi
  • 09 - Terrain
  • 10 - Critter Models 01
  • 11 - Chromatophores
  • 12 - Conformal Maps
  • 13 - Echo Logix
  • 14 - Music
  • 15 - Colour Theory
MIKE'S NOTEBOOK
Notes
Number:
Project:
07Games
└ Rockfall└ Speak & Math└ Space Invaders└ Apache└ Asteroids└ Molly Roger└ Carrom└ Chess└ Connect 4└ Anagrams
08Voronoi
└ 001└ 002└ 003└ 004└ 005
09Terrain
└ Simple Polygon└ Simple Flyover└ Infinite Flyover 01└ Infinite Flyover 02└ Hexagonal Plane└ Mountain Ridges
10Critter Models 01
└ Wireframe Spider└ Wireframe Fly└ Wireframe Scorpion└ Wireframe Mosquito└ Wireframe Bee└ Wireframe Butterfly└ Wireframe Locust
11Chromatophores
└ 01└ 02└ 03└ 04
12Conformal Maps
└ 01└ 02└ 03└ 04└ 05
02 / 03

SPACE INVADERS - INTERACTIVE ARCADE GAME SOURCE CODE

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover,user-scalable=no">
<meta name="theme-color" content="#04010a">
<title>Space Invaders</title>
<style>
  *{margin:0;padding:0;box-sizing:border-box}
  html,body{height:100%;width:100%;overflow:hidden;background:#04010a; transition: background 0.3s;}
  body.light-mode { background:#e1e6ed; }
  body{
    touch-action:none;overscroll-behavior:none;-webkit-user-select:none;user-select:none;
    -webkit-tap-highlight-color:transparent;
    font-family:ui-monospace,"SF Mono",Menlo,Consolas,"Courier New",monospace;
  }
  #game{position:fixed;inset:0;width:100%;height:100%;display:block;image-rendering:pixelated}

  /* CRT layers — GPU-composited, so they cost nothing per frame */
  #scan{
    position:fixed;inset:0;z-index:5;pointer-events:none;opacity:.5;
    background:repeating-linear-gradient(0deg,rgba(0,0,0,.42) 0 1px,transparent 1px 3px);
  }
  body.light-mode #scan {
    background:repeating-linear-gradient(0deg,rgba(255,255,255,.3) 0 1px,transparent 1px 3px);
  }
  #vig{
    position:fixed;inset:0;z-index:6;pointer-events:none;
    background:radial-gradient(ellipse 75% 75% at 50% 50%,transparent 40%,rgba(2,0,8,.82) 100%);
  }
  body.light-mode #vig {
    background:radial-gradient(ellipse 75% 75% at 50% 50%,transparent 40%,rgba(255,255,255,.5) 100%);
  }
  .fx-off{display:none!important}

  #chrome{
    position:fixed;z-index:8;display:flex;gap:6px;
    top:max(185px,env(safe-area-inset-top));right:max(10px,env(safe-area-inset-right));
  }
  body.in-fullscreen #chrome {
    top:max(10px,env(safe-area-inset-top));
  }

  .btn{
    width:34px;height:34px;border-radius:9px;cursor:pointer;
    background:rgba(10,4,26,.55);border:1px solid rgba(120,240,255,.28);
    color:#7fe9ff;font:600 13px/1 ui-monospace,monospace;
    display:flex;align-items:center;justify-content:center;
    backdrop-filter:blur(6px);transition:border-color .15s,color .15s,transform .08s;
  }
  body.light-mode .btn {
    background:rgba(255,255,255,.55);border:1px solid rgba(0,85,255,.28);
    color:#0055ff;
  }
  .btn:hover{border-color:rgba(120,240,255,.7);color:#d8fbff}
  body.light-mode .btn:hover{border-color:rgba(0,85,255,.7);color:#0033cc}
  .btn:active{transform:scale(.93)}
  .btn.dim{color:#4a5f6b;border-color:rgba(120,240,255,.12)}
  body.light-mode .btn.dim{color:#8899a6;border-color:rgba(0,85,255,.12)}
  @media (max-width:600px){.btn{width:38px;height:38px}}
</style>
</head>
<body>
<canvas id="game"></canvas>
<div id="scan"></div>
<div id="vig"></div>
<div id="chrome">
  <div class="btn" id="bSound" title="Sound (M)">♪</div>
  <div class="btn" id="bFx" title="CRT filter (G)">▦</div>
  <div class="btn" id="bFull" title="Fullscreen (F)">⛶</div>
</div>

<script>
"use strict";
/* ==========================================================================
   SIGNAL LOST — an invaders game
   Virtual world is 1000 units tall; width adapts to the viewport aspect ratio
   so the playfield genuinely fills the screen instead of letterboxing.
   ========================================================================== */

const cv  = document.getElementById('game');
cv.tabIndex = 0;
cv.style.outline = 'none';
const ctx = cv.getContext('2d', { alpha:false });

const clamp = (v,a,b) => v<a?a:v>b?b:v;
const lerp  = (a,b,t) => a+(b-a)*t;
const rand  = (a,b) => a+Math.random()*(b-a);
const randi = (a,b) => Math.floor(rand(a,b));
const pick  = a => a[randi(0,a.length)];

/* ---------- palette -------------------------------------------------- */
const C_DARK = {
  bg0:'#0a0320', bg1:'#02010a',
  ink:'#8ef6ff', hot:'#ff2e88', acid:'#a8ff36', amber:'#ffb020',
  violet:'#9d5cff', white:'#ffffff', dim:'#4a5f6b'
};
const C_LIGHT = {
  bg0:'#f4f7fa', bg1:'#e6ebf0',
  ink:'#0066cc', hot:'#cc0044', acid:'#228800', amber:'#cc6600',
  violet:'#6a0dad', white:'#000000', dim:'#8b9bb4'
};
let C = C_DARK;
let SURGE = ['#ff2e88','#a8ff36','#8ef6ff','#ffb020','#9d5cff'];

function isDarkMode() {
  try { return window.parent.document.documentElement.classList.contains('dark'); }
  catch(e) { return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; }
}
let lastDark = null;
function applyTheme() {
  const dark = isDarkMode();
  if (dark === lastDark) return;
  lastDark = dark;
  C = dark ? C_DARK : C_LIGHT;
  SURGE = dark 
    ? ['#ff2e88','#a8ff36','#8ef6ff','#ffb020','#9d5cff']
    : ['#cc0044','#228800','#0066cc','#cc6600','#6a0dad'];
  document.body.classList.toggle('light-mode', !dark);
  bunkers.forEach(b => b.dirty = true);
}
setInterval(applyTheme, 300);

/* ---------- pixel sprites -------------------------------------------- */
let _sid = 0;
function sprite(rows){
  const h = rows.length, w = rows[0].length;
  const c = document.createElement('canvas'); c.width = w; c.height = h;
  const g = c.getContext('2d'); g.fillStyle = '#fff';
  for (let y=0;y<h;y++) for (let x=0;x<w;x++) if (rows[y][x] !== '.') g.fillRect(x,y,1,1);
  c._id = ++_sid;
  return c;
}
const tintCache = new Map();
function tint(s, color){
  if (!s._id) s._id = ++_sid;      // never let an untagged canvas collide on "undefined"
  const k = s._id + '|' + color;
  let c = tintCache.get(k);
  if (!c){
    c = document.createElement('canvas'); c.width = s.width; c.height = s.height;
    const g = c.getContext('2d');
    g.drawImage(s,0,0);
    g.globalCompositeOperation = 'source-in';
    g.fillStyle = color; g.fillRect(0,0,c.width,c.height);
    tintCache.set(k,c);
  }
  return c;
}
/* draw sprite with a cheap additive bloom instead of shadowBlur */
function blit(s, color, x, y, w, h, glow){
  const t = tint(s,color);
  if (glow){
    ctx.save();
    ctx.globalCompositeOperation = 'lighter';
    ctx.globalAlpha = glow;
    ctx.drawImage(t, x-w*0.14, y-h*0.14, w*1.28, h*1.28);
    ctx.restore();
  }
  ctx.drawImage(t, x, y, w, h);
}

const SP = {
  squid: [sprite([
    "...XX...","..XXXX..",".XXXXXX.","XX.XX.XX",
    "XXXXXXXX","..X..X..",".X.XX.X.","X.X..X.X"]),
    sprite([
    "...XX...","..XXXX..",".XXXXXX.","XX.XX.XX",
    "XXXXXXXX",".X.XX.X.","X......X",".X....X."])],
  crab: [sprite([
    "..X.....X..","...X...X...","..XXXXXXX..",".XX.XXX.XX.",
    "XXXXXXXXXXX","X.XXXXXXX.X","X.X.....X.X","...XX.XX..."]),
    sprite([
    "..X.....X..","X..X...X..X","X.XXXXXXX.X","XXX.XXX.XXX",
    "XXXXXXXXXXX",".XXXXXXXXX.","..X.....X..",".X.......X."])],
  octo: [sprite([
    "....XXXX....",".XXXXXXXXXX.","XXXXXXXXXXXX","XXX..XX..XXX",
    "XXXXXXXXXXXX","...XX..XX...","..XX.XX.XX..","XX........XX"]),
    sprite([
    "....XXXX....",".XXXXXXXXXX.","XXXXXXXXXXXX","XXX..XX..XXX",
    "XXXXXXXXXXXX","..XXX..XXX..",".XX..XX..XX.","..XX....XX.."])],
  ship: sprite([
    ".......X.......","......XXX......","......XXX......",".XXXXXXXXXXXXX.",
    "XXXXXXXXXXXXXXX","XXXXXXXXXXXXXXX","XXX.XXXXXXX.XXX","XX...XXXXX...XX"]),
  ufo: sprite([
    ".....XXXXXX.....","...XXXXXXXXXX...","..XXXXXXXXXXXX..",".XX.XX.XX.XX.XX.",
    "XXXXXXXXXXXXXXXX","..XXX..XX..XXX..","...X........X..."]),
  bomb: [
    sprite([".X.","XXX",".X.",".X.","XXX",".X."]),
    sprite([".X.","X..",".X.","..X",".X.","X.."]),
    sprite(["X.X",".X.","X.X",".X.","X.X",".X."])],
  bombAlt: [
    sprite(["XXX",".X.",".X.","XXX",".X.",".X."]),
    sprite([".X.","..X",".X.","X..",".X.","..X"]),
    sprite([".X.","X.X",".X.","X.X",".X.","X.X"])],
  bunker: sprite([
    "....XXXXXXXXXXXXXX....","..XXXXXXXXXXXXXXXXXX..",".XXXXXXXXXXXXXXXXXXXX.",
    "XXXXXXXXXXXXXXXXXXXXXX","XXXXXXXXXXXXXXXXXXXXXX","XXXXXXXXXXXXXXXXXXXXXX",
    "XXXXXXXXXXXXXXXXXXXXXX","XXXXXXXXXXXXXXXXXXXXXX","XXXXXXXXXXXXXXXXXXXXXX",
    "XXXXXXXXXXXXXXXXXXXXXX","XXXXXXXXX....XXXXXXXXX","XXXXXXX........XXXXXXX",
    "XXXXXX..........XXXXXX","XXXXX............XXXXX","XXXXX............XXXXX",
    "XXXXX............XXXXX"])
};
const BUNKER_MASK = [
  "....XXXXXXXXXXXXXX....","..XXXXXXXXXXXXXXXXXX..",".XXXXXXXXXXXXXXXXXXXX.",
  "XXXXXXXXXXXXXXXXXXXXXX","XXXXXXXXXXXXXXXXXXXXXX","XXXXXXXXXXXXXXXXXXXXXX",
  "XXXXXXXXXXXXXXXXXXXXXX","XXXXXXXXXXXXXXXXXXXXXX","XXXXXXXXXXXXXXXXXXXXXX",
  "XXXXXXXXXXXXXXXXXXXXXX","XXXXXXXXX....XXXXXXXXX","XXXXXXX........XXXXXXX",
  "XXXXXX..........XXXXXX","XXXXX............XXXXX","XXXXX............XXXXX",
  "XXXXX............XXXXX"];

/* ---------- audio (procedural, no assets) ---------------------------- */
const Snd = {
  ac:null, master:null, on:true, noise:null, stepIx:0, ufoNode:null, lastStep:0,
  boot(){
    if (this.ac) return;
    const AC = window.AudioContext || window.webkitAudioContext;
    if (!AC) { this.on = false; return; }
    this.ac = new AC();
    this.master = this.ac.createGain();
    this.master.gain.value = 0.35;
    this.master.connect(this.ac.destination);
    const len = this.ac.sampleRate * 1.2;
    const buf = this.ac.createBuffer(1, len, this.ac.sampleRate);
    const d = buf.getChannelData(0);
    for (let i=0;i<len;i++) d[i] = Math.random()*2-1;
    this.noise = buf;
  },
  resume(){ if (this.ac && this.ac.state === 'suspended') this.ac.resume(); },
  tone(f0,f1,dur,type,vol,delay=0){
    if (!this.on || !this.ac) return;
    const t = this.ac.currentTime + delay;
    const o = this.ac.createOscillator(), g = this.ac.createGain();
    o.type = type; o.frequency.setValueAtTime(f0,t);
    o.frequency.exponentialRampToValueAtTime(Math.max(1,f1), t+dur);
    g.gain.setValueAtTime(0,t);
    g.gain.linearRampToValueAtTime(vol, t+0.008);
    g.gain.exponentialRampToValueAtTime(0.0001, t+dur);
    o.connect(g); g.connect(this.master); o.start(t); o.stop(t+dur+0.02);
  },
  burst(dur,f0,f1,vol){
    if (!this.on || !this.ac) return;
    const t = this.ac.currentTime;
    const s = this.ac.createBufferSource(); s.buffer = this.noise;
    const f = this.ac.createBiquadFilter(); f.type = 'lowpass';
    f.frequency.setValueAtTime(f0,t);
    f.frequency.exponentialRampToValueAtTime(f1, t+dur);
    const g = this.ac.createGain();
    g.gain.setValueAtTime(vol,t);
    g.gain.exponentialRampToValueAtTime(0.0001, t+dur);
    s.connect(f); f.connect(g); g.connect(this.master);
    s.start(t); s.stop(t+dur);
  },
  shoot(){ this.tone(900,180,0.10,'square',0.07); },
  step(){
    if (!this.ac) return;
    if (this.ac.currentTime - this.lastStep < 0.075) return;   // stays a pulse, never a buzz
    this.lastStep = this.ac.currentTime;
    const notes = [110,98,87,78];
    this.tone(notes[this.stepIx%4], notes[this.stepIx%4]*0.94, 0.09,'triangle',0.13);
    this.stepIx++;
  },
  kill(){ this.burst(0.22,2600,220,0.24); this.tone(420,120,0.14,'square',0.05); },
  hurt(){ this.burst(0.5,900,80,0.34); this.tone(200,40,0.5,'sawtooth',0.16); },
  drop(){ this.tone(70,52,0.14,'square',0.10); },
  power(){ [523,659,880].forEach((f,i)=>this.tone(f,f,0.10,'square',0.09,i*0.055)); },
  wave(){ [330,440,554,660,880].forEach((f,i)=>this.tone(f,f,0.13,'triangle',0.10,i*0.075)); },
  over(){ [440,392,330,262,196].forEach((f,i)=>this.tone(f,f*0.7,0.32,'sawtooth',0.10,i*0.16)); },
  ufoStart(){
    if (!this.on || !this.ac || this.ufoNode) return;
    const t = this.ac.currentTime;
    const o = this.ac.createOscillator(), g = this.ac.createGain();
    const lfo = this.ac.createOscillator(), la = this.ac.createGain();
    o.type='sawtooth'; o.frequency.value=520;
    lfo.type='sine'; lfo.frequency.value=11; la.gain.value=140;
    lfo.connect(la); la.connect(o.frequency);
    g.gain.value=0.055;
    o.connect(g); g.connect(this.master);
    o.start(t); lfo.start(t);
    this.ufoNode = {o,lfo,g};
  },
  ufoStop(){
    if (!this.ufoNode) return;
    const {o,lfo,g} = this.ufoNode;
    const t = this.ac.currentTime;
    g.gain.setValueAtTime(g.gain.value,t);
    g.gain.exponentialRampToValueAtTime(0.0001,t+0.15);
    o.stop(t+0.2); lfo.stop(t+0.2);
    this.ufoNode = null;
  }
};

/* ---------- viewport / world ----------------------------------------- */
const V = { w:1600, h:1000 };
const view = { scale:1, ox:0, oy:0, dpr:1 };
let shake = 0, glitch = 0, fxOn = true;

function layout(){
  const cw = Math.max(1, window.innerWidth), ch = Math.max(1, window.innerHeight);
  view.dpr = Math.min(window.devicePixelRatio || 1, 2);
  cv.width  = Math.floor(cw * view.dpr);
  cv.height = Math.floor(ch * view.dpr);
  cv.style.width = cw + 'px';
  cv.style.height = ch + 'px';

  V.h = 1000;
  V.w = clamp(1000 * (cw/ch), 660, 2400);
  view.scale = Math.min(cv.width / V.w, cv.height / V.h);
  view.ox = (cv.width  - V.w * view.scale) / 2;
  view.oy = (cv.height - V.h * view.scale) / 2;

  ctx.imageSmoothingEnabled = false;
  reflow();
}
window.addEventListener('resize', layout);
window.addEventListener('orientationchange', () => setTimeout(layout, 120));

/* ---------- game state ------------------------------------------------ */
const G = {
  state:'menu',            // menu | play | wave | dead | over
  t:0, timer:0, score:0, best:0, level:1, lives:3,
  surge:0, msg:'', anim:0
};

const player = { x:0, y:0, w:64, h:34, vx:0, inv:0, cool:0, weapon:0, wt:0 };
const shots = [], bombs = [], parts = [], drops = [];
let enemies = [], form = { x:0, y:140, dir:1, step:0, frame:0 };
let gapX = 90, gapY = 62, alienW = 54;
let bunkers = [];
let ufo = null, ufoTimer = 12;
const stars = [];
for (let i=0;i<140;i++) stars.push({ x:Math.random(), y:Math.random(), z:rand(0.25,1), s:rand(0.6,2.1) });

const TYPES = [
  { sp:SP.squid, pts:30, col:C.hot   },
  { sp:SP.crab,  pts:20, col:C.ink   },
  { sp:SP.octo,  pts:10, col:C.acid  }
];
const rowType = r => r === 0 ? 0 : r <= 2 ? 1 : 2;

/* reposition everything that depends on world size */
function reflow(){
  gapX   = Math.min(102, (V.w * 0.80) / 11);
  gapY   = 62;
  alienW = gapX * 0.60;
  player.w = clamp(V.w * 0.042, 52, 76);
  player.h = player.w * (8/15);
  player.y = V.h - 96;
  player.x = clamp(player.x || (V.w/2 - player.w/2), 0, V.w - player.w);

  const totalW = 10*gapX + alienW;
  form.x = clamp(form.x || (V.w - totalW)/2, 40, V.w - totalW - 40);

  bunkers.forEach(b => { b.x = V.w * b.frac - b.w/2; b.y = V.h - 250; });
}

function alienSize(t){
  const s = TYPES[t].sp[0];
  const w = alienW;
  return { w, h: w * (s.height / s.width) };
}
function alienPos(e){
  const sz = alienSize(e.type);
  return { x: form.x + e.col*gapX + (alienW - sz.w)/2, y: form.y + e.row*gapY, w: sz.w, h: sz.h };
}

function buildWave(){
  enemies = [];
  for (let r=0;r<5;r++)
    for (let c=0;c<11;c++)
      enemies.push({ col:c, row:r, type:rowType(r), alive:true });
  form.dir = 1;
  form.y = 130 + Math.min(150, (G.level-1) * 20);
  const totalW = 10*gapX + alienW;
  form.x = (V.w - totalW)/2;
  form.step = 0;
}

function buildBunkers(){
  bunkers = [];
  const cols = BUNKER_MASK[0].length, rows = BUNKER_MASK.length;
  const cell = 4.6, w = cols*cell, h = rows*cell;
  [0.2,0.4,0.6,0.8].forEach(f => {
    const cells = new Uint8Array(cols*rows);
    for (let y=0;y<rows;y++) for (let x=0;x<cols;x++)
      cells[y*cols+x] = BUNKER_MASK[y][x] === 'X' ? 1 : 0;
    const cn = document.createElement('canvas'); cn.width = cols; cn.height = rows;
    const b = { frac:f, x:V.w*f - w/2, y:V.h-250, w, h, cols, rows, cell, cells, cn,
                dirty:true, col:null };
    bunkers.push(b);
  });
}
/* Repainted only when the barrier is damaged or its colour changes, so this
   costs nothing on a normal frame. Deliberately NOT routed through tint():
   that cache is for immutable sprites, and a barrier changes every hit. */
function paintBunker(b, col){
  const g = b.cn.getContext('2d');
  g.clearRect(0,0,b.cols,b.rows);
  g.fillStyle = col;
  for (let y=0;y<b.rows;y++) for (let x=0;x<b.cols;x++)
    if (b.cells[y*b.cols+x]) g.fillRect(x,y,1,1);
  b.dirty = false; b.col = col;
}
/* Blast shapes, in barrier cells. The grid is 22x16 — the same resolution as
   the arcade original — so a player shot nibbles a small notch and takes many
   hits to tunnel through, while an alien bomb splashes a bit wider. */
const BLOB_SHOT = [
          [0,-1],
  [-1, 0],[0, 0],[1, 0],
  [-1, 1],[0, 1],[1, 1]
];
const BLOB_BOMB = [
          [-1,-1],[0,-1],[1,-1],
  [-2, 0],[-1, 0],[0, 0],[1, 0],[2, 0],
  [-2, 1],[-1, 1],[0, 1],[1, 1],[2, 1],
          [-1, 2],[0, 2],[1, 2]
];

function carve(b, cx, cy, blob){
  for (const o of blob){
    const x = cx+o[0], y = cy+o[1];
    if (x<0||y<0||x>=b.cols||y>=b.rows) continue;
    b.cells[y*b.cols+x] = 0;
  }
  /* a few random nibbles so edges crumble instead of staying geometric */
  for (let i=0;i<5;i++){
    const x = cx+randi(-2,3), y = cy+randi(-2,3);
    if (x<0||y<0||x>=b.cols||y>=b.rows) continue;
    if (Math.random() < 0.4) b.cells[y*b.cols+x] = 0;
  }
  b.dirty = true;
}

/* Swept test: walks the segment the projectile covered this frame in half-cell
   steps, so fast shots can skips over a barrier. Returns the impact point, or
   null if the path was clear — shots pass straight through carved-out holes. */
function bunkerHit(x0,y0,x1,y1,blob){
  const dx = x1-x0, dy = y1-y0;
  const steps = Math.max(1, Math.ceil(Math.hypot(dx,dy) / 2.2));
  for (let i=0;i<=steps;i++){
    const t = i/steps, px = x0+dx*t, py = y0+dy*t;
    for (const b of bunkers){
      if (px < b.x || px > b.x+b.w || py < b.y || py > b.y+b.h) continue;
      const cx = Math.floor((px-b.x)/b.cell), cy = Math.floor((py-b.y)/b.cell);
      if (cx<0||cy<0||cx>=b.cols||cy>=b.rows) continue;
      if (!b.cells[cy*b.cols+cx]) continue;
      carve(b, cx, cy, blob);
      return { x:px, y:py };
    }
  }
  return null;
}
function razeBunkersAt(y){
  for (const b of bunkers){
    if (y < b.y) continue;
    let any = false;
    const upto = Math.min(b.rows, Math.ceil((y - b.y)/b.cell));
    for (let r=0;r<upto;r++) for (let x=0;x<b.cols;x++)
      if (b.cells[r*b.cols+x]) { b.cells[r*b.cols+x]=0; any=true; }
    if (any) b.dirty = true;
  }
}

function burst(x,y,n,color,power=1){
  for (let i=0;i<n;i++){
    const a = Math.random()*Math.PI*2, s = rand(40,300)*power;
    parts.push({ x, y, vx:Math.cos(a)*s, vy:Math.sin(a)*s,
      life:rand(0.28,0.75), max:0.75, col:color || pick(SURGE), sz:rand(1.6,4.2) });
  }
}

function reset(){
  G.score = 0; G.level = 1; G.lives = 3; G.surge = 0;
  player.x = V.w/2 - player.w/2; player.inv = 2; player.weapon = 0; player.wt = 0;
  shots.length = 0; bombs.length = 0; drops.length = 0; parts.length = 0;
  ufo = null; ufoTimer = 14; Snd.ufoStop();
  buildBunkers(); buildWave();
  G.state = 'play';
}

/* ---------- input ----------------------------------------------------- */
const keys = Object.create(null);
const GAME_KEYS = new Set(['ArrowLeft','ArrowRight','ArrowUp','ArrowDown',' ','Spacebar']);
let touchX = null, touchFire = false, isTouch = false;

function press(){
  Snd.boot(); Snd.resume();
  if (G.state === 'menu' || G.state === 'over') reset();
}

addEventListener('keydown', e => {
  if (e.repeat) { if (GAME_KEYS.has(e.key)) e.preventDefault(); return; }
  const k = e.key;
  keys[k.length===1 ? k.toLowerCase() : k] = true;
  if (GAME_KEYS.has(k)) e.preventDefault();

  if (k === ' ' || k === 'Enter') press();
  if (k === 'p' || k === 'P' || k === 'Escape'){
    if (G.state === 'play') { G.state = 'pause'; Snd.ufoStop(); }
    else if (G.state === 'pause'){ G.state = 'play'; if (ufo) Snd.ufoStart(); }
  }
  if (k === 'm' || k === 'M') toggleSound();
  if (k === 'f' || k === 'F') toggleFull();
  if (k === 'g' || k === 'G') toggleFx();
});
addEventListener('keyup', e => {
  const k = e.key;
  keys[k.length===1 ? k.toLowerCase() : k] = false;
  if (GAME_KEYS.has(k)) e.preventDefault();
});
addEventListener('blur', () => {
  for (const k in keys) keys[k] = false;
  if (G.state === 'play') { G.state = 'pause'; Snd.ufoStop(); }
});
document.addEventListener('visibilitychange', () => {
  if (document.hidden && G.state === 'play') { G.state = 'pause'; Snd.ufoStop(); }
});

/* pointer: drag anywhere to steer, holding fires */
function ptrX(e){
  const r = cv.getBoundingClientRect();
  const px = (e.clientX - r.left) * (cv.width / r.width);
  return (px - view.ox) / view.scale;
}
cv.addEventListener('pointerdown', e => {
  /* A mouse click only claims keyboard focus and starts the round. Steering by
     pointer is touch-only, so clicking never yanks the ship away from the keys. */
  try { window.focus(); } catch(_) {}
  cv.focus();
  if (e.pointerType === 'touch'){
    isTouch = true;
    cv.setPointerCapture(e.pointerId);
    touchX = ptrX(e); touchFire = true;
  }
  press(); e.preventDefault();
});
cv.addEventListener('pointermove', e => {
  if (isTouch && touchFire && e.pointerType === 'touch') touchX = ptrX(e);
});
const endPtr = () => { touchFire = false; touchX = null; };
cv.addEventListener('pointerup', endPtr);
cv.addEventListener('pointercancel', endPtr);
cv.addEventListener('pointerleave', endPtr);
cv.addEventListener('contextmenu', e => e.preventDefault());

/* ---------- chrome buttons -------------------------------------------- */
const bSound = document.getElementById('bSound'),
      bFx    = document.getElementById('bFx'),
      bFull  = document.getElementById('bFull');

function toggleSound(){
  Snd.boot(); Snd.on = !Snd.on;
  if (!Snd.on) Snd.ufoStop();
  if (Snd.master) Snd.master.gain.value = Snd.on ? 0.35 : 0;
  bSound.classList.toggle('dim', !Snd.on);
}
function toggleFx(){
  fxOn = !fxOn;
  document.getElementById('scan').classList.toggle('fx-off', !fxOn);
  document.getElementById('vig').classList.toggle('fx-off', !fxOn);
  bFx.classList.toggle('dim', !fxOn);
}
async function toggleFull(){
  try {
    if (!document.fullscreenElement) await document.documentElement.requestFullscreen();
    else await document.exitFullscreen();
  } catch (_) { /* sandboxed frames may refuse; the layout already fills the viewport */ }
}
bSound.addEventListener('click', e => { e.stopPropagation(); toggleSound(); });
bFx.addEventListener('click',    e => { e.stopPropagation(); toggleFx(); });
bFull.addEventListener('click',  e => { e.stopPropagation(); toggleFull(); });
if (!document.fullscreenEnabled) bFull.style.display = 'none';

function updateFullscreenState(){
  const isFull = !!(document.fullscreenElement || document.webkitFullscreenElement || window.innerHeight >= window.screen.height - 10);
  document.body.classList.toggle("in-fullscreen", isFull);
}
document.addEventListener("fullscreenchange", updateFullscreenState);
document.addEventListener("webkitfullscreenchange", updateFullscreenState);
window.addEventListener("resize", updateFullscreenState);

/* ---------- update ---------------------------------------------------- */
function fire(){
  const cx = player.x + player.w/2;
  const mk = (x,vx) => shots.push({ x, y:player.y-6, w:4, h:16, vx, vy:-1150 });
  if (player.weapon === 2){ mk(cx-2,-190); mk(cx-2,0); mk(cx-2,190); }
  else mk(cx-2,0);
  player.cool = player.weapon === 1 ? 0.11 : 0.26;
  Snd.shoot();
}

function killPlayer(){
  G.lives--;
  player.inv = 2.6; player.weapon = 0; player.wt = 0;
  burst(player.x+player.w/2, player.y+player.h/2, 46, C.hot, 1.5);
  shake = 22; glitch = 0.5; Snd.hurt();
  bombs.length = 0;
  if (G.lives <= 0){
    G.state = 'over'; G.timer = 0; Snd.ufoStop(); Snd.over();
    G.best = Math.max(G.best, G.score);
  } else {
    G.state = 'dead'; G.timer = 1.1;
  }
}

function update(dt){
  G.t += dt; G.anim += dt;
  shake  = Math.max(0, shake - dt*60);
  glitch = Math.max(0, glitch - dt);
  G.surge = Math.max(0, G.surge - dt);

  /* stars drift always, even on menus */
  for (const s of stars){
    s.y += dt * (0.012 + s.z*0.05) * (G.state==='play'?1:0.4);
    if (s.y > 1) { s.y -= 1; s.x = Math.random(); }
  }

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

  if (G.state === 'dead'){
    G.timer -= dt;
    if (G.timer <= 0){ player.x = V.w/2 - player.w/2; G.state = 'play'; }
    return;
  }
  if (G.state === 'wave'){
    G.timer -= dt;
    if (G.timer <= 0){ buildWave(); G.state = 'play'; }
    return;
  }
  if (G.state !== 'play') return;

  /* --- player --- */
  const acc = 5200, maxV = 640;
  let want = 0;
  if (keys['ArrowLeft']  || keys['a']) want -= 1;
  if (keys['ArrowRight'] || keys['d']) want += 1;
  if (touchX !== null){
    const target = clamp(touchX - player.w/2, 0, V.w - player.w);
    const d = target - player.x;
    want = clamp(d / 40, -1, 1);
  }
  player.vx += want * acc * dt;
  player.vx *= Math.pow(0.0009, dt);            // frame-rate independent drag
  player.vx = clamp(player.vx, -maxV, maxV);
  player.x = clamp(player.x + player.vx*dt, 0, V.w - player.w);
  if (player.x <= 0 || player.x >= V.w - player.w) player.vx = 0;

  if (player.inv > 0) player.inv -= dt;
  if (player.wt > 0){ player.wt -= dt; if (player.wt <= 0) player.weapon = 0; }
  player.cool -= dt;
  if ((keys[' '] || touchFire) && player.cool <= 0) fire();

  /* --- player shots --- */
  for (let i=shots.length-1;i>=0;i--){
    const s = shots[i];
    s.x += s.vx*dt; s.y += s.vy*dt;
    if (s.y < -20 || s.x < -20 || s.x > V.w+20){ shots.splice(i,1); continue; }
    const hitB = bunkerHit(s.x + s.w/2 - s.vx*dt, s.y - s.vy*dt,
                           s.x + s.w/2, s.y, BLOB_SHOT);
    if (hitB){
      burst(hitB.x, hitB.y, 5, C.violet, 0.45);
      shots.splice(i,1); continue;
    }
    let gone = false;
    if (ufo && s.y < ufo.y + ufo.h && s.y > ufo.y &&
        s.x > ufo.x && s.x < ufo.x + ufo.w){
      G.score += ufo.pts; burst(ufo.x+ufo.w/2, ufo.y+ufo.h/2, 34, C.amber, 1.3);
      shake = 14; glitch = 0.35; G.surge = 3.2;
      Snd.kill(); Snd.ufoStop(); ufo = null; shots.splice(i,1); continue;
    }
    for (const e of enemies){
      if (!e.alive) continue;
      const p = alienPos(e);
      if (s.x < p.x+p.w && s.x+s.w > p.x && s.y < p.y+p.h && s.y+s.h > p.y){
        e.alive = false;
        G.score += TYPES[e.type].pts * (G.surge>0?2:1);
        burst(p.x+p.w/2, p.y+p.h/2, 14, TYPES[e.type].col);
        shake = Math.max(shake, 5); Snd.kill();
        if (Math.random() < 0.065) drops.push({
          x:p.x+p.w/2-13, y:p.y+p.h/2, w:26, h:26, vy:150,
          kind: Math.random()<0.08 ? 'life' : pick(['rapid','triple','shield'])
        });
        shots.splice(i,1); gone = true; break;
      }
    }
    if (gone) continue;
  }

  /* --- formation marching --- */
  const alive = enemies.filter(e => e.alive);
  if (alive.length === 0){
    G.level++; G.score += 100 * G.level;
    G.state = 'wave'; G.timer = 1.6; G.surge = 2.5;
    Snd.wave(); Snd.ufoStop(); ufo = null;
    if (G.level % 3 === 0 && bunkers.some(b => b.cells.some(c=>!c))) buildBunkers();
    return;
  }
  const ratio = alive.length / 55;
  const interval = clamp(lerp(0.045, 0.50, ratio) * Math.pow(0.9, G.level-1), 0.03, 0.5)
                   * (G.surge>0 ? 0.75 : 1);
  form.step += dt;
  if (form.step >= interval){
    form.step -= interval;
    form.frame ^= 1;
    let minC = 99, maxC = -1;
    for (const e of alive){ if (e.col<minC) minC=e.col; if (e.col>maxC) maxC=e.col; }
    const stepX = 20;
    const left  = form.x + minC*gapX;
    const right = form.x + maxC*gapX + alienW;
    if ((form.dir > 0 && right + stepX > V.w - 36) ||
        (form.dir < 0 && left  - stepX < 36)){
      form.dir *= -1;
      form.y += 28;
      Snd.drop();
      razeBunkersAt(form.y + 4*gapY + alienSize(2).h);
    } else {
      form.x += form.dir * stepX;
      Snd.step();
    }
    /* invasion check */
    let lowest = 0;
    for (const e of alive){ const p = alienPos(e); lowest = Math.max(lowest, p.y+p.h); }
    if (lowest >= player.y){
      G.lives = 0; killPlayer(); return;
    }
  }

  /* --- alien fire (bottom of a random column, classic rule) --- */
  const rate = (0.62 + G.level*0.12) * clamp(1.4 - ratio*0.6, 0.7, 1.4);
  if (Math.random() < rate * dt){
    const cols = [...new Set(alive.map(e=>e.col))];
    const col = pick(cols);
    let low = null;
    for (const e of alive) if (e.col === col && (!low || e.row > low.row)) low = e;
    if (low){
      const p = alienPos(low);
      bombs.push({ x:p.x+p.w/2-4, y:p.y+p.h, w:8, h:20,
        vy: 300 + G.level*22, kind: randi(0,3) });
    }
  }
  for (let i=bombs.length-1;i>=0;i--){
    const b = bombs[i];
    b.y += b.vy*dt;
    if (b.y > V.h+20){ bombs.splice(i,1); continue; }
    const hitB = bunkerHit(b.x + b.w/2, b.y + b.h - b.vy*dt,
                           b.x + b.w/2, b.y + b.h, BLOB_BOMB);
    if (hitB){
      burst(hitB.x, hitB.y, 7, C.hot, 0.55);
      bombs.splice(i,1); continue;
    }
    if (player.inv <= 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){
      bombs.splice(i,1); killPlayer(); return;
    }
  }

  /* --- UFO --- */
  ufoTimer -= dt;
  if (!ufo && ufoTimer <= 0 && alive.length > 4){
    const dir = Math.random()<0.5 ? 1 : -1;
    const w = clamp(V.w*0.048, 58, 84);
    ufo = { x: dir>0 ? -w : V.w, y:78, w, h:w*(7/16), vx:dir*230,
            pts: pick([50,100,150,200,300]) };
    ufoTimer = rand(18,32);
    Snd.ufoStart();
  }
  if (ufo){
    ufo.x += ufo.vx*dt;
    if (ufo.x < -ufo.w-10 || ufo.x > V.w+10){ ufo = null; Snd.ufoStop(); }
  }

  /* --- powerup drops --- */
  for (let i=drops.length-1;i>=0;i--){
    const d = drops[i];
    d.y += d.vy*dt;
    if (d.y > V.h+30){ drops.splice(i,1); continue; }
    if (d.x < player.x+player.w && d.x+d.w > player.x &&
        d.y < player.y+player.h && d.y+d.h > player.y){
      if (d.kind === 'rapid')  { player.weapon = 1; player.wt = 11; }
      if (d.kind === 'triple') { player.weapon = 2; player.wt = 11; }
      if (d.kind === 'shield') { player.inv = Math.max(player.inv, 7); }
      if (d.kind === 'life')   { G.lives = Math.min(6, G.lives+1); }
      burst(d.x+13, d.y+13, 16, C.amber, 0.8);
      Snd.power(); drops.splice(i,1);
    }
  }
  G.best = Math.max(G.best, G.score);
}

/* ---------- render ---------------------------------------------------- */
function text(str,x,y,size,color,align='left',weight=700){
  ctx.font = `${weight} ${size}px ui-monospace, Menlo, Consolas, "Courier New", monospace`;
  ctx.textAlign = align; ctx.textBaseline = 'alphabetic';
  ctx.shadowColor = color; ctx.shadowBlur = size*0.55;
  ctx.fillStyle = color; ctx.fillText(str,x,y);
  ctx.shadowBlur = 0;
}
function surgeCol(base, seed){
  return G.surge > 0 ? SURGE[Math.floor(G.t*11 + seed) % SURGE.length] : base;
}

function render(){
  /* backdrop fills the entire canvas — no bars, whatever the aspect ratio */
  const g = ctx.createRadialGradient(cv.width/2, cv.height*0.42, 0,
                                     cv.width/2, cv.height*0.42, cv.height*0.95);
  g.addColorStop(0, C.bg0); g.addColorStop(1, C.bg1);
  ctx.fillStyle = g; ctx.fillRect(0,0,cv.width,cv.height);

  const sx = shake ? rand(-shake,shake) : 0;
  const sy = shake ? rand(-shake,shake) : 0;
  ctx.setTransform(view.scale,0,0,view.scale, view.ox + sx*view.scale, view.oy + sy*view.scale);

  /* stars */
  for (const s of stars){
    ctx.globalAlpha = 0.25 + s.z*0.7;
    ctx.fillStyle = s.z > 0.75 ? C.ink : '#ffffff';
    ctx.fillRect(s.x*V.w, s.y*V.h, s.s, s.s);
  }
  ctx.globalAlpha = 1;

  /* horizon line — the one piece of set dressing */
  ctx.strokeStyle = 'rgba(157,92,255,.28)'; ctx.lineWidth = 2;
  ctx.beginPath(); ctx.moveTo(0, player.y+player.h+26); ctx.lineTo(V.w, player.y+player.h+26); ctx.stroke();

  if (G.state !== 'menu'){
    /* bunkers */
    for (const b of bunkers){
      const col = surgeCol(C.violet, 3);
      if (b.dirty || b.col !== col) paintBunker(b, col);
      ctx.save(); ctx.globalCompositeOperation = 'lighter'; ctx.globalAlpha = 0.28;
      ctx.drawImage(b.cn, b.x-2, b.y-2, b.w+4, b.h+4); ctx.restore();
      ctx.drawImage(b.cn, b.x, b.y, b.w, b.h);
    }

    /* aliens */
    for (const e of enemies){
      if (!e.alive) continue;
      const p = alienPos(e);
      const T = TYPES[e.type];
      blit(T.sp[form.frame], surgeCol(T.col, e.col+e.row*3), p.x, p.y, p.w, p.h, 0.3);
    }

    /* ufo */
    if (ufo){
      const c = SURGE[Math.floor(G.t*14)%SURGE.length];
      blit(SP.ufo, c, ufo.x, ufo.y, ufo.w, ufo.h, 0.55);
    }

    /* bombs */
    const bf = Math.floor(G.anim*14)%2;
    for (const b of bombs){
      const s = (bf ? SP.bombAlt : SP.bomb)[b.kind];
      blit(s, C.hot, b.x, b.y, b.w, b.h, 0.35);
    }

    /* player shots */
    for (const s of shots){
      ctx.save(); ctx.globalCompositeOperation = 'lighter';
      ctx.fillStyle = C.ink; ctx.globalAlpha = 0.45;
      ctx.fillRect(s.x-3, s.y-4, s.w+6, s.h+10);
      ctx.restore();
      ctx.fillStyle = C.white; ctx.fillRect(s.x, s.y, s.w, s.h);
    }

    /* drops */
    for (const d of drops){
      const col = d.kind==='rapid' ? C.ink : d.kind==='triple' ? C.hot
                : d.kind==='shield' ? C.amber : C.acid;
      const pulse = 0.6 + 0.4*Math.sin(G.t*9);
      ctx.save(); ctx.globalCompositeOperation='lighter'; ctx.globalAlpha = 0.35*pulse;
      ctx.fillStyle = col; ctx.fillRect(d.x-5, d.y-5, d.w+10, d.h+10); ctx.restore();
      ctx.strokeStyle = col; ctx.lineWidth = 2.5;
      ctx.strokeRect(d.x, d.y, d.w, d.h);
      const ch = d.kind==='rapid'?'R':d.kind==='triple'?'W':d.kind==='shield'?'S':'+';
      text(ch, d.x+d.w/2, d.y+d.h*0.74, 17, col, 'center');
    }

    /* player */
    if (G.state !== 'dead' && G.state !== 'over'){
      const blink = player.inv > 0 && player.inv < 1.2 && Math.floor(G.t*14)%2 === 0;
      if (!blink){
        blit(SP.ship, player.inv>0 ? C.amber : C.ink, player.x, player.y, player.w, player.h, 0.4);
        /* thruster */
        const th = 6 + Math.abs(player.vx)/70 + rand(0,5);
        ctx.save(); ctx.globalCompositeOperation='lighter'; ctx.globalAlpha=0.8;
        ctx.fillStyle = C.hot;
        ctx.fillRect(player.x+player.w*0.38, player.y+player.h, player.w*0.24, th);
        ctx.restore();
      }
      if (player.inv > 0){
        ctx.strokeStyle = `rgba(255,176,32,${0.15+0.15*Math.sin(G.t*11)})`;
        ctx.lineWidth = 3;
        ctx.beginPath();
        ctx.ellipse(player.x+player.w/2, player.y+player.h/2, player.w*0.85, player.h*1.5, 0, 0, 6.29);
        ctx.stroke();
      }
    }
  }

  /* particles */
  ctx.save(); ctx.globalCompositeOperation = 'lighter';
  for (const p of parts){
    ctx.globalAlpha = clamp(p.life/p.max,0,1);
    ctx.fillStyle = p.col;
    ctx.fillRect(p.x, p.y, p.sz, p.sz);
  }
  ctx.restore(); ctx.globalAlpha = 1;

  drawHud();
  ctx.setTransform(1,0,0,1,0,0);
  if (fxOn && glitch > 0) glitchPass();
}

function drawHud(){
  const pad = 28;
  if (G.state === 'menu'){
    const cx = V.w/2;
    ctx.save();
    ctx.globalAlpha = 0.9;
    text('SIGNAL', cx, V.h*0.36, 92, C.hot, 'center', 800);
    text('LOST',   cx, V.h*0.36+86, 92, C.ink, 'center', 800);
    ctx.restore();
    text('they came down through the carrier wave', cx, V.h*0.36+140, 20, C.dim, 'center', 500);
    const on = Math.floor(G.t*2)%2 === 0;
    const focused = document.hasFocus();
    text(isTouch ? 'TAP TO BEGIN'
                 : (focused ? 'PRESS SPACE TO BEGIN' : 'CLICK HERE, THEN PRESS SPACE'),
         cx, V.h*0.62, 26, on ? C.acid : 'rgba(168,255,54,.28)', 'center');
    text(isTouch ? 'DRAG TO STEER  ·  HOLD TO FIRE'
                 : '← → / A D  MOVE      SPACE  FIRE      P  PAUSE      F  FULLSCREEN',
         cx, V.h*0.62+52, 17, C.dim, 'center', 500);
    if (G.best) text('BEST  ' + G.best, cx, V.h*0.62+92, 17, C.amber, 'center');
    return;
  }

  text('SCORE', pad, 44, 15, C.dim, 'left', 500);
  text(String(G.score).padStart(5,'0'), pad, 78, 32, C.ink);

  text('WAVE', V.w/2, 44, 15, C.dim, 'center', 500);
  text(String(G.level).padStart(2,'0'), V.w/2, 78, 32, surgeCol(C.violet,1), 'center');

  text('BEST', V.w-pad, 44, 15, C.dim, 'right', 500);
  text(String(G.best).padStart(5,'0'), V.w-pad, 78, 32, C.amber, 'right');

  /* lives as ship icons */
  for (let i=0;i<G.lives;i++){
    blit(SP.ship, C.ink, pad + i*40, V.h-46, 30, 16, 0.25);
  }

  /* weapon timer */
  if (player.wt > 0){
    const w = 180, x = V.w-pad-w, y = V.h-42;
    ctx.fillStyle = 'rgba(142,246,255,.15)'; ctx.fillRect(x,y,w,7);
    ctx.fillStyle = player.weapon===1 ? C.ink : C.hot;
    ctx.fillRect(x,y,w*(player.wt/11),7);
    text(player.weapon===1?'RAPID':'SPREAD', V.w-pad, y-10, 14,
         player.weapon===1?C.ink:C.hot, 'right', 600);
  }

  if (G.surge > 0){
    text('SURGE ×2', V.w/2, 116, 20, SURGE[Math.floor(G.t*13)%SURGE.length], 'center');
  }

  if (G.state === 'wave'){
    text('WAVE ' + G.level, V.w/2, V.h/2, 64, C.acid, 'center', 800);
  }
  if (G.state === 'pause'){
    ctx.fillStyle = 'rgba(4,1,10,.72)'; ctx.fillRect(0,0,V.w,V.h);
    text('PAUSED', V.w/2, V.h/2, 58, C.ink, 'center', 800);
    text('press P to resume', V.w/2, V.h/2+46, 19, C.dim, 'center', 500);
  }
  if (G.state === 'over'){
    ctx.fillStyle = 'rgba(4,1,10,.7)'; ctx.fillRect(0,0,V.w,V.h);
    text('SIGNAL LOST', V.w/2, V.h/2-34, 68,
         SURGE[Math.floor(G.t*6)%SURGE.length], 'center', 800);
    text('SCORE  ' + G.score, V.w/2, V.h/2+26, 28, C.white, 'center');
    text('WAVE ' + G.level + '  ·  BEST ' + G.best, V.w/2, V.h/2+64, 19, C.dim, 'center', 500);
    const on = Math.floor(G.t*2)%2 === 0;
    text(isTouch ? 'TAP TO RETRY'
                 : (document.hasFocus() ? 'PRESS SPACE TO RETRY' : 'CLICK HERE, THEN PRESS SPACE'),
         V.w/2, V.h/2+124, 24, on ? C.acid : 'rgba(168,255,54,.25)', 'center');
  }
}

/* short-lived RGB/slice tear — only fires on real events, never every frame */
function glitchPass(){
  const a = clamp(glitch/0.5, 0, 1);
  ctx.save();
  ctx.globalCompositeOperation = 'lighter';
  for (let i=0;i<5;i++){
    const y = randi(0, cv.height-4);
    const h = randi(4, Math.max(8, cv.height*0.09));
    const dx = (Math.random()-0.5) * 90 * a * view.dpr;
    ctx.globalAlpha = 0.4*a;
    ctx.drawImage(cv, 0, y, cv.width, h, dx, y, cv.width, h);
  }
  ctx.globalAlpha = 0.16*a;
  ctx.fillStyle = C.hot;
  ctx.fillRect(0, randi(0,cv.height), cv.width, randi(2,10));
  ctx.restore();
}

/* ---------- loop ------------------------------------------------------ */
let last = performance.now();
function frame(now){
  const dt = Math.min(0.05, (now - last)/1000);
  last = now;
  update(dt);
  render();
  requestAnimationFrame(frame);
}

layout();
buildBunkers();
buildWave();
requestAnimationFrame(frame);
</script>
</body>
</html>
MikeHiggins.me.uk LinkedIn GitHub Bluesky Contra Upwork
© 2026 Mike Higgins. All rights reserved.