← back to Wild Orbs Opus

index.html

2825 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no">
<meta name="theme-color" content="#04030a">
<meta name="description" content="Wild Orbs — an Asteroids-style arcade game where every orb breaks differently.">
<title>WILD ORBS — every orb breaks differently</title>
<style>
  * { margin:0; padding:0; box-sizing:border-box; }
  html,body {
    width:100%; height:100%; overflow:hidden; background:#04030a;
    font-family: "Trebuchet MS", "Segoe UI", system-ui, sans-serif;
    color:#dfe9ff; user-select:none; -webkit-user-select:none; touch-action:none;
    overscroll-behavior:none;
  }
  #wrap { position:fixed; inset:0; display:block; }
  canvas { display:block; width:100%; height:100%; touch-action:none; }

  .overlay {
    position:absolute; inset:0; display:flex; flex-direction:column;
    align-items:center; justify-content:center; text-align:center;
    background: radial-gradient(ellipse at 50% 40%, rgba(30,10,60,.62), rgba(2,2,8,.9) 72%);
    -webkit-backdrop-filter: blur(3px); backdrop-filter: blur(3px);
    z-index:10; padding:22px 18px; overflow-y:auto;
    animation: fadein .22s ease;
  }
  @keyframes fadein { from { opacity:0 } to { opacity:1 } }
  .hidden { display:none !important; }

  h1 {
    font-size: clamp(34px, 8.4vw, 88px);
    letter-spacing: .14em; font-weight:900; line-height:1.04;
    background: linear-gradient(180deg,#fff 0%,#8ef7ff 40%,#c56bff 75%,#ff4fd8 100%);
    -webkit-background-clip:text; background-clip:text; color:transparent;
    filter: drop-shadow(0 0 26px rgba(140,90,255,.5));
    margin-bottom:2px;
  }
  .sub { font-size: clamp(10px,2.1vw,14px); letter-spacing:.42em; color:#8fa4d8; margin-bottom:18px; text-transform:uppercase; }
  .panel {
    max-width: 900px; width:100%;
    border:1px solid rgba(120,170,255,.20);
    background: rgba(10,10,26,.55);
    border-radius:14px; padding:14px 16px; margin-bottom:14px;
    box-shadow: 0 0 40px rgba(80,40,160,.22) inset;
  }
  .panel-title { letter-spacing:.3em; font-size:11px; color:#8fa4d8; margin-bottom:9px; text-transform:uppercase; }
  .keys { display:flex; gap:12px 16px; flex-wrap:wrap; justify-content:center; }
  .key { display:flex; align-items:center; gap:7px; font-size:12.5px; color:#b9c8ef; white-space:nowrap; }
  kbd {
    display:inline-block; min-width:24px; padding:3px 7px; font-size:10.5px; font-family:inherit;
    background:#171a33; border:1px solid #39406e; border-bottom-width:3px; border-radius:5px; color:#dce6ff;
  }
  .orbgrid {
    display:grid; grid-template-columns:repeat(auto-fill,minmax(196px,1fr));
    gap:8px 12px; text-align:left;
  }
  .orbrow { display:flex; gap:9px; align-items:flex-start; font-size:11.5px; line-height:1.34; color:#a3b3dd; }
  .dot { width:15px; height:15px; border-radius:50%; flex:0 0 15px; margin-top:2px; box-shadow:0 0 10px currentColor; }
  .orbrow b { color:#fff; display:block; font-size:11.5px; letter-spacing:.05em; }
  .tips { display:grid; grid-template-columns:repeat(auto-fill,minmax(240px,1fr)); gap:6px 16px; text-align:left; font-size:12px; color:#93a3ce; }
  .tips span b { color:#cfe0ff; }
  .btnrow { display:flex; gap:12px; flex-wrap:wrap; justify-content:center; align-items:center; }
  .btn {
    font-family:inherit; font-weight:800; letter-spacing:.2em; font-size:15px;
    padding:13px 38px; border-radius:40px; cursor:pointer; text-transform:uppercase;
    color:#04030a; border:none; -webkit-tap-highlight-color:transparent;
    background:linear-gradient(135deg,#7df9ff,#b06bff 55%,#ff5ec7);
    box-shadow:0 0 26px rgba(160,90,255,.55), 0 6px 0 rgba(0,0,0,.35);
    transition: transform .08s ease, box-shadow .2s ease;
  }
  .btn:hover { transform:translateY(-2px); box-shadow:0 0 40px rgba(180,110,255,.8), 0 8px 0 rgba(0,0,0,.35); }
  .btn:active { transform:translateY(2px); box-shadow:0 0 20px rgba(160,90,255,.6), 0 2px 0 rgba(0,0,0,.35); }
  .btn.ghost {
    background:transparent; color:#b9c8ef; border:1px solid rgba(140,170,255,.35);
    box-shadow:none; font-size:12px; padding:11px 24px; letter-spacing:.18em;
  }
  .btn.ghost:hover { color:#fff; border-color:rgba(180,210,255,.7); transform:translateY(-1px); box-shadow:0 0 18px rgba(120,90,220,.4); }
  .hint { margin-top:12px; font-size:11.5px; color:#67739c; letter-spacing:.14em; }
  #goTitle { font-size: clamp(30px,7vw,70px); }
  .statline { font-size:14px; color:#9fb1dd; letter-spacing:.16em; }
  .bigscore { font-size: clamp(30px,7vw,58px); font-weight:900; color:#fff; text-shadow:0 0 26px rgba(120,220,255,.7); line-height:1.1; }
  .newbest { color:#ffd76b; font-size:13px; letter-spacing:.3em; min-height:16px; text-shadow:0 0 16px rgba(255,180,60,.7); }
  .statgrid { display:grid; grid-template-columns:repeat(auto-fit,minmax(104px,1fr)); gap:10px 8px; }
  .stat { text-align:center; }
  .stat .v { font-size:20px; font-weight:900; color:#eaf3ff; }
  .stat .k { font-size:9.5px; letter-spacing:.22em; color:#7787b3; text-transform:uppercase; margin-top:2px; }
  .flashmsg { font-size:12.5px; color:#ffd76b; letter-spacing:.16em; min-height:16px; margin-top:8px; }
  .toggles { display:flex; gap:10px; flex-wrap:wrap; justify-content:center; font-size:11px; letter-spacing:.16em; color:#8b9bc8; }
  .tg { border:1px solid rgba(140,170,255,.25); border-radius:20px; padding:7px 14px; cursor:pointer; }
  .tg:hover { color:#fff; border-color:rgba(180,210,255,.6); }
  .tg b { color:#7df9ff; }
  .tg.off b { color:#6b7699; }
  @media (max-height: 560px) {
    .panel { padding:10px 12px; margin-bottom:9px; }
    h1 { font-size: clamp(26px,7vw,52px); }
    .sub { margin-bottom:10px; }
    #legendPanel { display:none; }
  }
</style>
</head>
<body>
<div id="wrap">
  <canvas id="cv"></canvas>

  <!-- ============ START SCREEN ============ -->
  <div id="startScreen" class="overlay">
    <h1>WILD ORBS</h1>
    <div class="sub">every orb breaks differently</div>

    <div class="panel">
      <div class="keys">
        <span class="key"><kbd>&#8592;</kbd><kbd>&#8594;</kbd>/<kbd>A</kbd><kbd>D</kbd> rotate</span>
        <span class="key"><kbd>&#8593;</kbd>/<kbd>W</kbd> thrust</span>
        <span class="key"><kbd>SPACE</kbd> fire</span>
        <span class="key"><kbd>SHIFT</kbd> pulse bomb</span>
        <span class="key"><kbd>H</kbd> hyperspace</span>
        <span class="key"><kbd>P</kbd> pause</span>
        <span class="key"><kbd>M</kbd> mute</span>
      </div>
      <div style="font-size:11.5px;color:#7d8cb8;letter-spacing:.08em;margin-top:9px">
        Screen wraps. Inertia is real. Touch: left thumb steers &amp; thrusts, right side fires.
      </div>
    </div>

    <div class="panel" id="legendPanel">
      <div class="panel-title">The thirteen wild orbs</div>
      <div class="orbgrid" id="orbLegend"></div>
    </div>

    <div class="panel">
      <div class="panel-title">How to survive</div>
      <div class="tips">
        <span><b>Know your target.</b> Some orbs punish the shot more than the miss.</span>
        <span><b>Chain kills.</b> Fast consecutive breaks build a score multiplier.</span>
        <span><b>Grab drops.</b> Broken orbs sometimes cough up spread, rapid, shields.</span>
        <span><b>Save a pulse.</b> SHIFT clears the room when the swarm closes in.</span>
      </div>
    </div>

    <div class="btnrow">
      <button class="btn" id="startBtn">Launch</button>
    </div>
    <div class="hint">press SPACE or ENTER</div>
  </div>

  <!-- ============ GAME OVER ============ -->
  <div id="overScreen" class="overlay hidden">
    <h1 id="goTitle">GAME OVER</h1>
    <div class="statline">FINAL SCORE</div>
    <div class="bigscore" id="finalScore">0</div>
    <div class="newbest" id="newBest"></div>
    <div class="panel" style="max-width:680px;margin-top:10px">
      <div class="statgrid">
        <div class="stat"><div class="v" id="stWave">1</div><div class="k">Wave</div></div>
        <div class="stat"><div class="v" id="stOrbs">0</div><div class="k">Orbs broken</div></div>
        <div class="stat"><div class="v" id="stCombo">0</div><div class="k">Best chain</div></div>
        <div class="stat"><div class="v" id="stAcc">0%</div><div class="k">Accuracy</div></div>
        <div class="stat"><div class="v" id="stTime">0:00</div><div class="k">Survived</div></div>
      </div>
      <div class="flashmsg" id="finalMsg"></div>
      <div class="statline" id="finalBest" style="font-size:12px;margin-top:6px">BEST 0</div>
    </div>
    <div class="btnrow">
      <button class="btn" id="againBtn">Again</button>
      <button class="btn ghost" id="menuBtn">Main menu</button>
    </div>
    <div class="hint">press SPACE or ENTER</div>
  </div>

  <!-- ============ PAUSE ============ -->
  <div id="pauseScreen" class="overlay hidden">
    <h1 style="font-size:clamp(28px,6.4vw,58px)">PAUSED</h1>
    <div class="panel" style="max-width:560px">
      <div class="toggles">
        <div class="tg" id="tgSound">SOUND <b id="tgSoundV">ON</b></div>
        <div class="tg" id="tgShake">SCREEN SHAKE <b id="tgShakeV">ON</b></div>
      </div>
    </div>
    <div class="panel" style="max-width:900px">
      <div class="panel-title">Orb bestiary</div>
      <div class="orbgrid" id="orbLegend2"></div>
    </div>
    <div class="btnrow">
      <button class="btn" id="resumeBtn">Resume</button>
      <button class="btn ghost" id="quitBtn">Quit run</button>
    </div>
    <div class="hint">press P to resume</div>
  </div>
</div>

<script>
"use strict";
/* ============================================================
   WILD ORBS — single-file canvas arcade
   Asteroids, except every orb detonates into a different disaster.
   ============================================================ */

/* ---------------- canvas + sizing ---------------- */
const cv  = document.getElementById('cv');
const ctx = cv.getContext('2d', { alpha:false });
const bgc = document.createElement('canvas');       // pre-rendered background
const bgx = bgc.getContext('2d');

let W = 0, H = 0, DPR = 1;

/* This game is fill-rate bound, not CPU bound: at devicePixelRatio 2 every
   full-screen pass costs 4x. Rather than drop frames on weaker hardware we
   scale the backing store and keep the motion at 60fps. */
let renderScale = 1, scaleCooldown = 0;

function applyBackingStore(){
  const base = Math.min(window.devicePixelRatio || 1, 2);
  DPR = clamp(base * renderScale, 0.6, 2);
  cv.width  = Math.max(1, Math.floor(W * DPR));
  cv.height = Math.max(1, Math.floor(H * DPR));
  cv.style.width  = W + 'px';
  cv.style.height = H + 'px';
  ctx.setTransform(DPR,0,0,DPR,0,0);
  bgc.width = cv.width; bgc.height = cv.height;
  bgx.setTransform(DPR,0,0,DPR,0,0);
  paintBackground();
  buildScreenGrads();
}

function resize(){
  W = Math.max(320, window.innerWidth);
  H = Math.max(320, window.innerHeight);
  buildStars();
  applyBackingStore();
}

/** nudge the backing-store scale toward whatever holds ~60fps (with hysteresis) */
function adaptResolution(){
  // A recovery step sets a deliberately long cooldown so the resolution can't
  // oscillate. But the cooldown is shared, so a recent upscale used to lock out
  // an urgently-needed DOWNSCALE for ~10s — the game sat at 29fps refusing to
  // shed pixels. A clearly-dropping framerate now bypasses the cooldown.
  const urgent = frameAvg > 30;
  if(scaleCooldown>0 && !urgent){ scaleCooldown--; return; }
  if(frameAvg > 23 && renderScale > 0.55){
    renderScale = Math.max(0.55, renderScale - 0.15);
    // Seed just UNDER the trigger, not down at a healthy 16.7: reseeding to a
    // good value made the average re-climb from scratch after every step, so
    // converging took ~9s of visible jank. Now a couple of still-slow frames
    // step again immediately. Recovery stays deliberately slow (below).
    scaleCooldown = 24; frameAvg = 21; applyBackingStore();
  } else if(frameAvg < 13.5 && renderScale < 1){
    renderScale = Math.min(1, renderScale + 0.1);
    scaleCooldown = 300; frameAvg = 16.7; applyBackingStore();
  }
}
window.addEventListener('resize', resize);
window.addEventListener('orientationchange', ()=> setTimeout(resize, 120));

/* ---------------- math helpers ---------------- */
const TAU = Math.PI*2;
const rnd  = (a=1,b=0)=> b + Math.random()*(a-b);
const rndi = (a,b)=> Math.floor(rnd(a,b));
const pick = arr => arr[(Math.random()*arr.length)|0];
const clamp= (v,a,b)=> v<a?a:(v>b?b:v);
const lerp = (a,b,t)=> a+(b-a)*t;

function wrapPos(o){
  const r = o.r || 0;
  if(o.x < -r) o.x += W + r*2;
  else if(o.x > W + r) o.x -= W + r*2;
  if(o.y < -r) o.y += H + r*2;
  else if(o.y > H + r) o.y -= H + r*2;
}
/** shortest delta from (x1,y1) to (x2,y2) across the wrapping playfield */
function wrapDelta(x1,y1,x2,y2){
  let dx = x2-x1, dy = y2-y1;
  if(dx >  W/2) dx -= W; else if(dx < -W/2) dx += W;
  if(dy >  H/2) dy -= H; else if(dy < -H/2) dy += H;
  return [dx,dy];
}
function wrapDist(a,b){ const [dx,dy]=wrapDelta(a.x,a.y,b.x,b.y); return Math.hypot(dx,dy); }
/** turn a wrapped delta + force magnitude into a velocity nudge on entity.
 *  dx,dy,d must already be the wrapDelta/hypot to the field; f is the
 *  caller's own (already strength+falloff scaled) force for this pull site —
 *  every well/hole/entity pairing keeps its own formula, this just centralizes
 *  the shared "divide by distance, scale by force, add to velocity" step. */
function applyPull(entity, dx, dy, d, f){
  entity.vx += (dx/d)*f;
  entity.vy += (dy/d)*f;
}

/** Draw fn once per screen-wrap copy so entities never pop at the edges. */
function wrapDraw(x,y,r,fn){
  fn(0,0);
  const ox = x < r ? W : (x > W-r ? -W : 0);
  const oy = y < r ? H : (y > H-r ? -H : 0);
  if(ox){ fn(ox,0); }
  if(oy){ fn(0,oy); }
  if(ox && oy){ fn(ox,oy); }
}

/* ---------------- color helpers ---------------- */
function hexA(hex,a){
  const h = hex.replace('#','');
  const n = parseInt(h.length===3? h.split('').map(c=>c+c).join('') : h, 16);
  return `rgba(${(n>>16)&255},${(n>>8)&255},${n&255},${a})`;
}
function shade(hex,amt){
  const h=hex.replace('#','');
  const n=parseInt(h.length===3? h.split('').map(c=>c+c).join(''):h,16);
  let r=(n>>16)&255,g=(n>>8)&255,b=n&255;
  if(amt<0){ r=Math.round(r*(1+amt)); g=Math.round(g*(1+amt)); b=Math.round(b*(1+amt)); }
  else { r=Math.round(r+(255-r)*amt); g=Math.round(g+(255-g)*amt); b=Math.round(b+(255-b)*amt); }
  return `rgb(${r},${g},${b})`;
}

/* ------------------------------------------------------------
   SPRITE + GRADIENT CACHES
   Building a CanvasGradient costs real time, and the naive version of
   this game built two per orb per frame (times up to 4 wrap copies).
   Radial glows are baked once into small offscreen canvases and blitted;
   body gradients are built in LOCAL space so they can be shared by every
   orb of the same kind and size.
   ------------------------------------------------------------ */
const _sprites = new Map();
function sprite(key, size, painter){
  let c = _sprites.get(key);
  if(c) return c;
  c = document.createElement('canvas');
  c.width = c.height = size;
  painter(c.getContext('2d'), size);
  _sprites.set(key, c);
  return c;
}
/** soft radial glow, baked small and scaled up at draw time */
function glowSprite(col, a0, a1){
  return sprite('glow|'+col+'|'+a0+'|'+a1, 128, (g,S)=>{
    const gr = g.createRadialGradient(S/2,S/2,0, S/2,S/2,S/2);
    gr.addColorStop(0, hexA(col,a0));
    gr.addColorStop(0.45, hexA(col,a1));
    gr.addColorStop(1, hexA(col,0));
    g.fillStyle = gr; g.fillRect(0,0,S,S);
  });
}
/** draw a cached sprite centred on (x,y) with the given radius */
function blit(spr, x, y, r){ ctx.drawImage(spr, x-r, y-r, r*2, r*2); }

const _grads = new Map();
function orbBodyGrad(kind, r){
  const key = kind+'|'+(Math.round(r/2)*2);
  let g = _grads.get(key);
  if(g) return g;
  const T = ORB_TYPES[kind];
  g = ctx.createRadialGradient(-r*0.34, -r*0.4, r*0.08, 0, 0, r*1.15);
  g.addColorStop(0, '#ffffff');
  g.addColorStop(0.18, T.c1);
  g.addColorStop(0.72, T.c2);
  g.addColorStop(1, shade(T.c2,-0.55));
  _grads.set(key, g);
  return g;
}
function sentryBodyGrad(r){
  const key = 'sentry|'+Math.round(r);
  let g = _grads.get(key);
  if(g) return g;
  g = ctx.createRadialGradient(-r*0.3,-r*0.3,2, 0,0, r);
  g.addColorStop(0,'#dfe4ff'); g.addColorStop(0.6,'#3b41a4'); g.addColorStop(1,'#171a4a');
  _grads.set(key,g);
  return g;
}
function bulletSprite(){
  return sprite('bullet', 64, (g,S)=>{
    const gr = g.createRadialGradient(S/2,S/2,0, S/2,S/2,S/2);
    gr.addColorStop(0,'rgba(255,255,255,1)');
    gr.addColorStop(0.25,'rgba(150,245,255,.9)');
    gr.addColorStop(1,'rgba(90,180,255,0)');
    g.fillStyle=gr; g.fillRect(0,0,S,S);
  });
}
function enemyShotSprite(){
  return sprite('eshot', 64, (g,S)=>{
    const gr = g.createRadialGradient(S/2,S/2,0, S/2,S/2,S/2);
    gr.addColorStop(0,'rgba(255,255,255,.95)');
    gr.addColorStop(0.3,'rgba(180,160,255,.85)');
    gr.addColorStop(1,'rgba(120,80,255,0)');
    g.fillStyle=gr; g.fillRect(0,0,S,S);
  });
}
let vignetteGrad = null, dangerGrad = null, shipHullGrad = null, shipFlameGrad = null;
function buildScreenGrads(){
  vignetteGrad = ctx.createRadialGradient(W/2,H/2,Math.min(W,H)*0.34, W/2,H/2, Math.max(W,H)*0.78);
  vignetteGrad.addColorStop(0,'rgba(0,0,0,0)');
  vignetteGrad.addColorStop(1,'rgba(0,0,0,.72)');
  dangerGrad = ctx.createRadialGradient(W/2,H/2,Math.min(W,H)*0.3, W/2,H/2, Math.max(W,H)*0.7);
  dangerGrad.addColorStop(0,'rgba(120,40,220,0)');
  dangerGrad.addColorStop(1,'rgba(120,40,220,1)');
}

/* ------------------------------------------------------------
   ADAPTIVE QUALITY — sheds particles rather than framerate
   ------------------------------------------------------------ */
let frameAvg = 16.7;
function partCap(){ return frameAvg > 32 ? 300 : frameAvg > 22 ? 550 : 1200; }

/* ============================================================
   SETTINGS (persisted)
   ============================================================ */
const prefersReduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const Store = {
  get(k,d){ try{ const v = localStorage.getItem('wildorbs_'+k); return v===null? d : JSON.parse(v); }catch(e){ return d; } },
  set(k,v){ try{ localStorage.setItem('wildorbs_'+k, JSON.stringify(v)); }catch(e){} }
};
const SET = {
  muted: Store.get('muted', false),
  shake: Store.get('shake', !prefersReduced)
};

/* ============================================================
   AUDIO — WebAudio synth, no assets
   ============================================================ */
const Snd = {
  ctx:null, master:null, ready:false, voices:0,
  init(){
    if(this.ready) return;
    try{
      const AC = window.AudioContext || window.webkitAudioContext;
      if(!AC) return;
      this.ctx = new AC();
      this.master = this.ctx.createGain();
      this.master.gain.value = SET.muted ? 0 : 0.30;
      const comp = this.ctx.createDynamicsCompressor();
      comp.threshold.value=-17; comp.knee.value=22; comp.ratio.value=11;
      comp.attack.value=.003; comp.release.value=.22;
      this.master.connect(comp); comp.connect(this.ctx.destination);
      this.ready = true;
    }catch(e){ /* audio is optional; game runs silent */ }
  },
  resume(){ if(this.ctx && this.ctx.state==='suspended') this.ctx.resume().catch(()=>{}); },
  applyMute(){ if(this.master) this.master.gain.value = SET.muted ? 0 : 0.30; Engine.set(Engine.level); },
  t(){ return this.ctx.currentTime; },
  live(){ return this.ready && !SET.muted; },
  /** cheap voice budget so a 20-orb chain reaction can't detonate the mixer */
  budget(){
    const now = performance.now();
    if(now - this._win > 100){ this._win = now; this.voices = 0; }
    return (this.voices++) < 26;
  },
  _win:0,
  env(v,at,dur,attack){
    const g = this.ctx.createGain();
    g.gain.setValueAtTime(0.0001, at);
    g.gain.exponentialRampToValueAtTime(Math.max(v,0.0002), at+(attack||0.008));
    g.gain.exponentialRampToValueAtTime(0.0001, at+dur);
    g.connect(this.master);
    return g;
  },
  tone(freq,dur,type,vol,at,slideTo,detune){
    if(!this.live() || !this.budget()) return;
    at = at===undefined? this.t() : at;
    const o = this.ctx.createOscillator();
    o.type = type||'sine';
    o.frequency.setValueAtTime(Math.max(freq,1), at);
    if(slideTo) o.frequency.exponentialRampToValueAtTime(Math.max(slideTo,1), at+dur);
    if(detune) o.detune.value = detune;
    const g = this.env(vol===undefined?.3:vol, at, dur);
    o.connect(g); o.start(at); o.stop(at+dur+0.05);
  },
  noise(dur,vol,filterType,f0,f1,at,q){
    if(!this.live() || !this.budget()) return;
    at = at===undefined? this.t() : at;
    const len = Math.max(1,Math.floor(this.ctx.sampleRate*Math.min(dur,2)));
    const buf = this.ctx.createBuffer(1,len,this.ctx.sampleRate);
    const d = buf.getChannelData(0);
    for(let i=0;i<len;i++) d[i] = (Math.random()*2-1) * (1 - i/len);
    const src = this.ctx.createBufferSource(); src.buffer = buf;
    const bq = this.ctx.createBiquadFilter();
    bq.type = filterType||'lowpass';
    bq.frequency.setValueAtTime(f0||1200, at);
    if(f1) bq.frequency.exponentialRampToValueAtTime(Math.max(f1,20), at+dur);
    bq.Q.value = q||1;
    const g = this.env(vol===undefined?.4:vol, at, dur);
    src.connect(bq); bq.connect(g); src.start(at); src.stop(at+dur+0.05);
  },
  chord(freqs,dur,type,vol){
    if(!this.live()) return;
    const at=this.t();
    freqs.forEach((f,i)=> this.tone(f,dur,type||'triangle',(vol||.16), at+i*0.012));
  },

  /* ---- named SFX ---- */
  shoot(){ if(!this.live())return; const at=this.t();
    this.tone(880,0.09,'square',.11,at,220);
    this.noise(0.05,.08,'highpass',1800,900,at); },
  hurt(){ if(!this.live())return; const at=this.t();
    this.tone(180,0.5,'sawtooth',.33,at,40);
    this.noise(0.45,.32,'lowpass',900,80,at); },
  shieldBreak(){ if(!this.live())return; const at=this.t();
    this.tone(1200,.3,'triangle',.22,at,300);
    this.noise(.3,.2,'bandpass',2600,700,at,7); },
  gameover(){ if(!this.live())return; const at=this.t();
    [440,330,262,175].forEach((f,i)=> this.tone(f,0.5,'triangle',.22, at+i*0.16, f*0.6));
    this.noise(1.4,.18,'lowpass',700,60,at); },
  wave(n){ if(!this.live())return; const at=this.t();
    [523.25,659.25,783.99,1046.5].forEach((f,i)=> this.tone(f*(1+((n-1)%4)*0.02),0.28,'triangle',.19, at+i*0.09)); },
  extraLife(){ if(!this.live())return; const at=this.t();
    [523,659,784,1047,1319].forEach((f,i)=> this.tone(f,0.22,'square',.13, at+i*0.06)); },
  powerup(){ if(!this.live())return; const at=this.t();
    [660,880,1180].forEach((f,i)=> this.tone(f,0.18,'triangle',.16, at+i*0.05, f*1.35));
    this.noise(.14,.08,'bandpass',2600,3400,at,8); },
  pulse(){ if(!this.live())return; const at=this.t();
    this.tone(90,1.0,'sine',.4,at,26);
    this.tone(420,.7,'sawtooth',.16,at,60);
    this.noise(.9,.36,'lowpass',5200,120,at); },
  hyper(){ if(!this.live())return; const at=this.t();
    this.tone(220,.28,'sine',.22,at,2600);
    this.noise(.26,.16,'bandpass',600,4200,at,6); },
  bounce(){ if(!this.live())return; const at=this.t();
    this.tone(rnd(900,520),.07,'triangle',.08,at, rnd(1500,900)); },
  pop(){ if(!this.live())return; const at=this.t();
    this.tone(rnd(1400,900),.06,'sine',.07,at,300); },
  turretShot(){ if(!this.live())return; const at=this.t();
    this.tone(300,.12,'square',.10,at,120);
    this.noise(.1,.08,'bandpass',1400,600,at,5); },

  orbHit(kind){
    if(!this.live()) return;
    const at = this.t();
    switch(kind){
      case 'swarm':
        for(let i=0;i<7;i++) this.tone(500+i*130, .07,'square',.08, at+i*0.026, 1400);
        this.noise(.22,.12,'bandpass',2600,900,at,6); break;
      case 'gravity':
        this.tone(300,.9,'sine',.28,at,38);
        this.tone(150,.9,'triangle',.18,at,24);
        this.noise(.8,.14,'lowpass',600,60,at); break;
      case 'phase':
        this.tone(1500,.16,'sine',.18,at,120);
        this.tone(300,.20,'sine',.14,at,2400);
        this.noise(.12,.10,'bandpass',3000,600,at,10); break;
      case 'blackhole':
        this.tone(70,1.6,'sine',.40,at,28);
        this.tone(105,1.4,'triangle',.16,at,44);
        this.noise(1.3,.20,'lowpass',400,40,at);
        this.tone(1800,.9,'sine',.06,at,220); break;
      case 'reverse':
        for(let i=0;i<9;i++) this.tone(i%2? 340:620, .09,'sawtooth',.12, at+i*0.05);
        break;
      case 'shard':
        this.noise(.5,.30,'highpass',5200,1400,at,2);
        for(let i=0;i<9;i++) this.tone(rnd(2600,1100),.16,'triangle',.08, at+i*0.018); break;
      case 'time':
        this.chord([392,523.25,659.25,880],1.1,'sine',.12);
        this.tone(1200,1.1,'sine',.08,at,300); break;
      case 'chain':
        this.tone(520,.22,'square',.22,at,90);
        this.noise(.3,.28,'lowpass',2600,180,at); break;
      case 'mirror':
        this.tone(740,.35,'triangle',.15,at,740*1.5);
        this.tone(740*1.005,.35,'triangle',.15,at+0.03,740*1.5);
        this.noise(.2,.09,'bandpass',2200,2600,at,8); break;
      case 'rubber':
        this.tone(200,.28,'sine',.28,at,760);
        this.tone(760,.20,'sine',.15,at+0.06,180); break;
      case 'nova':
        this.tone(120,1.0,'sawtooth',.32,at,30);
        this.noise(.9,.36,'lowpass',4000,120,at);
        this.chord([220,277,330,440],.7,'sawtooth',.09); break;
      case 'sentry':
        [740,560,930].forEach((f,i)=> this.tone(f,.12,'square',.13, at+i*0.07));
        this.noise(.3,.14,'bandpass',900,2200,at,9); break;
      case 'warp':
        this.tone(180,.34,'sine',.24,at,2200);
        this.tone(2400,.34,'sine',.14,at+0.05,160);
        this.noise(.3,.16,'bandpass',400,3800,at,7); break;
      default:
        this.tone(360,.2,'square',.18,at,120); this.noise(.24,.18,'lowpass',1500,200,at);
    }
  }
};

/* engine hum — continuous, gated on thrust */
const Engine = {
  src:null, sub:null, gain:null, filt:null, level:0,
  start(){
    if(!Snd.ready || this.src) return;
    const c = Snd.ctx;
    this.gain = c.createGain(); this.gain.gain.value = 0;
    this.filt = c.createBiquadFilter(); this.filt.type='lowpass'; this.filt.frequency.value=760; this.filt.Q.value=3;
    const len = Math.floor(c.sampleRate*2);
    const buf = c.createBuffer(1,len,c.sampleRate);
    const d = buf.getChannelData(0);
    for(let i=0;i<len;i++) d[i] = Math.random()*2-1;
    this.src = c.createBufferSource(); this.src.buffer=buf; this.src.loop=true;
    this.sub = c.createOscillator(); this.sub.type='sawtooth'; this.sub.frequency.value=62;
    const subG = c.createGain(); subG.gain.value=0.5;
    this.src.connect(this.filt); this.sub.connect(subG); subG.connect(this.filt);
    this.filt.connect(this.gain); this.gain.connect(Snd.master);
    try{ this.src.start(); this.sub.start(); }catch(e){}
  },
  set(v){
    this.level = v;
    if(!this.gain || !Snd.ready) return;
    const target = SET.muted ? 0 : v*0.19;
    const now = Snd.ctx.currentTime;
    this.gain.gain.setTargetAtTime(target, now, 0.06);
    this.filt.frequency.setTargetAtTime(500 + v*900, now, 0.08);
  }
};

/* ============================================================
   ORB TYPES — the wild bestiary
   ============================================================ */
const ORB_TYPES = {
  swarm: {
    name:'SWARM ORB', c1:'#ffe27a', c2:'#ff9d2e', glow:'#ffb454', shape:'hive',
    desc:'Bursts into homing hunters that chase your hull.', pts:110, hp:1
  },
  gravity: {
    name:'GRAVITY WELL', c1:'#8de7ff', c2:'#1f6bd8', glow:'#57c8ff', shape:'ring',
    desc:'Collapses into a well that drags your ship and bullets.', pts:140, hp:1
  },
  phase: {
    name:'PHASE ORB', c1:'#c9a6ff', c2:'#6a2fd0', glow:'#b48bff', shape:'glitch',
    desc:'Blinks elsewhere when hit. Takes three tags to kill.', pts:90, hp:3
  },
  blackhole: {
    name:'VOID ORB', c1:'#2a2140', c2:'#0a0714', glow:'#a24fff', shape:'void',
    desc:'Spawns a hungry black hole that eats everything near.', pts:220, hp:2
  },
  reverse: {
    name:'CHAOS ORB', c1:'#ff8ad4', c2:'#c31f8a', glow:'#ff62c0', shape:'spiral',
    desc:'Scrambles your steering for six seconds. Good luck.', pts:130, hp:1
  },
  shard: {
    name:'SHARD ORB', c1:'#e8fbff', c2:'#7fb6c9', glow:'#c9f4ff', shape:'crystal',
    desc:'Shatters into ricocheting glass that hurts on contact.', pts:120, hp:1
  },
  time: {
    name:'CHRONO ORB', c1:'#9dffc9', c2:'#129c6a', glow:'#66ffb0', shape:'clock',
    desc:'Drops a slow-time bubble. Everything inside crawls.', pts:150, hp:1
  },
  chain: {
    name:'CHAIN BOMB', c1:'#ff6a5e', c2:'#a3131f', glow:'#ff7f5c', shape:'bomb',
    desc:'Detonates and sets off every orb nearby. Chains hard.', pts:160, hp:1
  },
  mirror: {
    name:'MIRROR ORB', c1:'#f2f6ff', c2:'#8e9ac4', glow:'#dfe9ff', shape:'mirror',
    desc:'Clones itself twice. Kill the clones or drown in them.', pts:70, hp:1
  },
  rubber: {
    name:'RUBBER ORB', c1:'#c3ff5e', c2:'#4d8a11', glow:'#a8ff45', shape:'rubber',
    desc:'Never wraps — bounces off walls faster on every hit.', pts:100, hp:5
  },
  nova: {
    name:'NOVA ORB', c1:'#fff3c4', c2:'#ff7a18', glow:'#ffb03a', shape:'star',
    desc:'Erupts a shockwave ring that shatters whatever it crosses.', pts:180, hp:2
  },
  sentry: {
    name:'SENTINEL ORB', c1:'#c3ccff', c2:'#2c318f', glow:'#7a86ff', shape:'eye',
    desc:'Deploys turrets that track you and shoot back.', pts:170, hp:2
  },
  warp: {
    name:'WARP ORB', c1:'#c9ffee', c2:'#00727a', glow:'#2fe8d5', shape:'portal',
    desc:'Rips space open and swaps your ship into its place.', pts:120, hp:1
  }
};
const ORB_KEYS = Object.keys(ORB_TYPES);

/* ============================================================
   POWERUPS
   ============================================================ */
const POWERS = {
  spread: { name:'SPREAD',  col:'#7df9ff', glyph:'W', dur:900 },
  rapid:  { name:'RAPID',   col:'#ffd76b', glyph:'R', dur:900 },
  shield: { name:'SHIELD',  col:'#8effc0', glyph:'S' },
  pulse:  { name:'PULSE +1',col:'#ff8ad4', glyph:'P' },
  life:   { name:'1UP',     col:'#ff6a5e', glyph:'1' }
};
const POWER_BAG = ['spread','spread','rapid','rapid','shield','shield','pulse','pulse','life'];

/* ============================================================
   STATE
   ============================================================ */
let orbs=[], bullets=[], parts=[], hunters=[], shards=[], wells=[], holes=[],
    bubbles=[], rings=[], floats=[], waves=[], sentries=[], enemyShots=[],
    powerups=[], starsDim=[], starsTwinkle=[];


const G = {
  state:'start',                 // start | play | pause | over
  score:0, wave:1, lives:3,
  best: Store.get('best',0), bestWave: Store.get('bestWave',1),
  shake:0, shakeX:0, shakeY:0,
  time:0, flash:0, flashCol:'#fff',
  reverseT:0, reverseMax:360, spreadT:0, rapidT:0,
  pulses:1, hyperCool:0,
  waveBannerT:0, waveBannerTxt:'', waveBannerSub:'',
  spawnGuard:0, nextLife:5000,
  hitStop:0, combo:0, comboT:0,
  respawnT:0,
  // run stats
  orbsBroken:0, bestCombo:0, shots:0, hits:0, frames:0
};

/* ---------------- starfield / background ---------------- */
function buildStars(){
  starsDim = []; starsTwinkle = [];
  const n = clamp(Math.floor((W*H)/5200), 40, 460);
  for(let i=0;i<n;i++){
    const s = { x:rnd(W), y:rnd(H), z:rnd(1,.15), s:rnd(1.7,.4), tw:rnd(TAU), tws:rnd(3,.5) };
    if(i % 3 === 0 && starsTwinkle.length < 150) starsTwinkle.push(s); else starsDim.push(s);
  }
}
function paintBackground(){
  const g = bgx.createLinearGradient(0,0,0,H);
  g.addColorStop(0,'#080618'); g.addColorStop(0.55,'#0a0720'); g.addColorStop(1,'#050410');
  bgx.fillStyle=g; bgx.fillRect(0,0,W,H);

  bgx.save();
  bgx.globalCompositeOperation='lighter';
  const n1 = bgx.createRadialGradient(W*0.22,H*0.28,10,W*0.22,H*0.28,Math.max(W,H)*0.55);
  n1.addColorStop(0,'rgba(70,30,140,.22)'); n1.addColorStop(1,'rgba(70,30,140,0)');
  bgx.fillStyle=n1; bgx.fillRect(0,0,W,H);
  const n2 = bgx.createRadialGradient(W*0.78,H*0.74,10,W*0.78,H*0.74,Math.max(W,H)*0.5);
  n2.addColorStop(0,'rgba(10,80,140,.18)'); n2.addColorStop(1,'rgba(10,80,140,0)');
  bgx.fillStyle=n2; bgx.fillRect(0,0,W,H);
  const n3 = bgx.createRadialGradient(W*0.5,H*0.05,10,W*0.5,H*0.05,Math.max(W,H)*0.4);
  n3.addColorStop(0,'rgba(120,20,90,.10)'); n3.addColorStop(1,'rgba(120,20,90,0)');
  bgx.fillStyle=n3; bgx.fillRect(0,0,W,H);
  bgx.restore();

  for(const s of starsDim){
    bgx.globalAlpha = 0.22 + 0.5*s.z;
    bgx.fillStyle = s.z>0.7? '#dfeaff' : (s.z>0.4? '#9fb4e8' : '#6b7bb0');
    bgx.fillRect(s.x, s.y, s.s, s.s);
  }
  bgx.globalAlpha = 1;
}

/* ============================================================
   SHIP
   ============================================================ */
const ship = {
  x:0,y:0,vx:0,vy:0,a:-Math.PI/2, r:13,
  thrust:0, cool:0, inv:0, alive:true, shield:0,
  trail:[], recoil:0
};
function resetShip(){
  ship.x = W/2; ship.y = H/2; ship.vx=0; ship.vy=0; ship.a=-Math.PI/2;
  ship.inv = 140; ship.alive=true; ship.trail.length=0; ship.cool=0;
  G.reverseT = 0;
}

/* ============================================================
   INPUT — keyboard + gamepad + touch
   ============================================================ */
const keys = Object.create(null);
const CODES = {
  left:['ArrowLeft','KeyA'], right:['ArrowRight','KeyD'],
  up:['ArrowUp','KeyW'], down:['ArrowDown','KeyS'], fire:['Space']
};
function keyDown(list){ for(const k of list) if(keys[k]) return true; return false; }

const touch = { on:false, stickId:-1, ox:0, oy:0, cx:0, cy:0, fireId:-1, btn:null, lastTapRight:0 };

function primeAudio(){ Snd.init(); Snd.resume(); Engine.start(); }

window.addEventListener('keydown', e=>{
  if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Space'].includes(e.code)) e.preventDefault();
  keys[e.code]=true;
  primeAudio();

  if(e.repeat) return;

  if(e.code==='KeyM'){ toggleSound(); }
  if(e.code==='KeyK'){ toggleShake(); }
  if(e.code==='KeyP' || e.code==='Escape'){
    if(G.state==='play'||G.state==='pause') togglePause();
  }
  if(G.state==='play'){
    if(e.code==='ShiftLeft'||e.code==='ShiftRight') firePulse();
    if(e.code==='KeyH') hyperspace();
  }
  if(e.code==='Space'||e.code==='Enter'||e.code==='NumpadEnter'){
    if(G.state==='start' || G.state==='over') startGame();
  }
}, {passive:false});
window.addEventListener('keyup', e=>{ keys[e.code]=false; });
window.addEventListener('blur', ()=>{ for(const k in keys) keys[k]=false; if(G.state==='play') togglePause(); });
document.addEventListener('visibilitychange', ()=>{
  if(document.hidden && G.state==='play') togglePause();
});

/* --- touch --- */
function touchButtons(){
  const s = clamp(Math.min(W,H)*0.085, 44, 74);
  const pad = s*0.55;
  return [
    { id:'pulse', x: W - pad - s*0.5,      y: H - pad - s*0.5, r: s*0.5, label:'PULSE' },
    { id:'warp',  x: W - pad - s*1.75,     y: H - pad - s*0.5, r: s*0.5, label:'WARP'  }
  ];
}
function hitButton(x,y){
  for(const b of touchButtons()) if(Math.hypot(x-b.x,y-b.y) < b.r*1.25) return b;
  return null;
}
cv.addEventListener('pointerdown', e=>{
  primeAudio();
  if(e.pointerType==='mouse') return;          // virtual stick is touch/pen only
  touch.on = true;
  const x = e.clientX, y = e.clientY;
  if(G.state !== 'play') return;

  const b = hitButton(x,y);
  if(b){
    touch.btn = b.id;
    if(b.id==='pulse') firePulse(); else hyperspace();
    return;
  }
  if(x < W*0.5 && touch.stickId===-1){
    touch.stickId = e.pointerId; touch.ox=x; touch.oy=y; touch.cx=x; touch.cy=y;
  } else if(touch.fireId===-1){
    touch.fireId = e.pointerId;
  }
  // Capture last and defensively: it throws NotFoundError when the pointer is
  // already gone (fast taps, browser gesture cancellation). Thrown from the top
  // of this handler it silently swallowed the input that followed it.
  try { cv.setPointerCapture && cv.setPointerCapture(e.pointerId); } catch(err){}
}, {passive:true});
cv.addEventListener('pointermove', e=>{
  if(e.pointerId===touch.stickId){ touch.cx=e.clientX; touch.cy=e.clientY; }
}, {passive:true});
function endPointer(e){
  if(e.pointerId===touch.stickId) touch.stickId=-1;
  if(e.pointerId===touch.fireId)  touch.fireId=-1;
  touch.btn = null;
}
cv.addEventListener('pointerup', endPointer, {passive:true});
cv.addEventListener('pointercancel', endPointer, {passive:true});
cv.addEventListener('contextmenu', e=> e.preventDefault());

/* --- gamepad --- */
let padPrev = {};
function readGamepad(inp){
  if(!navigator.getGamepads) return;
  const pads = navigator.getGamepads();
  for(const p of pads){
    if(!p) continue;
    const ax = p.axes[0]||0, ay = p.axes[1]||0;
    if(ax < -0.28) inp.left = true;
    if(ax >  0.28) inp.right = true;
    const b = p.buttons;
    const on = i => b[i] && (b[i].pressed || b[i].value>0.4);
    if(on(0) || on(7) || ay < -0.5) inp.up = true;
    if(on(2) || on(6)) inp.fire = true;
    if(on(1) && !padPrev.hyper) hyperspace();
    if(on(3) && !padPrev.pulse) firePulse();
    if(on(9) && !padPrev.start) togglePause();
    padPrev = { hyper:on(1), pulse:on(3), start:on(9) };
    return;
  }
}

function readInput(){
  const inp = { left:false, right:false, up:false, down:false, fire:false, aim:null };
  if(keyDown(CODES.left))  inp.left = true;
  if(keyDown(CODES.right)) inp.right = true;
  if(keyDown(CODES.up))    inp.up = true;
  if(keyDown(CODES.down))  inp.down = true;
  if(keyDown(CODES.fire))  inp.fire = true;
  readGamepad(inp);
  if(touch.stickId!==-1){
    const dx = touch.cx-touch.ox, dy = touch.cy-touch.oy;
    const m = Math.hypot(dx,dy);
    if(m > 10){ inp.aim = Math.atan2(dy,dx); if(m > 26) inp.up = true; }
  }
  if(touch.fireId!==-1) inp.fire = true;
  return inp;
}

/* ============================================================
   PARTICLES / FX
   ============================================================ */
function spark(x,y,col,opt){
  if(parts.length > partCap()) return;
  opt = opt || {};
  const life = opt.life || rnd(48,18);
  parts.push({
    x,y,
    vx: opt.vx!==undefined? opt.vx : rnd(2.4,-2.4),
    vy: opt.vy!==undefined? opt.vy : rnd(2.4,-2.4),
    life, max:life, r: opt.r || rnd(3.2,.9),
    col, drag: opt.drag===undefined? 0.975 : opt.drag, grav: opt.grav||0
  });
}
function burst(x,y,n,col,spd,opt){
  opt = opt || {};
  for(let i=0;i<n;i++){
    const a = rnd(TAU), s = rnd(spd, spd*0.15);
    spark(x,y,col,Object.assign({}, opt, { vx:Math.cos(a)*s, vy:Math.sin(a)*s }));
  }
}
function ringFx(x,y,col,r0,r1,life,width){
  rings.push({x,y,col,r:r0,r1,cur:r0,life,max:life,w:width||3});
}
function floatText(x,y,txt,col,size){
  // during a big cascade the same label fires dozens of times — collapse the
  // duplicates instead of painting an unreadable stack
  for(let i=floats.length-1;i>=0;i--){
    const f=floats[i];
    if(f.txt===txt && f.life>44 && Math.hypot(f.x-x,f.y-y)<110) return;
  }
  if(floats.length>14) floats.shift();
  floats.push({x,y,txt,col,life:70,max:70,size:size||16,vy:-0.7});
}
function shake(v){ if(!SET.shake) return; G.shake = Math.min(40, G.shake + v); }
function flashScreen(col,amt){ G.flash = Math.max(G.flash, SET.shake? amt : amt*0.45); G.flashCol = col; }
function hitStop(f){ if(SET.shake) G.hitStop = Math.max(G.hitStop, f); }

let toastTxt='', toastT=0;
function toast(t){ toastTxt=t; toastT=95; }

/* ============================================================
   SPAWNING
   ============================================================ */
function orbSizeFor(tier){ return tier===4? 74 : tier===3? 52 : tier===2? 34 : 21; }

function waveKinds(){
  const w = G.wave;
  const pool = ['swarm','shard','mirror','rubber'];
  if(w>=2) pool.push('phase','chain');
  if(w>=3) pool.push('gravity','time');
  if(w>=4) pool.push('reverse','nova');
  if(w>=5) pool.push('blackhole','warp');
  if(w>=6) pool.push('sentry');
  if(w>=7) return ORB_KEYS.slice();
  return pool;
}

function makeOrb(x,y,tier,kind,vx,vy){
  kind = kind || pick(waveKinds());
  const T = ORB_TYPES[kind];
  const r = orbSizeFor(tier);
  const tierSpd = tier===4?0.62 : tier===3?0.85 : tier===2?1.05 : 1.35;
  const spd = lerp(0.5,1.7,Math.random()) * (1 + (G.wave-1)*0.055) * tierSpd;
  const a = rnd(TAU);
  const hpMul = tier===4?3 : tier===3?1 : tier===2?0.8 : 0.6;
  const o = {
    kind, tier, r, x, y,
    vx: vx!==undefined? vx : Math.cos(a)*spd,
    vy: vy!==undefined? vy : Math.sin(a)*spd,
    hp: tier===4 ? Math.max(5, Math.round(T.hp*hpMul)+2) : Math.max(1, Math.round(T.hp*hpMul)),
    maxhp:0, elder: tier===4,
    a: rnd(TAU), spin: rnd(0.03,-0.03),
    pulse: rnd(TAU), t:0, wobble: rnd(TAU),
    hitFlash:0, bounces:0, clones:0, chainT:-1,
    verts: []
  };
  o.maxhp = o.hp;
  const nv = 11 + tier*2;
  for(let i=0;i<nv;i++) o.verts.push(rnd(1.14,0.84));
  return o;
}

function edgeSpawn(minDistFromShip){
  let x,y,guard=0;
  do{
    const edge = rndi(0,4);
    if(edge===0){ x=rnd(W); y=-30; }
    else if(edge===1){ x=W+30; y=rnd(H); }
    else if(edge===2){ x=rnd(W); y=H+30; }
    else { x=-30; y=rnd(H); }
    guard++;
  } while(guard<14 && Math.hypot(x-ship.x, y-ship.y) < (minDistFromShip||200));
  return [x,y];
}

function spawnWave(){
  const n = Math.min(12, 3 + Math.floor(G.wave*0.9));
  for(let i=0;i<n;i++){
    const [x,y] = edgeSpawn(200);
    orbs.push(makeOrb(x,y,3));
  }
  const elder = G.wave % 5 === 0;
  if(elder){
    const [ex,ey] = edgeSpawn(300);
    const o = makeOrb(ex,ey,4);
    // rubber elders would be pinned to the bounds on spawn — keep them wrapping-friendly
    if(o.kind==='rubber'){ o.x = clamp(o.x, o.r+4, W-o.r-4); o.y = clamp(o.y, o.r+4, H-o.r-4); }
    orbs.push(o);
  }
  G.waveBannerT = 140;
  G.waveBannerTxt = 'WAVE ' + G.wave;
  G.waveBannerSub = elder ? 'AN ELDER ORB IS AWAKE' : '';
  Snd.wave(G.wave);
}

/* ============================================================
   SCORE
   ============================================================ */
function addScore(n, x, y){
  const mult = 1 + Math.min(10,G.combo)*0.1;
  const gained = Math.round(n*mult);
  G.score += gained;
  if(x!==undefined && n>=60) floatText(x, y-10, '+'+gained, '#ffffff', 15);
  while(G.score >= G.nextLife){
    G.lives++; G.nextLife += 6000;
    Snd.extraLife(); toast('EXTRA LIFE');
    flashScreen('#8effc0',.2);
  }
}
function bumpCombo(){
  G.combo++; G.comboT = 75;
  if(G.combo > G.bestCombo) G.bestCombo = G.combo;
}

/* ============================================================
   ORB DESTRUCTION — the wild behaviours
   ============================================================ */
function splitOrb(o){
  if(o.tier<=1 || orbs.length>70) return;
  for(let i=0;i<2;i++){
    const a = rnd(TAU);
    const s = rnd(2.1,0.8);
    const kind = Math.random()<0.35 ? pick(waveKinds()) : o.kind;
    const child = makeOrb(
      o.x + Math.cos(a)*o.r*0.5, o.y + Math.sin(a)*o.r*0.5, o.tier-1, kind,
      o.vx*0.5 + Math.cos(a)*s, o.vy*0.5 + Math.sin(a)*s);
    if(child.kind==='rubber'){ child.x = clamp(child.x, child.r, W-child.r); child.y = clamp(child.y, child.r, H-child.r); }
    orbs.push(child);
  }
}

function dropPowerup(x,y,forced){
  if(!forced && Math.random() > 0.11) return;
  const kind = forced ? pick(['spread','rapid','shield','pulse']) : pick(POWER_BAG);
  powerups.push({
    x, y, vx:rnd(.7,-.7), vy:rnd(.7,-.7),
    kind, r:14, life:780, ph:rnd(TAU), a:0
  });
}

function spawnShockwave(x,y,rMax,dmg,speed,col){
  waves.push({ x, y, r:8, rMax, dmg, speed, col:col||'#ffd9a0', band:22, hit:new Set() });
}

function destroyOrb(o, silentCombo){
  const idx = orbs.indexOf(o);
  if(idx<0) return;
  orbs.splice(idx,1);

  const T = ORB_TYPES[o.kind];
  const M = o.elder ? 2 : 1;                       // elder orbs do everything twice as hard
  const tierPts = o.tier===4?3 : o.tier===3?1 : o.tier===2?1.4 : 2.0;
  addScore(Math.round(T.pts * tierPts), o.x, o.y);
  G.orbsBroken++;

  Snd.orbHit(o.kind);
  ringFx(o.x,o.y,T.glow, o.r*0.5, o.r*3.4, 34, 3);
  burst(o.x,o.y, 14 + o.tier*8, T.c1, 2.6+o.tier*0.5, {life:rnd(56,24)});
  burst(o.x,o.y, 8 + o.tier*5, T.c2, 4.2+o.tier, {life:rnd(40,16), r:rnd(2.4,.8)});
  shake(3 + o.tier*2.2);

  if(o.elder){
    flashScreen('#ffffff',.28); shake(18); hitStop(5);
    floatText(o.x, o.y-o.r-18, 'ELDER DOWN', '#fff2b0', 22);
    spawnShockwave(o.x,o.y, 320, 1, 9, T.glow);
    dropPowerup(o.x,o.y,true);
  } else {
    dropPowerup(o.x,o.y,false);
  }

  let didSplit = false;

  switch(o.kind){
    /* 1. SWARM — homing hunters */
    case 'swarm': {
      const n = (3 + o.tier*2) * M;
      for(let i=0;i<n;i++){
        const a = (i/n)*TAU + rnd(.5);
        hunters.push({
          x:o.x+Math.cos(a)*o.r*0.6, y:o.y+Math.sin(a)*o.r*0.6,
          vx:Math.cos(a)*2.4, vy:Math.sin(a)*2.4,
          r:6, life:460, max:460, a, wig:rnd(TAU), spd: 0.095 + G.wave*0.0035
        });
      }
      floatText(o.x,o.y,'SWARM!','#ffcb5e',18);
      flashScreen('#ffb454',.16);
      break;
    }
    /* 2. GRAVITY WELL */
    case 'gravity': {
      wells.push({ x:o.x, y:o.y, r:(210 + o.tier*40)*(o.elder?1.4:1), str:(0.55 + o.tier*0.18)*M, life:300, max:300, a:0 });
      floatText(o.x,o.y,'GRAVITY WELL','#57c8ff',17);
      ringFx(o.x,o.y,'#8de7ff', 10, 260, 60, 5);
      flashScreen('#57c8ff',.18);
      break;
    }
    /* 3. PHASE — the blinks happen on hit; death is a violet implosion */
    case 'phase': {
      for(let i=0;i<3;i++){
        const a=rnd(TAU);
        ringFx(o.x+Math.cos(a)*30,o.y+Math.sin(a)*30,'#c9a6ff',4,90,30,2);
      }
      floatText(o.x,o.y,'PHASE OUT','#b48bff',16);
      break;
    }
    /* 4. BLACK HOLE */
    case 'blackhole': {
      holes.push({ x:o.x, y:o.y, r:26, rMax:(34 + o.tier*8), pull:(400 + o.tier*55)*(o.elder?1.3:1),
                   life:360, max:360, a:0, eaten:0 });
      floatText(o.x,o.y,'VOID OPENED','#a24fff',19);
      flashScreen('#7a2fff',.30); shake(16);
      ringFx(o.x,o.y,'#a24fff', 10, 340, 70, 6);
      break;
    }
    /* 5. REVERSE CONTROLS */
    case 'reverse': {
      G.reverseT = Math.max(G.reverseT, 360*M);
      G.reverseMax = Math.max(G.reverseMax||1, G.reverseT);
      floatText(ship.x, ship.y-40,'CONTROLS REVERSED!','#ff62c0',20);
      flashScreen('#ff62c0',.30); shake(11);
      for(let i=0;i<3;i++) ringFx(o.x,o.y,'#ff8ad4', 8+i*20, 200+i*40, 44, 3);
      break;
    }
    /* 6. SHARDS — ricocheting glass */
    case 'shard': {
      const n = (5 + o.tier*3) * M;
      for(let i=0;i<n;i++){
        const a=(i/n)*TAU+rnd(.4);
        const s=rnd(5.4,2.6);
        shards.push({ x:clamp(o.x,6,W-6)+Math.cos(a)*o.r*0.4, y:clamp(o.y,6,H-6)+Math.sin(a)*o.r*0.4,
          vx:Math.cos(a)*s, vy:Math.sin(a)*s, r:5, life:420, max:420,
          rot:rnd(TAU), rs:rnd(.28,-.28), bounce:0 });
      }
      floatText(o.x,o.y,'SHATTER','#c9f4ff',17);
      break;
    }
    /* 7. TIME BUBBLE */
    case 'time': {
      bubbles.push({ x:o.x, y:o.y, r:(150 + o.tier*45)*(o.elder?1.35:1), life:380, max:380, ph:0 });
      floatText(o.x,o.y,'SLOW FIELD','#66ffb0',17);
      ringFx(o.x,o.y,'#9dffc9', 8, 220, 60, 4);
      flashScreen('#66ffb0',.14);
      break;
    }
    /* 8. CHAIN BOMB */
    case 'chain': {
      const R = (190 + o.tier*45) * M;
      ringFx(o.x,o.y,'#ff7f5c', 8, R, 30, 7);
      burst(o.x,o.y,30,'#ffd08a',7,{life:rnd(50,20)});
      shake(12); flashScreen('#ff7f5c',.24);
      floatText(o.x,o.y,'CHAIN!','#ff8f6b',20);
      let d=0;
      for(const t of orbs){
        if(t.chainT>=0) continue;
        if(wrapDist(o,t) < R + t.r) t.chainT = 6 + (d++)*3;   // staged for a proper cascade
      }
      break;
    }
    /* 9. MIRROR CLONE */
    case 'mirror': {
      if(o.clones < 2 && orbs.length < 46){
        for(let i=0;i<2;i++){
          const a = o.a + (i? 2.4 : -2.4);
          const c = makeOrb(o.x + Math.cos(a)*(o.r+16), o.y + Math.sin(a)*(o.r+16),
                            Math.max(1,Math.min(3,o.tier)), 'mirror',
                            Math.cos(a)*rnd(2.4,1.2), Math.sin(a)*rnd(2.4,1.2));
          c.clones = o.clones+1;
          c.r = o.r*0.86; c.hp = 1; c.maxhp = 1;
          orbs.push(c);
          ringFx(c.x,c.y,'#dfe9ff',4,70,26,2);
        }
        floatText(o.x,o.y,'CLONED x2','#dfe9ff',17);
      } else {
        floatText(o.x,o.y,'MIRROR BROKE','#dfe9ff',15);
        splitOrb(o); didSplit = true;
      }
      break;
    }
    /* 10. RUBBER — confetti pop */
    case 'rubber': {
      burst(o.x,o.y,26,'#c3ff5e',6.5,{life:rnd(60,24)});
      floatText(o.x,o.y,'POP!','#a8ff45',20);
      break;
    }
    /* 11. NOVA — expanding shockwave that shatters what it crosses */
    case 'nova': {
      // dmg 2 so it genuinely shatters what it crosses — and so novas chain
      // into each other. Bounded: a wave damages each orb at most once.
      spawnShockwave(o.x,o.y, (240 + o.tier*70)*M, 2, 7.5, '#ffb03a');
      burst(o.x,o.y,34,'#fff3c4',8,{life:rnd(56,22)});
      flashScreen('#ffb03a',.30); shake(15); hitStop(3);
      floatText(o.x,o.y,'NOVA!','#ffd88a',22);
      break;
    }
    /* 12. SENTINEL — deploys turrets */
    case 'sentry': {
      const n = (o.tier>=3?2:1) * M;
      for(let i=0;i<n;i++){
        const a = (i/n)*TAU + rnd(.6);
        sentries.push({
          x:o.x+Math.cos(a)*o.r*0.7, y:o.y+Math.sin(a)*o.r*0.7,
          vx:Math.cos(a)*0.5, vy:Math.sin(a)*0.5,
          r:15, a:0, hp:2, cool: 70 + i*26, life: 660, max:660, spin:rnd(.04,-.04)
        });
      }
      floatText(o.x,o.y,'TURRETS DEPLOYED','#8f9bff',17);
      flashScreen('#7a86ff',.18); shake(7);
      break;
    }
    /* 13. WARP — swaps your ship into the orb's place */
    case 'warp': {
      if(ship.alive){
        const sx = ship.x, sy = ship.y;
        for(let i=0;i<3;i++) ringFx(sx,sy,'#2fe8d5', 6+i*14, 150+i*30, 34, 3);
        ship.x = o.x; ship.y = o.y;
        ship.inv = Math.max(ship.inv, 70);         // never warp you straight into a death
        ship.vx *= 0.4; ship.vy *= 0.4;
        burst(ship.x,ship.y,26,'#c9ffee',5,{life:rnd(48,20)});
        ringFx(ship.x,ship.y,'#2fe8d5',150,6,32,4);
        floatText(ship.x, ship.y-40,'WARPED!','#2fe8d5',20);
        flashScreen('#2fe8d5',.26); shake(10);
      }
      break;
    }
  }

  // generic split for everything that didn't grow its own descendants
  if(!didSplit && o.kind!=='mirror') splitOrb(o);

  if(!silentCombo) bumpCombo();
}

function hitOrb(o, bx, by, dmg){
  dmg = dmg || 1;
  const T = ORB_TYPES[o.kind];
  o.hitFlash = 12;

  // PHASE — teleports on a non-lethal hit
  if(o.kind==='phase' && o.hp > dmg){
    o.hp -= dmg;
    Snd.orbHit('phase');
    burst(o.x,o.y,16,'#c9a6ff',3.4,{life:34});
    ringFx(o.x,o.y,'#b48bff',4,120,26,3);
    let nx,ny,g=0;
    do{ nx = rnd(W*0.9, W*0.1); ny = rnd(H*0.9, H*0.1); g++; }
    while(g<24 && Math.hypot(nx-ship.x,ny-ship.y) < 170);
    o.x = nx; o.y = ny;
    o.vx = rnd(2.4,-2.4); o.vy = rnd(2.4,-2.4);
    burst(o.x,o.y,18,'#e0ccff',3.8,{life:38});
    ringFx(o.x,o.y,'#c9a6ff',110,4,26,3);
    floatText(o.x,o.y-o.r-8,'BLINK','#c9a6ff',14);
    addScore(20,o.x,o.y);
    return false;
  }

  // RUBBER — accelerates on every hit
  if(o.kind==='rubber' && o.hp > dmg){
    o.hp -= dmg;
    o.vx *= 1.5; o.vy *= 1.5;
    o.vx += rnd(.9,-.9); o.vy += rnd(.9,-.9);
    const sp = Math.hypot(o.vx,o.vy);
    if(sp < 1.4){ const a=rnd(TAU); o.vx=Math.cos(a)*1.8; o.vy=Math.sin(a)*1.8; }
    o.bounces++;
    Snd.orbHit('rubber');
    burst(o.x,o.y,10,'#c3ff5e',3.2,{life:26});
    ringFx(o.x,o.y,'#a8ff45',o.r*0.4,o.r*2.2,20,3);
    floatText(o.x,o.y-o.r-6,'FASTER!','#c3ff5e',14);
    addScore(15,o.x,o.y);
    shake(2.5);
    return false;
  }

  o.hp -= dmg;
  if(o.hp>0){
    Snd.pop();
    burst(bx===undefined?o.x:bx, by===undefined?o.y:by, 7, T.c1, 2.6, {life:22});
    addScore(10,o.x,o.y);
    return false;
  }
  destroyOrb(o);
  return true;
}

/* ============================================================
   SHIP ACTIONS
   ============================================================ */
function fire(){
  if(ship.cool>0 || !ship.alive || G.state!=='play') return;
  const angles = G.spreadT>0 ? [-0.15, 0, 0.15] : [0];
  for(const off of angles){
    const a = ship.a + off;
    const bx = ship.x + Math.cos(a)*(ship.r+4);
    const by = ship.y + Math.sin(a)*(ship.r+4);
    bullets.push({
      x:bx, y:by,
      vx: Math.cos(a)*8.4 + ship.vx*0.45,
      vy: Math.sin(a)*8.4 + ship.vy*0.45,
      life: 74, r:3.1, trail:[]
    });
    G.shots++;
    burst(bx,by,3,'#9ff6ff',2.4,{life:14,r:1.6});
  }
  ship.cool = G.rapidT>0 ? 5 : 9;
  ship.recoil = 4;
  ship.vx -= Math.cos(ship.a)*0.22; ship.vy -= Math.sin(ship.a)*0.22;
  Snd.shoot();
}

function firePulse(){
  if(G.state!=='play' || !ship.alive || G.pulses<=0) return;
  G.pulses--;
  spawnShockwave(ship.x, ship.y, Math.max(W,H)*0.62, 3, 13, '#ff8ad4');
  burst(ship.x,ship.y,50,'#ffd6f5',9,{life:rnd(60,26)});
  ringFx(ship.x,ship.y,'#ff8ad4',10,Math.max(W,H)*0.6,44,7);
  flashScreen('#ff8ad4',.4); shake(24); hitStop(6);
  floatText(ship.x, ship.y-46,'PULSE','#ff8ad4',24);
  Snd.pulse();
}

function hyperspace(){
  if(G.state!=='play' || !ship.alive || G.hyperCool>0) return;
  G.hyperCool = 260;
  burst(ship.x,ship.y,24,'#b9e8ff',5,{life:34});
  ringFx(ship.x,ship.y,'#8ef7ff',6,120,26,3);
  let nx=W/2, ny=H/2, best=-1;
  for(let i=0;i<26;i++){
    const cx=rnd(W*0.92,W*0.08), cy=rnd(H*0.92,H*0.08);
    let near=1e9;
    for(const o of orbs) near = Math.min(near, Math.hypot(cx-o.x,cy-o.y)-o.r);
    for(const h of holes) near = Math.min(near, Math.hypot(cx-h.x,cy-h.y)-h.pull*0.5);
    if(near>best){ best=near; nx=cx; ny=cy; }
  }
  ship.x=nx; ship.y=ny; ship.vx*=0.25; ship.vy*=0.25;
  ship.inv = Math.max(ship.inv, 60);
  ship.trail.length = 0;
  burst(ship.x,ship.y,24,'#dff4ff',5,{life:34});
  ringFx(ship.x,ship.y,'#8ef7ff',120,6,26,3);
  Snd.hyper();
}

function grantPower(kind,x,y){
  const P = POWERS[kind];
  switch(kind){
    case 'spread': G.spreadT = Math.min(1800, G.spreadT + P.dur); break;
    case 'rapid':  G.rapidT  = Math.min(1800, G.rapidT  + P.dur); break;
    case 'shield': ship.shield = 1; break;
    case 'pulse':  G.pulses = Math.min(3, G.pulses+1); break;
    case 'life':   G.lives++; break;
  }
  Snd.powerup();
  floatText(x, y-16, P.name, P.col, 18);
  ringFx(x,y,P.col,6,80,26,3);
  burst(x,y,16,P.col,3.4,{life:32});
  flashScreen(P.col,.14);
}

function killShip(){
  if(G.state!=='play' || ship.inv>0 || !ship.alive) return;

  if(ship.shield>0){
    ship.shield = 0;
    ship.inv = 100;
    Snd.shieldBreak();
    ringFx(ship.x,ship.y,'#8effc0',ship.r+8,150,32,4);
    burst(ship.x,ship.y,26,'#8effc0',5,{life:38});
    floatText(ship.x,ship.y-38,'SHIELD DOWN','#8effc0',18);
    flashScreen('#8effc0',.26); shake(12);
    return;
  }

  ship.alive=false;
  G.lives--;
  G.respawnT = 58;
  Snd.hurt();
  burst(ship.x,ship.y,60,'#ffd07a',6.5,{life:rnd(80,30)});
  burst(ship.x,ship.y,34,'#ff5a3c',9,{life:rnd(60,24)});
  burst(ship.x,ship.y,18,'#ffffff',3.5,{life:rnd(44,18),r:rnd(4,2)});
  ringFx(ship.x,ship.y,'#ffae5c',6,200,44,5);
  shake(26); flashScreen('#ff8a4c',.42); hitStop(7);
  G.combo=0; G.spreadT=0; G.rapidT=0;
  Engine.set(0);
}

/* ============================================================
   GAME FLOW
   ============================================================ */
const startScreen = document.getElementById('startScreen');
const overScreen  = document.getElementById('overScreen');
const pauseScreen = document.getElementById('pauseScreen');

function clearWorld(){
  orbs.length=0; bullets.length=0; parts.length=0; hunters.length=0; shards.length=0;
  wells.length=0; holes.length=0; bubbles.length=0; rings.length=0; floats.length=0;
  waves.length=0; sentries.length=0; enemyShots.length=0; powerups.length=0;
}

function startGame(){
  primeAudio();
  clearWorld();
  G.score=0; G.wave=1; G.lives=3; G.state='play';
  G.reverseT=0; G.reverseMax=360; G.spreadT=0; G.rapidT=0; G.pulses=1; G.hyperCool=0;
  G.shake=0; G.flash=0; G.nextLife=5000; G.combo=0; G.comboT=0; G.hitStop=0;
  G.spawnGuard=0; G.respawnT=0;
  G.orbsBroken=0; G.bestCombo=0; G.shots=0; G.hits=0; G.frames=0;
  ship.shield=0;
  toastT=0;
  resetShip();
  spawnWave();
  startScreen.classList.add('hidden');
  overScreen.classList.add('hidden');
  pauseScreen.classList.add('hidden');
}

function toMenu(){
  G.state='start';
  clearWorld();
  startScreen.classList.remove('hidden');
  overScreen.classList.add('hidden');
  pauseScreen.classList.add('hidden');
  Engine.set(0);
}

function togglePause(){
  if(G.state==='play'){ G.state='pause'; pauseScreen.classList.remove('hidden'); Engine.set(0); }
  else if(G.state==='pause'){ G.state='play'; pauseScreen.classList.add('hidden'); }
}

function fmtTime(sec){
  const m = Math.floor(sec/60), s = Math.floor(sec%60);
  return m + ':' + String(s).padStart(2,'0');
}

function gameOver(){
  G.state='over';
  const isBest = G.score > G.best;
  if(isBest){ G.best = G.score; Store.set('best', G.best); }
  if(G.wave > G.bestWave){ G.bestWave = G.wave; Store.set('bestWave', G.bestWave); }

  document.getElementById('finalScore').textContent = G.score.toLocaleString();
  document.getElementById('newBest').textContent = isBest ? '★ NEW PERSONAL BEST ★' : '';
  document.getElementById('stWave').textContent  = G.wave;
  document.getElementById('stOrbs').textContent  = G.orbsBroken;
  document.getElementById('stCombo').textContent = 'x'+G.bestCombo;
  document.getElementById('stAcc').textContent   = (G.shots? Math.round(G.hits/G.shots*100):0) + '%';
  document.getElementById('stTime').textContent  = fmtTime(G.frames/60);
  document.getElementById('finalBest').textContent =
    'BEST ' + G.best.toLocaleString() + '  ·  FURTHEST WAVE ' + G.bestWave;
  document.getElementById('finalMsg').textContent =
    G.wave>=12? 'The void filed a complaint.' :
    G.wave>=8 ? 'Orb whisperer.' :
    G.wave>=5 ? 'The void respects you.' :
    G.wave>=3 ? 'Not bad. The orbs disagree.' : 'The orbs were unimpressed.';
  overScreen.classList.remove('hidden');
  Engine.set(0);
  Snd.gameover();
}

/* ---------------- UI wiring ---------------- */
function toggleSound(){
  SET.muted = !SET.muted; Store.set('muted', SET.muted);
  Snd.applyMute(); syncToggles();
  toast(SET.muted? 'MUTED' : 'SOUND ON');
}
function toggleShake(){
  SET.shake = !SET.shake; Store.set('shake', SET.shake);
  if(!SET.shake){ G.shake=0; G.hitStop=0; }
  syncToggles();
  toast('SCREEN SHAKE ' + (SET.shake? 'ON':'OFF'));
}
function syncToggles(){
  const s = document.getElementById('tgSound'), k = document.getElementById('tgShake');
  document.getElementById('tgSoundV').textContent = SET.muted? 'OFF':'ON';
  document.getElementById('tgShakeV').textContent = SET.shake? 'ON':'OFF';
  s.classList.toggle('off', SET.muted);
  k.classList.toggle('off', !SET.shake);
}
document.getElementById('startBtn').addEventListener('click', startGame);
document.getElementById('againBtn').addEventListener('click', startGame);
document.getElementById('menuBtn').addEventListener('click', toMenu);
document.getElementById('resumeBtn').addEventListener('click', togglePause);
document.getElementById('quitBtn').addEventListener('click', ()=>{ pauseScreen.classList.add('hidden'); gameOver(); });
document.getElementById('tgSound').addEventListener('click', toggleSound);
document.getElementById('tgShake').addEventListener('click', toggleShake);

/* ============================================================
   UPDATE
   ============================================================ */
function slowAt(x,y){
  for(let i=0;i<bubbles.length;i++){
    const b=bubbles[i];
    if(Math.hypot(x-b.x, y-b.y) < b.r) return 0.34;
  }
  return 1;
}

function update(){
  G.time++;
  if(G.hitStop>0){ G.hitStop--; return; }
  G.frames++;

  const inp = readInput();
  const rev = G.reverseT>0 ? -1 : 1;
  if(G.reverseT>0) G.reverseT--;
  if(G.spreadT>0) G.spreadT--;
  if(G.rapidT>0)  G.rapidT--;
  if(G.hyperCool>0) G.hyperCool--;

  let shipSlow = 1;
  for(const b of bubbles){ if(Math.hypot(ship.x-b.x, ship.y-b.y) < b.r){ shipSlow = 0.42; break; } }

  /* ---------- ship control ---------- */
  if(ship.alive){
    const turn = 0.062 * shipSlow;
    if(inp.aim !== null){
      // touch stick: steer toward the thumb direction (reverse flips the target)
      const target = inp.aim + (rev<0 ? Math.PI : 0);
      let diff = ((target - ship.a + Math.PI*3) % TAU) - Math.PI;
      ship.a += clamp(diff, -turn*2.2, turn*2.2);
    } else {
      if(inp.left)  ship.a -= turn*rev;
      if(inp.right) ship.a += turn*rev;
    }

    const thrusting = inp.up;
    ship.thrust = lerp(ship.thrust, thrusting?1:0, 0.22);
    if(thrusting){
      const acc = 0.185 * shipSlow;
      ship.vx += Math.cos(ship.a)*acc;
      ship.vy += Math.sin(ship.a)*acc;
      for(let i=0;i<2;i++){
        const ea = ship.a + Math.PI + rnd(.45,-.45);
        const es = rnd(3.6,1.6);
        spark(ship.x + Math.cos(ship.a+Math.PI)*ship.r*0.85,
              ship.y + Math.sin(ship.a+Math.PI)*ship.r*0.85,
              Math.random()<0.45? '#9ff0ff' : (Math.random()<0.5?'#ffd88a':'#ff8f4c'),
              { vx: Math.cos(ea)*es + ship.vx*0.4, vy: Math.sin(ea)*es + ship.vy*0.4,
                life: rnd(26,10), r: rnd(3.4,1.2), drag:0.93 });
      }
    }
    if(inp.down){ ship.vx *= 0.975; ship.vy *= 0.975; }
    if(inp.fire) fire();
    Engine.set(ship.thrust);
  } else {
    Engine.set(0);
    if(G.respawnT>0 && --G.respawnT<=0){
      if(G.lives<=0){ gameOver(); return; }
      resetShip();
    }
  }

  if(ship.cool>0) ship.cool--;
  if(ship.inv>0) ship.inv--;
  if(ship.recoil>0.05) ship.recoil *= 0.8; else ship.recoil = 0;

  /* ---------- ship physics ---------- */
  if(ship.alive){
    ship.vx *= 0.9915; ship.vy *= 0.9915;
    const sp = Math.hypot(ship.vx,ship.vy), MAX=8.6;
    if(sp>MAX){ ship.vx = ship.vx/sp*MAX; ship.vy = ship.vy/sp*MAX; }
    const px = ship.x, py = ship.y;
    ship.x += ship.vx*shipSlow; ship.y += ship.vy*shipSlow;
    wrapPos(ship);
    // break the trail when the ship teleports across a wrap seam
    const jumped = Math.abs(ship.x-px) > W*0.5 || Math.abs(ship.y-py) > H*0.5;
    ship.trail.push({x:ship.x, y:ship.y, a:ship.a, cut:jumped});
    if(ship.trail.length>16) ship.trail.shift();
  }

  /* ---------- gravity wells ---------- */
  for(let i=wells.length-1;i>=0;i--){
    const w = wells[i];
    w.life--; w.a += 0.03;
    if(ship.alive){
      const [dx,dy] = wrapDelta(ship.x,ship.y,w.x,w.y);
      const d = Math.hypot(dx,dy);
      if(d < w.r && d>1){
        applyPull(ship, dx, dy, d, w.str * (1 - d/w.r) * 0.9);
      }
    }
    if(G.time%2===0){
      const a=rnd(TAU), rr=w.r*rnd(1,.5);
      spark(w.x+Math.cos(a)*rr, w.y+Math.sin(a)*rr, '#8de7ff',
        {vx:-Math.cos(a)*2.4, vy:-Math.sin(a)*2.4, life:26, r:1.6, drag:1});
    }
    if(w.life<=0){ ringFx(w.x,w.y,'#8de7ff',w.r,4,26,3); wells.splice(i,1); }
  }

  /* ---------- black holes ---------- */
  for(let i=holes.length-1;i>=0;i--){
    const h = holes[i];
    h.life--; h.a += 0.09;
    h.r = h.rMax * (0.75 + 0.18*Math.sin(h.a*1.7));
    if(h.life<50) h.r *= h.life/50;

    if(ship.alive){
      const [dx,dy] = wrapDelta(ship.x,ship.y,h.x,h.y);
      const d = Math.hypot(dx,dy);
      if(d < h.pull && d>1){
        applyPull(ship, dx, dy, d, 0.85*(1 - d/h.pull)*1.1);
        if(d < h.r + ship.r*0.4) killShip();
      }
    }
    for(let j=orbs.length-1;j>=0;j--){
      const o=orbs[j];
      const [dx,dy]=wrapDelta(o.x,o.y,h.x,h.y); const d=Math.hypot(dx,dy)||1;
      if(d < h.pull){
        applyPull(o, dx, dy, d, 0.7*(1-d/h.pull));
        if(d < h.r*0.9){
          h.eaten++;
          addScore(40,o.x,o.y);
          G.orbsBroken++;
          burst(o.x,o.y,10,ORB_TYPES[o.kind].c1,2.2,{life:24});
          orbs.splice(j,1);
          Snd.pop();
        }
      }
    }
    for(let k=0;k<2;k++){
      const a=rnd(TAU), rr=h.pull*rnd(.9,.3);
      spark(h.x+Math.cos(a)*rr, h.y+Math.sin(a)*rr, Math.random()<.5?'#c07bff':'#5a2fa8',
        {vx:-Math.cos(a)*3.4 - Math.sin(a)*2.4, vy:-Math.sin(a)*3.4 + Math.cos(a)*2.4,
         life:30, r:rnd(2.4,1), drag:0.99});
    }
    if(h.life<=0){
      ringFx(h.x,h.y,'#a24fff',6,300,40,5);
      burst(h.x,h.y,40,'#c07bff',7,{life:50});
      shake(10); holes.splice(i,1);
    }
  }

  /* ---------- time bubbles ---------- */
  for(let i=bubbles.length-1;i>=0;i--){
    const b=bubbles[i]; b.life--; b.ph += 0.04;
    if(b.life<=0){ ringFx(b.x,b.y,'#9dffc9',b.r,6,26,3); bubbles.splice(i,1); }
  }

  /* ---------- shockwaves (nova + pulse bomb) ---------- */
  for(let i=waves.length-1;i>=0;i--){
    const sw = waves[i];
    sw.r += sw.speed;
    if(sw.r > sw.rMax){ waves.splice(i,1); continue; }

    for(let j=orbs.length-1;j>=0;j--){
      const o = orbs[j];
      if(sw.hit.has(o)) continue;
      const d = wrapDist(o,sw);
      if(d < sw.r + o.r*0.7 && d > sw.r - sw.band - o.r*0.7){
        sw.hit.add(o);
        const [dx,dy] = wrapDelta(sw.x,sw.y,o.x,o.y);
        const m = Math.hypot(dx,dy)||1;
        o.vx += (dx/m)*2.2; o.vy += (dy/m)*2.2;
        o.hitFlash = 12;
        o.hp -= sw.dmg;
        if(o.hp<=0) destroyOrb(o);
        else { burst(o.x,o.y,6,sw.col,3,{life:20}); Snd.pop(); }
      }
    }
    for(let j=hunters.length-1;j>=0;j--){
      const hh=hunters[j], d=wrapDist(hh,sw);
      if(d < sw.r && d > sw.r - sw.band - 8){ burst(hh.x,hh.y,8,'#ffcb5e',3,{life:22}); addScore(35,hh.x,hh.y); hunters.splice(j,1); }
    }
    for(let j=shards.length-1;j>=0;j--){
      const sh=shards[j], d=wrapDist(sh,sw);
      if(d < sw.r && d > sw.r - sw.band - 8){ burst(sh.x,sh.y,7,'#dff8ff',3,{life:20}); addScore(25,sh.x,sh.y); shards.splice(j,1); }
    }
    for(let j=sentries.length-1;j>=0;j--){
      const st=sentries[j], d=wrapDist(st,sw);
      if(d < sw.r && d > sw.r - sw.band - 12){
        st.hp -= sw.dmg;
        if(st.hp<=0){ burst(st.x,st.y,16,'#8f9bff',4,{life:30}); addScore(80,st.x,st.y); sentries.splice(j,1); }
      }
    }
    for(let j=enemyShots.length-1;j>=0;j--){
      const es=enemyShots[j], d=wrapDist(es,sw);
      if(d < sw.r && d > sw.r - sw.band - 8){ burst(es.x,es.y,5,'#b4a0ff',2.4,{life:16}); enemyShots.splice(j,1); }
    }
    if(ship.alive){
      const [dx,dy]=wrapDelta(sw.x,sw.y,ship.x,ship.y);
      const d=Math.hypot(dx,dy)||1;
      if(Math.abs(d - sw.r) < sw.band){
        ship.vx += (dx/d)*0.9; ship.vy += (dy/d)*0.9;   // pushes you, never kills you
      }
    }
    for(let k=0;k<3;k++){
      const a=rnd(TAU);
      spark(sw.x+Math.cos(a)*sw.r, sw.y+Math.sin(a)*sw.r, sw.col,
        {vx:Math.cos(a)*2.2, vy:Math.sin(a)*2.2, life:20, r:rnd(2.6,1)});
    }
  }

  /* ---------- orbs ---------- */
  for(let i=orbs.length-1;i>=0;i--){
    const o = orbs[i];
    const s = slowAt(o.x,o.y);
    o.t++;
    o.a += o.spin*s;
    o.pulse += 0.07*s;
    o.wobble += 0.05*s;
    if(o.hitFlash>0) o.hitFlash--;

    for(const w of wells){
      const [dx,dy]=wrapDelta(o.x,o.y,w.x,w.y); const d=Math.hypot(dx,dy);
      if(d<w.r && d>1){ applyPull(o, dx, dy, d, w.str*0.4*(1-d/w.r)); }
    }

    // clamp runaway speed (wells + shockwaves + rubber boosts can stack)
    const osp = Math.hypot(o.vx,o.vy);
    if(osp > 11){ o.vx = o.vx/osp*11; o.vy = o.vy/osp*11; }

    o.x += o.vx*s; o.y += o.vy*s;

    if(o.kind==='rubber'){
      // rubber orbs refuse to wrap — they slam off the walls instead
      if(o.x < o.r){ o.x=o.r; o.vx=Math.abs(o.vx)*1.02; o.bounces++; Snd.bounce(); burst(o.x,o.y,6,'#c3ff5e',2.4,{life:20}); }
      if(o.x > W-o.r){ o.x=W-o.r; o.vx=-Math.abs(o.vx)*1.02; o.bounces++; Snd.bounce(); burst(o.x,o.y,6,'#c3ff5e',2.4,{life:20}); }
      if(o.y < o.r){ o.y=o.r; o.vy=Math.abs(o.vy)*1.02; o.bounces++; Snd.bounce(); burst(o.x,o.y,6,'#c3ff5e',2.4,{life:20}); }
      if(o.y > H-o.r){ o.y=H-o.r; o.vy=-Math.abs(o.vy)*1.02; o.bounces++; Snd.bounce(); burst(o.x,o.y,6,'#c3ff5e',2.4,{life:20}); }
    } else {
      wrapPos(o);
    }

    if(o.chainT>=0){
      o.chainT--;
      o.hitFlash = 8;
      if(o.chainT<0){ destroyOrb(o,true); continue; }
    }

    if(ship.alive && ship.inv<=0 && wrapDist(o,ship) < o.r*0.86 + ship.r*0.7) killShip();
  }

  /* ---------- bullets ---------- */
  for(let i=bullets.length-1;i>=0;i--){
    const b = bullets[i];
    let dead = false;

    for(const w of wells){
      const [dx,dy]=wrapDelta(b.x,b.y,w.x,w.y); const d=Math.hypot(dx,dy);
      if(d<w.r && d>1){ applyPull(b, dx, dy, d, w.str*1.5*(1-d/w.r)); }
    }
    for(const h of holes){
      const [dx,dy]=wrapDelta(b.x,b.y,h.x,h.y); const d=Math.hypot(dx,dy);
      if(d<h.pull && d>1){
        applyPull(b, dx, dy, d, 1.5*(1-d/h.pull));
        if(d<h.r*0.8){ burst(h.x,h.y,4,'#c07bff',1.6,{life:16}); dead = true; break; }
      }
    }
    if(dead){ bullets.splice(i,1); continue; }

    const s = slowAt(b.x,b.y);
    b.x += b.vx*s; b.y += b.vy*s;
    b.life -= s;
    b.trail.push({x:b.x,y:b.y});
    if(b.trail.length>9) b.trail.shift();
    wrapPos(b);
    if(b.life<=0){ bullets.splice(i,1); continue; }

    for(let j=orbs.length-1;j>=0 && !dead;j--){
      const o=orbs[j];
      if(wrapDist(o,b) < o.r*0.92 + b.r){ hitOrb(o,b.x,b.y); dead=true; G.hits++; }
    }
    for(let j=hunters.length-1;j>=0 && !dead;j--){
      const hh=hunters[j];
      if(wrapDist(hh,b) < hh.r + b.r + 2){
        burst(hh.x,hh.y,10,'#ffcb5e',3.2,{life:26});
        Snd.pop(); addScore(35,hh.x,hh.y);
        hunters.splice(j,1); dead=true; G.hits++;
      }
    }
    for(let j=shards.length-1;j>=0 && !dead;j--){
      const sh=shards[j];
      if(wrapDist(sh,b) < sh.r + b.r + 2){
        burst(sh.x,sh.y,9,'#dff8ff',3.4,{life:24});
        Snd.pop(); addScore(25,sh.x,sh.y);
        shards.splice(j,1); dead=true; G.hits++;
      }
    }
    for(let j=sentries.length-1;j>=0 && !dead;j--){
      const st=sentries[j];
      if(wrapDist(st,b) < st.r + b.r){
        st.hp--; dead=true; G.hits++;
        burst(b.x,b.y,8,'#c3ccff',3,{life:22});
        if(st.hp<=0){
          burst(st.x,st.y,20,'#8f9bff',4.6,{life:36});
          ringFx(st.x,st.y,'#7a86ff',6,90,26,3);
          addScore(80,st.x,st.y); Snd.pop();
          sentries.splice(j,1);
        } else Snd.pop();
      }
    }
    for(let j=enemyShots.length-1;j>=0 && !dead;j--){
      const es=enemyShots[j];
      if(wrapDist(es,b) < es.r + b.r + 2){
        burst(es.x,es.y,6,'#b4a0ff',2.6,{life:18});
        addScore(15,es.x,es.y);
        enemyShots.splice(j,1); dead=true; G.hits++;
      }
    }
    if(dead) bullets.splice(i,1);
  }

  /* ---------- hunters ---------- */
  for(let i=hunters.length-1;i>=0;i--){
    const h=hunters[i];
    const s = slowAt(h.x,h.y);
    h.life -= s;
    if(h.life<=0){ burst(h.x,h.y,8,'#ffb454',2.2,{life:20}); hunters.splice(i,1); continue; }
    if(ship.alive){
      const [dx,dy]=wrapDelta(h.x,h.y,ship.x,ship.y);
      const d=Math.hypot(dx,dy)||1;
      h.vx += (dx/d)*h.spd; h.vy += (dy/d)*h.spd;
    }
    h.wig += 0.3;
    h.vx += Math.cos(h.wig)*0.06; h.vy += Math.sin(h.wig*1.3)*0.06;
    const sp=Math.hypot(h.vx,h.vy), M=3.4;
    if(sp>M){ h.vx=h.vx/sp*M; h.vy=h.vy/sp*M; }
    h.x += h.vx*s; h.y += h.vy*s;
    h.a = Math.atan2(h.vy,h.vx);
    wrapPos(h);
    if(G.time%3===0) spark(h.x,h.y,'#ff9d2e',{vx:rnd(.6,-.6),vy:rnd(.6,-.6),life:16,r:1.7,drag:.92});
    if(ship.alive && ship.inv<=0 && wrapDist(h,ship) < h.r+ship.r*0.72) killShip();
  }

  /* ---------- shards ---------- */
  for(let i=shards.length-1;i>=0;i--){
    const sh=shards[i];
    const s=slowAt(sh.x,sh.y);
    sh.life-=s; sh.rot += sh.rs*s;
    if(sh.life<=0){ burst(sh.x,sh.y,5,'#dff8ff',1.8,{life:16}); shards.splice(i,1); continue; }
    sh.x += sh.vx*s; sh.y += sh.vy*s;
    let bounced=false;
    if(sh.x<sh.r){ sh.x=sh.r; sh.vx=Math.abs(sh.vx); bounced=true; }
    if(sh.x>W-sh.r){ sh.x=W-sh.r; sh.vx=-Math.abs(sh.vx); bounced=true; }
    if(sh.y<sh.r){ sh.y=sh.r; sh.vy=Math.abs(sh.vy); bounced=true; }
    if(sh.y>H-sh.r){ sh.y=H-sh.r; sh.vy=-Math.abs(sh.vy); bounced=true; }
    if(bounced){ sh.bounce++; Snd.bounce(); burst(sh.x,sh.y,4,'#e8fbff',2,{life:16,r:1.4}); }
    if(G.time%4===0) spark(sh.x,sh.y,'#bfeeff',{vx:rnd(.3,-.3),vy:rnd(.3,-.3),life:14,r:1.2,drag:.9});
    if(ship.alive && ship.inv<=0 && wrapDist(sh,ship) < sh.r+ship.r*0.7) killShip();
  }

  /* ---------- sentries ---------- */
  for(let i=sentries.length-1;i>=0;i--){
    const st=sentries[i];
    const s=slowAt(st.x,st.y);
    st.life-=s;
    if(st.life<=0){ burst(st.x,st.y,12,'#8f9bff',3,{life:26}); sentries.splice(i,1); continue; }
    st.x += st.vx*s*0.5; st.y += st.vy*s*0.5;
    st.vx *= 0.995; st.vy *= 0.995;
    wrapPos(st);
    if(ship.alive){
      const [dx,dy]=wrapDelta(st.x,st.y,ship.x,ship.y);
      st.a = Math.atan2(dy,dx);
      st.cool -= s;
      if(st.cool<=0){
        st.cool = Math.max(52, 100 - G.wave*3);
        const sp = 3.6;
        enemyShots.push({ x:st.x+Math.cos(st.a)*st.r, y:st.y+Math.sin(st.a)*st.r,
                          vx:Math.cos(st.a)*sp, vy:Math.sin(st.a)*sp, r:4.6, life:200 });
        Snd.turretShot();
        burst(st.x+Math.cos(st.a)*st.r, st.y+Math.sin(st.a)*st.r, 5, '#b4a0ff', 2.4, {life:16});
      }
      if(ship.inv<=0 && wrapDist(st,ship) < st.r + ship.r*0.7) killShip();
    }
  }

  /* ---------- enemy shots ---------- */
  for(let i=enemyShots.length-1;i>=0;i--){
    const es=enemyShots[i];
    const s=slowAt(es.x,es.y);
    es.life-=s;
    es.x += es.vx*s; es.y += es.vy*s;
    wrapPos(es);
    if(G.time%2===0) spark(es.x,es.y,'#9d86ff',{vx:rnd(.4,-.4),vy:rnd(.4,-.4),life:14,r:1.5,drag:.9});
    if(es.life<=0){ enemyShots.splice(i,1); continue; }
    if(ship.alive && ship.inv<=0 && wrapDist(es,ship) < es.r + ship.r*0.66){
      enemyShots.splice(i,1); killShip();
    }
  }

  /* ---------- powerups ---------- */
  for(let i=powerups.length-1;i>=0;i--){
    const p=powerups[i];
    p.life--; p.ph += 0.09; p.a += 0.03;
    p.x += p.vx; p.y += p.vy;
    p.vx *= 0.995; p.vy *= 0.995;
    wrapPos(p);
    if(G.time%4===0) spark(p.x,p.y,POWERS[p.kind].col,{vx:rnd(.5,-.5),vy:rnd(.5,-.5),life:18,r:1.5,drag:.92});
    if(p.life<=0){ burst(p.x,p.y,8,POWERS[p.kind].col,2,{life:20}); powerups.splice(i,1); continue; }
    if(ship.alive && wrapDist(p,ship) < p.r + ship.r + 4){
      grantPower(p.kind, p.x, p.y);
      powerups.splice(i,1);
    }
  }

  /* ---------- particles / rings / floats ---------- */
  for(let i=parts.length-1;i>=0;i--){
    const p=parts[i];
    if(--p.life<=0){ parts.splice(i,1); continue; }
    p.vy += p.grav;
    p.x += p.vx; p.y += p.vy;
    p.vx *= p.drag; p.vy *= p.drag;
  }
  for(let i=rings.length-1;i>=0;i--){
    const r=rings[i];
    if(--r.life<=0){ rings.splice(i,1); continue; }
    const t = 1 - r.life/r.max;
    r.cur = lerp(r.r, r.r1, 1-Math.pow(1-t,2.6));
  }
  for(let i=floats.length-1;i>=0;i--){
    const f=floats[i]; f.life--; f.y += f.vy; f.vy *= 0.96;
    if(f.life<=0) floats.splice(i,1);
  }

  if(G.comboT>0){ G.comboT--; if(G.comboT===0) G.combo=0; }

  /* ---------- wave flow ---------- */
  if(orbs.length===0 && G.spawnGuard<=0){
    G.spawnGuard = 80;
    G.wave++;
    const bonus = 500 + G.wave*120;
    addScore(bonus);
    floatText(W/2,H/2-40,'WAVE CLEAR  +'+bonus,'#8ef7ff',24);
    if(G.wave % 3 === 0) G.pulses = Math.min(3, G.pulses+1);
  }
  if(G.spawnGuard>0){
    G.spawnGuard--;
    if(G.spawnGuard===0 && orbs.length===0) spawnWave();
  }

  /* ---------- decay ---------- */
  G.shake *= 0.88;
  if(G.shake < 0.15) G.shake = 0;
  G.shakeX = G.shake? rnd(G.shake,-G.shake) : 0;
  G.shakeY = G.shake? rnd(G.shake,-G.shake) : 0;
  G.flash *= 0.88;
  if(toastT>0) toastT--;
  if(G.waveBannerT>0) G.waveBannerT--;
}

/* ============================================================
   RENDER
   ============================================================ */
function drawTwinkle(){
  ctx.save();
  for(const s of starsTwinkle){
    s.tw += 0.03*s.tws;
    ctx.globalAlpha = 0.22 + 0.6*(0.5+0.5*Math.sin(s.tw))*s.z;
    ctx.fillStyle = s.z>0.7? '#dfeaff' : (s.z>0.4? '#9fb4e8' : '#6b7bb0');
    ctx.fillRect(s.x, s.y, s.s, s.s);
  }
  ctx.restore();
}

/** orb silhouette in LOCAL space — caller has already translated to the centre */
function orbPath(o){
  ctx.beginPath();
  const n = o.verts.length;
  for(let i=0;i<=n;i++){
    const idx = i%n;
    const ang = (idx/n)*TAU + o.a;
    const wob = 1 + Math.sin(o.wobble + idx*1.7)*0.05;
    const rr = o.r * o.verts[idx] * wob;
    const x = Math.cos(ang)*rr, y = Math.sin(ang)*rr;
    if(i===0) ctx.moveTo(x,y); else ctx.lineTo(x,y);
  }
  ctx.closePath();
}

function drawOrbAt(o, ox, oy){
  const T = ORB_TYPES[o.kind];
  const pulse = 0.5 + 0.5*Math.sin(o.pulse);
  ctx.save();
  ctx.translate(o.x+ox, o.y+oy);

  // outer glow (cached sprite)
  blit(glowSprite(T.glow, 0.50, 0.16), 0, 0, o.r*1.85);

  // body (cached local-space gradient)
  orbPath(o);
  ctx.fillStyle = orbBodyGrad(o.kind, o.r);
  ctx.fill();
  ctx.lineWidth = 2;
  ctx.strokeStyle = hexA(T.c1, 0.85);
  ctx.stroke();

  // interior decoration
  ctx.save();
  ctx.rotate(o.a);
  const R = o.r;
  switch(T.shape){
    case 'hive': {
      ctx.strokeStyle = hexA('#7a4400',0.55); ctx.lineWidth=1.4;
      for(let k=0;k<3;k++){
        const rr = R*(0.28+k*0.22);
        ctx.beginPath();
        for(let i=0;i<6;i++){ const a=i/6*TAU + k*0.3;
          const x=Math.cos(a)*rr,y=Math.sin(a)*rr; i?ctx.lineTo(x,y):ctx.moveTo(x,y); }
        ctx.closePath(); ctx.stroke();
      }
      ctx.fillStyle = hexA('#5a2f00',0.5);
      for(let i=0;i<5;i++){ const a=i/5*TAU + o.t*0.02;
        ctx.beginPath(); ctx.arc(Math.cos(a)*R*0.5, Math.sin(a)*R*0.5, R*0.09,0,TAU); ctx.fill(); }
      break;
    }
    case 'ring': {
      for(let k=0;k<4;k++){
        ctx.strokeStyle = hexA('#eaffff', 0.5 - k*0.1);
        ctx.lineWidth = 1.6;
        ctx.beginPath();
        ctx.ellipse(0,0, R*(0.28+k*0.19), R*(0.28+k*0.19)*(0.35+0.2*Math.sin(o.pulse+k)), k*0.5,0,TAU);
        ctx.stroke();
      }
      break;
    }
    case 'glitch': {
      ctx.lineWidth=2.4;
      for(let k=0;k<5;k++){
        ctx.strokeStyle = hexA(k%2?'#ffffff':'#dcc6ff', 0.5+0.3*pulse);
        const a0 = (k/5)*TAU + o.t*0.05;
        ctx.beginPath(); ctx.arc(0,0,R*(0.35+ (k%3)*0.2), a0, a0+0.9); ctx.stroke();
      }
      break;
    }
    case 'void': {
      const vg = ctx.createRadialGradient(0,0,0,0,0,R);
      vg.addColorStop(0,'#000000'); vg.addColorStop(0.6,'#150a28'); vg.addColorStop(1,'#3a1a66');
      ctx.fillStyle=vg; ctx.beginPath(); ctx.arc(0,0,R*0.92,0,TAU); ctx.fill();
      ctx.strokeStyle = hexA('#c07bff', 0.6+0.35*pulse); ctx.lineWidth=2;
      for(let k=0;k<3;k++){
        ctx.beginPath();
        ctx.ellipse(0,0,R*(0.5+k*0.16), R*0.18, o.t*0.03 + k*1.1, 0, TAU); ctx.stroke();
      }
      break;
    }
    case 'spiral': {
      ctx.strokeStyle = hexA('#ffffff',0.6); ctx.lineWidth=2.2;
      ctx.beginPath();
      for(let i=0;i<70;i++){
        const t=i/70, a=t*TAU*2.6 + o.t*0.04, rr=R*0.92*t;
        const x=Math.cos(a)*rr, y=Math.sin(a)*rr;
        i?ctx.lineTo(x,y):ctx.moveTo(x,y);
      }
      ctx.stroke();
      break;
    }
    case 'crystal': {
      ctx.strokeStyle = hexA('#ffffff',0.72); ctx.lineWidth=1.5;
      for(let i=0;i<7;i++){
        const a=i/7*TAU;
        ctx.beginPath(); ctx.moveTo(0,0);
        ctx.lineTo(Math.cos(a)*R*0.92, Math.sin(a)*R*0.92); ctx.stroke();
      }
      ctx.beginPath();
      for(let i=0;i<7;i++){ const a=i/7*TAU + .45;
        const x=Math.cos(a)*R*0.55,y=Math.sin(a)*R*0.55; i?ctx.lineTo(x,y):ctx.moveTo(x,y); }
      ctx.closePath(); ctx.strokeStyle=hexA('#ffffff',0.5); ctx.stroke();
      break;
    }
    case 'clock': {
      ctx.strokeStyle=hexA('#04321f',0.7); ctx.lineWidth=2.6;
      ctx.beginPath(); ctx.arc(0,0,R*0.62,0,TAU); ctx.stroke();
      ctx.lineWidth=3;
      const t1 = o.t*0.05, t2 = o.t*0.012;
      ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(Math.cos(t1)*R*0.5, Math.sin(t1)*R*0.5); ctx.stroke();
      ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(Math.cos(t2)*R*0.34, Math.sin(t2)*R*0.34); ctx.stroke();
      ctx.fillStyle=hexA('#04321f',0.55);
      for(let i=0;i<12;i++){ const a=i/12*TAU;
        ctx.fillRect(Math.cos(a)*R*0.74-1, Math.sin(a)*R*0.74-1, 2.4,2.4); }
      break;
    }
    case 'bomb': {
      ctx.fillStyle = hexA('#ffe08a', 0.55+0.4*pulse);
      ctx.beginPath(); ctx.arc(0,0,R*0.34*(0.85+0.25*pulse),0,TAU); ctx.fill();
      ctx.strokeStyle=hexA('#ffd9b0',0.8); ctx.lineWidth=2.4;
      for(let i=0;i<6;i++){ const a=i/6*TAU + o.t*0.03;
        ctx.beginPath();
        ctx.moveTo(Math.cos(a)*R*0.46, Math.sin(a)*R*0.46);
        ctx.lineTo(Math.cos(a)*R*0.82, Math.sin(a)*R*0.82); ctx.stroke(); }
      break;
    }
    case 'mirror': {
      ctx.save();
      ctx.beginPath(); ctx.arc(0,0,R*0.92,0,TAU); ctx.clip();
      ctx.fillStyle = hexA('#ffffff',0.35);
      ctx.beginPath(); ctx.moveTo(-R,-R); ctx.lineTo(R,-R); ctx.lineTo(-R,R); ctx.closePath(); ctx.fill();
      ctx.strokeStyle=hexA('#ffffff',0.85); ctx.lineWidth=2;
      ctx.beginPath(); ctx.moveTo(R*0.9,-R*0.9); ctx.lineTo(-R*0.9,R*0.9); ctx.stroke();
      ctx.restore();
      break;
    }
    case 'rubber': {
      ctx.save();
      ctx.beginPath(); ctx.arc(0,0,R*0.9,0,TAU); ctx.clip();
      ctx.strokeStyle=hexA('#2f5c04',0.5); ctx.lineWidth=R*0.16;
      for(let i=-3;i<=3;i++){ ctx.beginPath(); ctx.moveTo(-R, i*R*0.4); ctx.lineTo(R, i*R*0.4 + R*0.5); ctx.stroke(); }
      ctx.restore();
      break;
    }
    case 'star': {
      ctx.fillStyle = hexA('#fff6d0', 0.5+0.45*pulse);
      ctx.beginPath();
      for(let i=0;i<12;i++){
        const a = i/12*TAU + o.t*0.02;
        const rr = R*(i%2? 0.30 : 0.86);
        const x=Math.cos(a)*rr, y=Math.sin(a)*rr;
        i?ctx.lineTo(x,y):ctx.moveTo(x,y);
      }
      ctx.closePath(); ctx.fill();
      ctx.strokeStyle = hexA('#ff7a18',0.8); ctx.lineWidth=1.6; ctx.stroke();
      ctx.fillStyle = hexA('#ffffff',0.85);
      ctx.beginPath(); ctx.arc(0,0,R*0.18*(0.8+0.4*pulse),0,TAU); ctx.fill();
      break;
    }
    case 'eye': {
      ctx.fillStyle = hexA('#0c1030',0.85);
      ctx.beginPath(); ctx.ellipse(0,0,R*0.78,R*0.5,0,0,TAU); ctx.fill();
      ctx.strokeStyle = hexA('#c3ccff',0.75); ctx.lineWidth=2; ctx.stroke();
      const look = o.t*0.03;
      ctx.fillStyle = '#7a86ff';
      ctx.beginPath(); ctx.arc(Math.cos(look)*R*0.26, Math.sin(look)*R*0.14, R*0.24,0,TAU); ctx.fill();
      ctx.fillStyle = '#04061a';
      ctx.beginPath(); ctx.arc(Math.cos(look)*R*0.26, Math.sin(look)*R*0.14, R*0.11,0,TAU); ctx.fill();
      ctx.strokeStyle = hexA('#7a86ff',0.45); ctx.lineWidth=1.4;
      for(let i=0;i<4;i++){
        const a=i/4*TAU + o.t*0.02;
        ctx.beginPath();
        ctx.moveTo(Math.cos(a)*R*0.82, Math.sin(a)*R*0.82);
        ctx.lineTo(Math.cos(a)*R*0.95, Math.sin(a)*R*0.95); ctx.stroke();
      }
      break;
    }
    case 'portal': {
      ctx.strokeStyle = hexA('#ffffff',0.55); ctx.lineWidth=2;
      for(let k=0;k<3;k++){
        ctx.beginPath();
        for(let i=0;i<48;i++){
          const t=i/48, a=t*TAU*1.6 + o.t*0.06 + k*2.1, rr=R*0.9*(1-t*0.8);
          const x=Math.cos(a)*rr, y=Math.sin(a)*rr;
          i?ctx.lineTo(x,y):ctx.moveTo(x,y);
        }
        ctx.stroke();
      }
      const pg = ctx.createRadialGradient(0,0,0,0,0,R*0.5);
      pg.addColorStop(0,'rgba(255,255,255,.9)'); pg.addColorStop(1,'rgba(47,232,213,0)');
      ctx.fillStyle=pg; ctx.beginPath(); ctx.arc(0,0,R*0.5,0,TAU); ctx.fill();
      break;
    }
  }
  ctx.restore();

  // specular
  ctx.beginPath();
  ctx.arc(-o.r*0.33, -o.r*0.38, o.r*0.2, 0, TAU);
  ctx.fillStyle = 'rgba(255,255,255,.55)'; ctx.fill();

  // elder crown
  if(o.elder){
    ctx.strokeStyle = `rgba(255,240,180,${0.45+0.35*Math.abs(Math.sin(G.time*0.06))})`;
    ctx.lineWidth = 3;
    ctx.setLineDash([10,9]);
    ctx.beginPath(); ctx.arc(0,0,o.r+13,0,TAU); ctx.stroke();
    ctx.setLineDash([]);
  }

  // hp pips
  if(o.maxhp>1 && o.hp<o.maxhp){
    const n=Math.min(o.maxhp,10);
    for(let i=0;i<n;i++){
      const a = -Math.PI/2 + (i-(n-1)/2)*0.24;
      ctx.beginPath(); ctx.arc(Math.cos(a)*(o.r+11), Math.sin(a)*(o.r+11), 2.6, 0, TAU);
      ctx.fillStyle = i<o.hp ? T.c1 : 'rgba(255,255,255,.16)';
      ctx.fill();
    }
  }

  // chain fuse warning
  if(o.chainT>=0){
    ctx.strokeStyle = `rgba(255,120,80,${0.4+0.6*Math.abs(Math.sin(G.time*0.5))})`;
    ctx.lineWidth = 3;
    ctx.beginPath(); ctx.arc(0,0,o.r+7,0,TAU); ctx.stroke();
  }

  // hit flash
  if(o.hitFlash>0){
    ctx.globalCompositeOperation='lighter';
    ctx.fillStyle = `rgba(255,255,255,${o.hitFlash/22})`;
    orbPath(o); ctx.fill();
    ctx.globalCompositeOperation='source-over';
  }

  ctx.restore();
}

function drawOrbs(){
  for(const o of orbs){
    if(o.kind==='rubber') drawOrbAt(o,0,0);          // never wraps, never needs a mirror copy
    else wrapDraw(o.x,o.y,o.r*2.4,(ox,oy)=> drawOrbAt(o,ox,oy));
  }
}

function shipPath(r){
  ctx.beginPath();
  ctx.moveTo(r*1.35, 0);
  ctx.lineTo(-r*0.72, -r*0.82);
  ctx.lineTo(-r*0.34, 0);
  ctx.lineTo(-r*0.72, r*0.82);
  ctx.closePath();
}

function drawShipAt(ox,oy){
  const blink = ship.inv>0 && (Math.floor(G.time/4)%2===0);
  ctx.save();
  ctx.translate(ship.x+ox, ship.y+oy);
  ctx.rotate(ship.a);
  if(ship.recoil>0.2) ctx.translate(-ship.recoil,0);

  blit(glowSprite('#78f0ff', 0.45, 0.16), 0, 0, 34);

  if(ship.thrust>0.05){
    const fl = (0.7+0.5*Math.random())*ship.thrust;
    if(!shipFlameGrad){
      shipFlameGrad = ctx.createLinearGradient(-ship.r, 0, -ship.r-30, 0);
      shipFlameGrad.addColorStop(0,'rgba(255,255,255,.95)');
      shipFlameGrad.addColorStop(0.3,'rgba(120,230,255,.85)');
      shipFlameGrad.addColorStop(0.7,'rgba(255,150,60,.6)');
      shipFlameGrad.addColorStop(1,'rgba(255,60,20,0)');
    }
    ctx.fillStyle=shipFlameGrad;
    ctx.beginPath();
    ctx.moveTo(-ship.r*0.62, -5.4);
    ctx.lineTo(-ship.r - 30*fl, 0);
    ctx.lineTo(-ship.r*0.62, 5.4);
    ctx.closePath(); ctx.fill();
  }

  if(!blink){
    shipPath(ship.r);
    if(!shipHullGrad){
      shipHullGrad = ctx.createLinearGradient(-ship.r,0,ship.r,0);
      shipHullGrad.addColorStop(0,'#1a2a52'); shipHullGrad.addColorStop(0.55,'#dff2ff'); shipHullGrad.addColorStop(1,'#ffffff');
    }
    ctx.fillStyle=shipHullGrad; ctx.fill();
    ctx.lineWidth=2; ctx.strokeStyle= G.reverseT>0? '#ff62c0' : '#7df9ff'; ctx.stroke();
    ctx.beginPath(); ctx.ellipse(2,0,4.4,3,0,0,TAU);
    ctx.fillStyle='#0d2b45'; ctx.fill();
    ctx.strokeStyle='rgba(160,240,255,.9)'; ctx.lineWidth=1; ctx.stroke();
  }

  if(ship.shield>0){
    const a = 0.35+0.28*Math.abs(Math.sin(G.time*0.08));
    ctx.beginPath(); ctx.arc(0,0, ship.r+12, 0, TAU);
    ctx.strokeStyle = `rgba(142,255,192,${a})`; ctx.lineWidth=2.6; ctx.stroke();
    ctx.beginPath(); ctx.arc(0,0, ship.r+12, 0, TAU);
    ctx.fillStyle = `rgba(142,255,192,${a*0.10})`; ctx.fill();
  }
  if(ship.inv>0){
    ctx.beginPath(); ctx.arc(0,0, ship.r+9, 0, TAU);
    ctx.strokeStyle = `rgba(140,240,255,${0.25+0.35*Math.abs(Math.sin(G.time*0.16))})`;
    ctx.lineWidth=2; ctx.stroke();
  }
  ctx.restore();
}

function drawShip(){
  if(!ship.alive) return;
  // trail ghosts (wrap-safe: a segment flagged `cut` is skipped)
  ctx.save();
  for(let i=0;i<ship.trail.length;i+=2){
    const t = ship.trail[i];
    if(t.cut) continue;
    if(Math.abs(t.x-ship.x)>W*0.5 || Math.abs(t.y-ship.y)>H*0.5) continue;
    const a = (i/ship.trail.length)*0.20;
    ctx.save(); ctx.translate(t.x,t.y); ctx.rotate(t.a);
    ctx.globalAlpha=a; ctx.fillStyle='#5ce8ff';
    shipPath(ship.r*0.9); ctx.fill(); ctx.restore();
  }
  ctx.restore();
  wrapDraw(ship.x, ship.y, 46, drawShipAt);
}

function drawBullets(){
  ctx.save();
  ctx.globalCompositeOperation='lighter';
  for(const b of bullets){
    for(let i=0;i<b.trail.length;i++){
      const t=b.trail[i];
      const a=(i/b.trail.length);
      if(Math.abs(t.x-b.x)>W/2 || Math.abs(t.y-b.y)>H/2) continue;
      ctx.beginPath(); ctx.arc(t.x,t.y, b.r*a*0.95, 0, TAU);
      ctx.fillStyle=`rgba(110,230,255,${a*0.42})`; ctx.fill();
    }
    blit(bulletSprite(), b.x, b.y, b.r*4.5);
    ctx.fillStyle='#ffffff'; ctx.beginPath(); ctx.arc(b.x,b.y,b.r*0.8,0,TAU); ctx.fill();
  }
  ctx.restore();
}

function drawHunters(){
  for(const h of hunters){
    wrapDraw(h.x,h.y,22,(ox,oy)=>{
      const fade = Math.min(1, h.life/70);
      ctx.save(); ctx.translate(h.x+ox,h.y+oy); ctx.rotate(h.a);
      ctx.globalAlpha = fade;
      blit(glowSprite('#ffbe50', 0.7, 0.26), 0, 0, 16);
      ctx.beginPath();
      ctx.moveTo(9,0); ctx.lineTo(-5,-5.4); ctx.lineTo(-2.4,0); ctx.lineTo(-5,5.4);
      ctx.closePath();
      ctx.fillStyle='#ffd66b'; ctx.fill();
      ctx.strokeStyle='#ff8c1e'; ctx.lineWidth=1.4; ctx.stroke();
      ctx.beginPath(); ctx.arc(3,0,1.7,0,TAU); ctx.fillStyle='#5a1500'; ctx.fill();
      ctx.restore();
    });
  }
}

function drawShards(){
  for(const s of shards){
    const fade=Math.min(1,s.life/70);
    ctx.save(); ctx.translate(s.x,s.y); ctx.rotate(s.rot); ctx.globalAlpha=fade;
    ctx.globalCompositeOperation='lighter';
    blit(glowSprite('#dcfaff', 0.65, 0.24), 0, 0, 14);
    ctx.globalCompositeOperation='source-over';
    ctx.beginPath();
    ctx.moveTo(0,-7.5); ctx.lineTo(5.6,1.4); ctx.lineTo(1.7,7.4); ctx.lineTo(-4.4,3.4); ctx.closePath();
    const lg=ctx.createLinearGradient(-6,-6,6,6);
    lg.addColorStop(0,'#ffffff'); lg.addColorStop(0.5,'#c9f4ff'); lg.addColorStop(1,'#6fa9c4');
    ctx.fillStyle=lg; ctx.fill();
    ctx.strokeStyle='rgba(255,255,255,.9)'; ctx.lineWidth=1; ctx.stroke();
    ctx.restore();
  }
}

function drawSentries(){
  for(const s of sentries){
    wrapDraw(s.x,s.y,40,(ox,oy)=>{
      const fade = Math.min(1, s.life/70);
      ctx.save(); ctx.translate(s.x+ox,s.y+oy); ctx.globalAlpha=fade;
      blit(glowSprite('#7886ff', 0.34, 0.12), 0, 0, 34);

      ctx.save();
      ctx.rotate(s.a);
      ctx.fillStyle='#39408f';
      ctx.fillRect(0, -4, s.r+11, 8);
      ctx.fillStyle='#8f9bff';
      ctx.fillRect(s.r+3, -3, 8, 6);
      ctx.restore();

      ctx.beginPath(); ctx.arc(0,0,s.r,0,TAU);
      ctx.fillStyle = sentryBodyGrad(s.r); ctx.fill();
      ctx.strokeStyle='#c3ccff'; ctx.lineWidth=1.8; ctx.stroke();

      const ready = s.cool < 22;
      ctx.beginPath(); ctx.arc(0,0,s.r*0.36,0,TAU);
      ctx.fillStyle = ready ? `rgba(255,90,120,${0.6+0.4*Math.abs(Math.sin(G.time*0.4))})` : 'rgba(160,175,255,.85)';
      ctx.fill();

      if(s.hp<2){
        ctx.strokeStyle='rgba(255,120,120,.8)'; ctx.lineWidth=2;
        ctx.beginPath(); ctx.arc(0,0,s.r+5,-0.6,1.4); ctx.stroke();
      }
      ctx.restore();
    });
  }
}

function drawEnemyShots(){
  ctx.save();
  ctx.globalCompositeOperation='lighter';
  for(const e of enemyShots){
    blit(enemyShotSprite(), e.x, e.y, e.r*3.4);
    ctx.fillStyle='#efe6ff'; ctx.beginPath(); ctx.arc(e.x,e.y,e.r*0.6,0,TAU); ctx.fill();
  }
  ctx.restore();
}

function drawPowerups(){
  for(const p of powerups){
    const P = POWERS[p.kind];
    const blink = p.life<120 && Math.floor(p.life/6)%2===0;
    if(blink) continue;
    wrapDraw(p.x,p.y,36,(ox,oy)=>{
      const x=p.x+ox, y=p.y+oy;
      const bob = Math.sin(p.ph)*2.5;
      ctx.save();
      ctx.translate(x,y+bob);
      blit(glowSprite(P.col, 0.5, 0.18), 0, 0, 26);
      ctx.rotate(p.a);
      const s=p.r;
      ctx.beginPath();
      ctx.moveTo(-s,0); ctx.lineTo(0,-s); ctx.lineTo(s,0); ctx.lineTo(0,s); ctx.closePath();
      ctx.fillStyle='rgba(8,10,26,.9)'; ctx.fill();
      ctx.strokeStyle=P.col; ctx.lineWidth=2.2; ctx.stroke();
      ctx.rotate(-p.a);
      ctx.fillStyle=P.col;
      ctx.font='900 13px "Trebuchet MS", sans-serif';
      ctx.textAlign='center'; ctx.textBaseline='middle';
      ctx.fillText(P.glyph, 0, 0.5);
      ctx.restore();
    });
  }
}

function drawWells(){
  for(const w of wells){
    const f = Math.min(1, w.life/60);
    ctx.save();
    ctx.globalAlpha = 0.85*f;
    const g = ctx.createRadialGradient(w.x,w.y, w.r*0.05, w.x,w.y, w.r);
    g.addColorStop(0,'rgba(190,250,255,.42)');
    g.addColorStop(0.35,'rgba(70,180,255,.16)');
    g.addColorStop(1,'rgba(30,80,220,0)');
    ctx.fillStyle=g; ctx.beginPath(); ctx.arc(w.x,w.y,w.r,0,TAU); ctx.fill();
    ctx.strokeStyle='rgba(140,235,255,.32)'; ctx.lineWidth=1.5;
    for(let k=0;k<4;k++){
      ctx.beginPath();
      const rr = w.r*(0.22+k*0.2) * (0.9+0.1*Math.sin(w.a*2+k));
      ctx.ellipse(w.x,w.y, rr, rr*0.55, w.a + k*0.7, 0, TAU); ctx.stroke();
    }
    const cg=ctx.createRadialGradient(w.x,w.y,0,w.x,w.y,22);
    cg.addColorStop(0,'rgba(255,255,255,.9)'); cg.addColorStop(1,'rgba(90,200,255,0)');
    ctx.fillStyle=cg; ctx.beginPath(); ctx.arc(w.x,w.y,22,0,TAU); ctx.fill();
    ctx.restore();
  }
}

function drawHoles(){
  for(const h of holes){
    ctx.save();
    blit(glowSprite('#be6eff', 0.36, 0.14), h.x, h.y, h.pull*0.58);

    ctx.globalCompositeOperation='lighter';
    for(let k=0;k<5;k++){
      ctx.strokeStyle=`rgba(${180+k*10},${90+k*20},255,${0.30-k*0.045})`;
      ctx.lineWidth = 3.4-k*0.5;
      ctx.beginPath();
      const rr = h.r*(1.6+k*0.9);
      ctx.ellipse(h.x,h.y, rr, rr*0.42, h.a*(1+k*0.3), 0.3, 0.3+TAU*0.78);
      ctx.stroke();
    }
    ctx.globalCompositeOperation='source-over';

    ctx.beginPath(); ctx.arc(h.x,h.y,Math.max(2,h.r),0,TAU);
    ctx.fillStyle='#000'; ctx.fill();
    ctx.strokeStyle='rgba(220,160,255,.85)'; ctx.lineWidth=2.4; ctx.stroke();
    ctx.beginPath(); ctx.arc(h.x,h.y,Math.max(3,h.r*1.22),0,TAU);
    ctx.strokeStyle=`rgba(255,220,255,${0.25+0.2*Math.sin(h.a*3)})`; ctx.lineWidth=1.4; ctx.stroke();
    ctx.restore();
  }
}

function drawBubbles(){
  for(const b of bubbles){
    const f = Math.min(1, b.life/80);
    ctx.save();
    ctx.globalAlpha = f;
    const g=ctx.createRadialGradient(b.x,b.y,b.r*0.2,b.x,b.y,b.r);
    g.addColorStop(0,'rgba(120,255,190,.05)');
    g.addColorStop(0.75,'rgba(90,240,180,.10)');
    g.addColorStop(0.96,'rgba(150,255,210,.28)');
    g.addColorStop(1,'rgba(150,255,210,0)');
    ctx.fillStyle=g; ctx.beginPath(); ctx.arc(b.x,b.y,b.r,0,TAU); ctx.fill();
    ctx.strokeStyle=`rgba(160,255,215,${0.35+0.2*Math.sin(b.ph*2)})`;
    ctx.lineWidth=2; ctx.beginPath(); ctx.arc(b.x,b.y,b.r,0,TAU); ctx.stroke();
    ctx.strokeStyle='rgba(160,255,215,.22)'; ctx.lineWidth=1.4;
    for(let i=0;i<16;i++){
      const a=i/16*TAU + b.ph*0.3;
      ctx.beginPath();
      ctx.moveTo(b.x+Math.cos(a)*b.r*0.88, b.y+Math.sin(a)*b.r*0.88);
      ctx.lineTo(b.x+Math.cos(a)*b.r*0.96, b.y+Math.sin(a)*b.r*0.96);
      ctx.stroke();
    }
    ctx.restore();
  }
}

function drawShockwaves(){
  ctx.save();
  ctx.globalCompositeOperation='lighter';
  for(const sw of waves){
    const t = 1 - sw.r/sw.rMax;
    ctx.globalAlpha = clamp(t*1.1,0,1);
    ctx.strokeStyle = sw.col;
    ctx.lineWidth = 3 + 9*t;
    ctx.beginPath(); ctx.arc(sw.x,sw.y,sw.r,0,TAU); ctx.stroke();
    ctx.globalAlpha = clamp(t*0.5,0,1);
    ctx.lineWidth = 1.5;
    ctx.beginPath(); ctx.arc(sw.x,sw.y,Math.max(1,sw.r - sw.band),0,TAU); ctx.stroke();
  }
  ctx.restore();
}

function drawParticles(){
  ctx.save();
  ctx.globalCompositeOperation='lighter';
  for(const p of parts){
    const a = p.life/p.max;
    ctx.globalAlpha = Math.min(1,a*1.3);
    ctx.fillStyle = p.col;
    ctx.beginPath();
    ctx.arc(p.x,p.y, Math.max(0.3, p.r*a), 0, TAU);
    ctx.fill();
  }
  ctx.restore();
}
function drawRings(){
  ctx.save();
  ctx.globalCompositeOperation='lighter';
  for(const r of rings){
    const t = r.life/r.max;
    ctx.globalAlpha = t*0.85;
    ctx.strokeStyle = r.col;
    ctx.lineWidth = Math.max(0.4, r.w*t);
    ctx.beginPath(); ctx.arc(r.x,r.y, Math.max(1,r.cur), 0, TAU); ctx.stroke();
  }
  ctx.restore();
}
function drawFloats(){
  ctx.save();
  ctx.textAlign='center'; ctx.textBaseline='middle';
  for(const f of floats){
    const a = Math.min(1, f.life/28);
    ctx.globalAlpha = a;
    ctx.font = `900 ${f.size}px "Trebuchet MS", sans-serif`;
    ctx.lineWidth=4; ctx.strokeStyle='rgba(0,0,0,.6)';
    ctx.strokeText(f.txt, f.x, f.y);
    ctx.fillStyle=f.col;
    ctx.shadowColor=f.col; ctx.shadowBlur=14;
    ctx.fillText(f.txt, f.x, f.y);
    ctx.shadowBlur=0;
  }
  ctx.restore();
}

/* ---------------- HUD ---------------- */
function drawMeter(x,y,w,pct,col,label){
  ctx.fillStyle='rgba(255,255,255,.12)';
  ctx.fillRect(x,y,w,5);
  ctx.fillStyle=col;
  ctx.fillRect(x,y,w*clamp(pct,0,1),5);
  ctx.font='700 9px "Trebuchet MS", sans-serif';
  ctx.fillStyle=col; ctx.textAlign='left';
  ctx.fillText(label, x, y-6);
}

function drawHUD(){
  ctx.save();
  ctx.textBaseline='top';

  // score
  const sc = String(G.score).padStart(6,'0');
  ctx.font='900 30px "Trebuchet MS", sans-serif';
  ctx.textAlign='left';
  ctx.fillStyle='rgba(0,0,0,.5)'; ctx.fillText(sc, 21,19);
  ctx.fillStyle='#eaf4ff'; ctx.shadowColor='#5ad4ff'; ctx.shadowBlur=14;
  ctx.fillText(sc, 20,18);
  ctx.shadowBlur=0;

  ctx.font='700 12px "Trebuchet MS", sans-serif';
  ctx.fillStyle='#7f8fbd';
  ctx.fillText('BEST ' + G.best.toLocaleString(), 21, 52);

  // wave + orb count
  ctx.textAlign='right';
  ctx.font='900 22px "Trebuchet MS", sans-serif';
  ctx.fillStyle='#c9d8ff'; ctx.shadowColor='#8a6bff'; ctx.shadowBlur=12;
  ctx.fillText('WAVE ' + G.wave, W-20, 20);
  ctx.shadowBlur=0;
  ctx.font='700 12px "Trebuchet MS", sans-serif';
  ctx.fillStyle='#7f8fbd';
  ctx.fillText(orbs.length + ' ORBS', W-21, 48);

  // lives
  const shown = Math.min(G.lives, 8);
  for(let i=0;i<shown;i++){
    ctx.save();
    ctx.translate(30 + i*26, 84);
    ctx.rotate(-Math.PI/2);
    ctx.scale(0.72,0.72);
    shipPath(11);
    ctx.fillStyle='#9ff0ff'; ctx.fill();
    ctx.strokeStyle='#2b7fa8'; ctx.lineWidth=1.6; ctx.stroke();
    ctx.restore();
  }
  if(G.lives>8){
    ctx.textAlign='left'; ctx.font='800 13px "Trebuchet MS", sans-serif';
    ctx.fillStyle='#9ff0ff'; ctx.fillText('+'+(G.lives-8), 30+8*26, 78);
  }

  // pulse charges
  ctx.textAlign='left';
  for(let i=0;i<3;i++){
    const x = 22 + i*17, y = 108;
    ctx.beginPath();
    ctx.moveTo(x, y+6); ctx.lineTo(x+6, y-6); ctx.lineTo(x+12, y+6); ctx.closePath();
    ctx.fillStyle = i<G.pulses ? '#ff8ad4' : 'rgba(255,138,212,.16)';
    ctx.fill();
  }
  ctx.font='700 9px "Trebuchet MS", sans-serif';
  ctx.fillStyle='#8b6f9e'; ctx.fillText(touch.on ? 'PULSE' : 'PULSE (SHIFT)', 22, 118);

  // active power meters
  let my = 138;
  if(G.spreadT>0){ drawMeter(22,my,90,G.spreadT/1800,'#7df9ff','SPREAD'); my+=22; }
  if(G.rapidT>0){  drawMeter(22,my,90,G.rapidT/1800,'#ffd76b','RAPID');  my+=22; }
  if(G.hyperCool>0){ drawMeter(22,my,90,1-G.hyperCool/260,'#8ef7ff','WARP DRIVE'); my+=22; }
  if(ship.shield>0){
    ctx.font='800 11px "Trebuchet MS", sans-serif';
    ctx.fillStyle='#8effc0'; ctx.fillText('◇ SHIELD ACTIVE', 22, my); my+=18;
  }

  // combo
  if(G.combo>1){
    ctx.textAlign='left';
    ctx.font='900 17px "Trebuchet MS", sans-serif';
    ctx.globalAlpha = Math.min(1,G.comboT/30);
    ctx.fillStyle='#ffd76b'; ctx.shadowColor='#ff9d2e'; ctx.shadowBlur=12;
    ctx.fillText('x'+G.combo+' CHAIN', 22, my);
    ctx.shadowBlur=0; ctx.globalAlpha=1;
  }

  // reverse warning
  if(G.reverseT>0){
    const a = 0.6+0.4*Math.sin(G.time*0.3);
    ctx.textAlign='center';
    ctx.font='900 clamp(16px,4vw,24px) "Trebuchet MS", sans-serif';
    ctx.fillStyle=`rgba(255,98,192,${a})`;
    ctx.shadowColor='#ff62c0'; ctx.shadowBlur=20;
    ctx.fillText('CONTROLS REVERSED', W/2, H-58);
    ctx.shadowBlur=0;
    const bw=Math.min(240,W*0.6), pct=G.reverseT/G.reverseMax;
    ctx.fillStyle='rgba(255,98,192,.22)'; ctx.fillRect(W/2-bw/2, H-26, bw, 6);
    ctx.fillStyle='#ff62c0'; ctx.fillRect(W/2-bw/2, H-26, bw*clamp(pct,0,1), 6);
  }

  // wave banner
  if(G.waveBannerT>0){
    const t = G.waveBannerT/140;
    const a = t>0.78? (1-t)/0.22 : Math.min(1, t/0.35);
    ctx.textAlign='center'; ctx.textBaseline='middle';
    ctx.globalAlpha = clamp(a,0,1);
    const fs = Math.min(62, W*0.11);
    ctx.font=`900 ${fs}px "Trebuchet MS", sans-serif`;
    ctx.strokeStyle='rgba(0,0,0,.55)'; ctx.lineWidth=8;
    ctx.strokeText(G.waveBannerTxt, W/2, H*0.30);
    const grd = ctx.createLinearGradient(0,H*0.30-34,0,H*0.30+34);
    grd.addColorStop(0,'#ffffff'); grd.addColorStop(.5,'#8ef7ff'); grd.addColorStop(1,'#c56bff');
    ctx.fillStyle=grd; ctx.shadowColor='#7df9ff'; ctx.shadowBlur=26;
    ctx.fillText(G.waveBannerTxt, W/2, H*0.30);
    ctx.shadowBlur=0;
    if(G.waveBannerSub){
      ctx.font='900 15px "Trebuchet MS", sans-serif';
      ctx.fillStyle='#ffd76b';
      ctx.fillText(G.waveBannerSub, W/2, H*0.30 + fs*0.72);
    }
    ctx.globalAlpha=1;
  }

  // toast
  if(toastT>0){
    ctx.textAlign='center'; ctx.textBaseline='middle';
    ctx.globalAlpha = Math.min(1,toastT/25);
    ctx.font='800 15px "Trebuchet MS", sans-serif';
    ctx.fillStyle='#9fe9ff';
    ctx.fillText(toastTxt, W/2, 34);
    ctx.globalAlpha=1;
  }

  // touch controls
  if(touch.on){
    ctx.globalAlpha = 0.5;
    for(const b of touchButtons()){
      ctx.beginPath(); ctx.arc(b.x,b.y,b.r,0,TAU);
      ctx.strokeStyle = b.id==='pulse'? '#ff8ad4' : '#8ef7ff';
      ctx.lineWidth=2; ctx.stroke();
      ctx.fillStyle='rgba(10,12,28,.5)'; ctx.fill();
      ctx.fillStyle = b.id==='pulse'? '#ff8ad4' : '#8ef7ff';
      ctx.font='800 9px "Trebuchet MS", sans-serif';
      ctx.textAlign='center'; ctx.textBaseline='middle';
      ctx.fillText(b.label, b.x, b.y);
    }
    if(touch.stickId!==-1){
      const dx=touch.cx-touch.ox, dy=touch.cy-touch.oy;
      const m=Math.min(Math.hypot(dx,dy),56), a=Math.atan2(dy,dx);
      ctx.globalAlpha=0.35;
      ctx.strokeStyle='#8ef7ff'; ctx.lineWidth=2;
      ctx.beginPath(); ctx.arc(touch.ox,touch.oy,56,0,TAU); ctx.stroke();
      ctx.fillStyle='rgba(142,247,255,.4)';
      ctx.beginPath(); ctx.arc(touch.ox+Math.cos(a)*m, touch.oy+Math.sin(a)*m, 22,0,TAU); ctx.fill();
    }
    ctx.globalAlpha=1;
  }
  ctx.restore();
}

function drawVignette(){
  ctx.fillStyle = vignetteGrad; ctx.fillRect(0,0,W,H);
  if(holes.length){
    ctx.save();
    ctx.globalAlpha = 0.10 + 0.05*Math.sin(G.time*0.09);
    ctx.fillStyle = dangerGrad; ctx.fillRect(0,0,W,H);
    ctx.restore();
  }
}

function render(){
  ctx.drawImage(bgc, 0,0, W,H);
  drawTwinkle();

  ctx.save();
  ctx.translate(G.shakeX, G.shakeY);

  drawWells();
  drawBubbles();
  drawHoles();
  drawParticles();
  drawRings();
  drawShockwaves();
  drawOrbs();
  drawPowerups();
  drawShards();
  drawSentries();
  drawHunters();
  drawEnemyShots();
  drawBullets();
  drawShip();
  drawFloats();

  ctx.restore();

  if(G.flash>0.004){
    ctx.save();
    ctx.globalAlpha = Math.min(0.7, G.flash);
    ctx.fillStyle = G.flashCol;
    ctx.globalCompositeOperation='lighter';
    ctx.fillRect(0,0,W,H);
    ctx.restore();
  }

  drawVignette();
  if(G.state==='play' || G.state==='pause') drawHUD();
}

/* ============================================================
   ATTRACT MODE (start / game-over backdrop)
   ============================================================ */
function attractTick(){
  if(orbs.length<8 && G.time%14===0){
    orbs.push(makeOrbForAttract());
  }
  for(const o of orbs){
    o.t++; o.a+=o.spin; o.pulse+=0.06; o.wobble+=0.04;
    o.x+=o.vx*0.55; o.y+=o.vy*0.55;
    wrapPos(o);
  }
  for(let i=parts.length-1;i>=0;i--){
    const p=parts[i]; if(--p.life<=0){parts.splice(i,1);continue;}
    p.x+=p.vx;p.y+=p.vy;p.vx*=p.drag;p.vy*=p.drag;
  }
  for(let i=rings.length-1;i>=0;i--){
    const r=rings[i]; if(--r.life<=0){rings.splice(i,1);continue;}
    const t=1-r.life/r.max; r.cur=lerp(r.r,r.r1,1-Math.pow(1-t,2.6));
  }
  G.shake*=0.9; G.shakeX=0; G.shakeY=0; G.flash*=0.9;
  G.time++;
}
function makeOrbForAttract(){
  const o = makeOrb(rnd(W), rnd(H), pick([2,3]), pick(ORB_KEYS));
  o.chainT = -1;
  return o;
}
function renderAttract(){
  ctx.drawImage(bgc, 0,0, W,H);
  drawTwinkle();
  ctx.save(); ctx.globalAlpha=0.9;
  drawParticles(); drawRings();
  drawOrbs();
  ctx.restore();
  ctx.fillStyle = vignetteGrad; ctx.fillRect(0,0,W,H);
  ctx.fillStyle = 'rgba(0,0,0,.22)'; ctx.fillRect(0,0,W,H);
}

/* ============================================================
   LEGEND
   ============================================================ */
(function buildLegend(){
  let html='';
  for(const k of ORB_KEYS){
    const T = ORB_TYPES[k];
    html += '<div class="orbrow">'
      + '<span class="dot" style="background:linear-gradient(135deg,'+T.c1+','+T.c2+');color:'+T.glow+'"></span>'
      + '<span><b>'+T.name+'</b>'+T.desc+'</span></div>';
  }
  document.getElementById('orbLegend').innerHTML = html;
  document.getElementById('orbLegend2').innerHTML = html;
})();

/* ============================================================
   MAIN LOOP — fixed timestep, capped catch-up
   ============================================================ */
let last = performance.now(), acc = 0;
const STEP = 1000/60;
function loop(now){
  requestAnimationFrame(loop);
  let delta = now - last; last = now;
  if(delta > 240) delta = 240;
  frameAvg = frameAvg*0.9 + delta*0.1;      // drives adaptive particles + resolution
  adaptResolution();
  acc += delta;
  let steps = 0;
  while(acc >= STEP && steps < 5){
    acc -= STEP; steps++;
    if(G.state==='play') update();
    else if(G.state==='start' || G.state==='over') attractTick();
    else break;                       // paused: burn the accumulator, run nothing
  }
  if(G.state==='pause') acc = 0;
  if(G.state==='play' || G.state==='pause') render();
  else renderAttract();
}

resize();
syncToggles();
requestAnimationFrame(loop);
</script>
</body>
</html>