[object Object]

← back to Wild Orbs Opus

Wild Orbs: complete single-file canvas arcade shooter

db229b2fe208c416cec891984b343f692aaf891a · 2026-09-08 15:41:57 -0700 · Steve Abrams

Rebuilt from a broken starting artifact. Fixed along the way: undefined
'timestamp' in the fire handler (shooting threw on every press), gameLoop
called with no argument (NaN delta poisoned every position), per-frame
velocities multiplied by dt (everything crawled ~60x too slow), orbs spawned
off-screen at negative coordinates, splice-during-forEach skipping entities,
recursive spawn handlers that multiplied orbs without bound, hex colours
interpolated into rgba(), particles that re-randomised direction every frame,
a game-over overlay that covered the start screen, and a ship heading 90
degrees out of phase with its thrust vector.

Added: 12 distinct orb types with unique silhouettes and on-hit rules, gravity
wells / black holes / stasis bubbles / shockwaves as real force and time fields,
ricocheting shards, homing swarmlings, wrap-aware collision and homing, a glow
sprite cache, motion trails, trauma screen shake, combo scoring, waves with
staggered type unlocks, lives and extra lives, per-type WebAudio synthesis with
a wave-scaled bassline, and start/pause/game-over screens with a live bestiary.

Verified with node --check plus a Playwright suite: every orb behaviour, the
field-effect lifecycles, death to game-over to restart, pause, mid-game resize,
and a 20s soak. Zero console errors, 73fps under load.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NMeFvLtrZNgG2sQnYqmUp

Files touched

Diff

commit db229b2fe208c416cec891984b343f692aaf891a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Sep 8 15:41:57 2026 -0700

    Wild Orbs: complete single-file canvas arcade shooter
    
    Rebuilt from a broken starting artifact. Fixed along the way: undefined
    'timestamp' in the fire handler (shooting threw on every press), gameLoop
    called with no argument (NaN delta poisoned every position), per-frame
    velocities multiplied by dt (everything crawled ~60x too slow), orbs spawned
    off-screen at negative coordinates, splice-during-forEach skipping entities,
    recursive spawn handlers that multiplied orbs without bound, hex colours
    interpolated into rgba(), particles that re-randomised direction every frame,
    a game-over overlay that covered the start screen, and a ship heading 90
    degrees out of phase with its thrust vector.
    
    Added: 12 distinct orb types with unique silhouettes and on-hit rules, gravity
    wells / black holes / stasis bubbles / shockwaves as real force and time fields,
    ricocheting shards, homing swarmlings, wrap-aware collision and homing, a glow
    sprite cache, motion trails, trauma screen shake, combo scoring, waves with
    staggered type unlocks, lives and extra lives, per-type WebAudio synthesis with
    a wave-scaled bassline, and start/pause/game-over screens with a live bestiary.
    
    Verified with node --check plus a Playwright suite: every orb behaviour, the
    field-effect lifecycles, death to game-over to restart, pause, mid-game resize,
    and a 20s soak. Zero console errors, 73fps under load.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_013NMeFvLtrZNgG2sQnYqmUp
---
 .gitignore |    8 +
 README.md  |   42 ++
 index.html | 1759 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 1809 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1924158
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..a03d98e
--- /dev/null
+++ b/README.md
@@ -0,0 +1,42 @@
+# WILD ORBS
+
+Single-file HTML5 canvas arcade shooter. Asteroids' skeleton, but every orb breaks
+a different rule when you shoot it. No assets, no CDNs, no build step — open
+`index.html` in any modern browser.
+
+## Controls
+`WASD` / arrows fly · `S`/`↓` brake · `SPACE` fire (hold) · `P` pause · `M` mute
+
+## The twelve orbs
+| Orb | On hit |
+|---|---|
+| SPLITTER | cracks into smaller, faster splitters (3 size tiers) |
+| HIVE | bursts into 5 homing swarmlings that hunt the ship |
+| WELL | collapses into a gravity well that bends and eats your shots |
+| PHASE | 3 hp — blinks to a new position every time you clip it |
+| SINGULARITY | opens a black hole that devours orbs, bullets and you, then implodes |
+| INVERTER | reverses your controls for 6 seconds |
+| SHATTER | explodes into 8 shards that ricochet off the walls |
+| STASIS | drops a bubble where everything but you moves at 30% speed |
+| CHAIN BOMB | detonates and sets off every orb in range — cascades |
+| MIRROR | clones itself point-mirrored across the screen (2 generations) |
+| RUBBER | 4 hp — bounces off walls instead of wrapping, faster each hit |
+| NOVA | erupts in a shockwave that damages everything it sweeps through |
+
+Types unlock two per wave over the first six waves.
+
+## Systems
+Inertial ship with screen wrap, fire cooldown and recoil · toroidal (wrap-aware)
+distance for homing, gravity and collisions · force fields applied per entity
+(bullets pulled harder than the ship) · localized time dilation from stasis
+bubbles · pre-rendered radial-gradient glow sprites drawn additively · motion-trail
+frame fade · trauma-based screen shake with rotation · combo multiplier, extra life
+every 5,000, localStorage high score · WebAudio: per-orb-type synthesized hit
+sounds, gated thrust noise, and a bassline whose tempo climbs with the wave.
+
+## Verify
+`node --check` on the extracted script, plus a Playwright suite that starts the
+game from the keyboard, exercises every orb type's on-hit behaviour, runs the
+field effects through a full lifecycle, walks the death → game-over → restart →
+pause paths, resizes mid-game and soaks for 20s. Zero console errors; 73fps under
+load in a real browser.
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..ff299f5
--- /dev/null
+++ b/index.html
@@ -0,0 +1,1759 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no">
+<title>WILD ORBS</title>
+<style>
+  :root{
+    --bg:#05060e;
+    --fg:#e9f1ff;
+    --dim:#7d8aa8;
+    --accent:#38f5d0;
+    --accent2:#ff2f6e;
+  }
+  *{box-sizing:border-box}
+  html,body{margin:0;padding:0;height:100%;width:100%;overflow:hidden;background:var(--bg);
+    font-family:"SF Mono",ui-monospace,Menlo,Consolas,"Courier New",monospace;color:var(--fg);
+    -webkit-font-smoothing:antialiased;}
+  canvas#c{display:block;position:fixed;inset:0;}
+  .screen{
+    position:fixed;inset:0;display:none;flex-direction:column;justify-content:center;align-items:center;
+    text-align:center;padding:24px;z-index:10;
+    background:radial-gradient(ellipse at 50% 45%, rgba(8,10,26,.60) 0%, rgba(3,4,10,.90) 72%);
+    backdrop-filter:blur(2px);-webkit-backdrop-filter:blur(2px);
+    animation:fade .28s ease both;
+  }
+  .screen.on{display:flex}
+  @keyframes fade{from{opacity:0}to{opacity:1}}
+  h1{
+    margin:0;font-size:clamp(38px,9vw,104px);letter-spacing:.14em;font-weight:800;
+    background:linear-gradient(180deg,#fff 0%,#8fe9ff 42%,#38f5d0 62%,#0f7f8c 100%);
+    -webkit-background-clip:text;background-clip:text;color:transparent;
+    text-shadow:0 0 60px rgba(56,245,208,.28);
+    line-height:1;
+  }
+  h1 .w{color:transparent}
+  .tag{margin:12px 0 0;color:var(--dim);letter-spacing:.32em;font-size:clamp(9px,1.6vw,13px);text-transform:uppercase}
+  .keys{margin:22px 0 4px;color:#b9c6e2;font-size:13px;line-height:1.9;letter-spacing:.06em}
+  kbd{
+    display:inline-block;min-width:26px;padding:2px 7px;margin:0 2px;border-radius:5px;
+    background:rgba(255,255,255,.07);border:1px solid rgba(255,255,255,.16);
+    border-bottom-width:2px;font-size:11px;color:#fff
+  }
+  .bestiary{
+    display:grid;gap:8px 14px;margin:22px auto 6px;max-width:min(920px,94vw);
+    grid-template-columns:repeat(auto-fit,minmax(190px,1fr));
+    max-height:38vh;overflow:auto;padding:4px 6px;
+  }
+  .bestiary::-webkit-scrollbar{width:6px}
+  .bestiary::-webkit-scrollbar-thumb{background:rgba(255,255,255,.14);border-radius:3px}
+  .chip{display:flex;align-items:center;gap:9px;text-align:left;opacity:.94}
+  .chip canvas{width:34px;height:34px;flex:0 0 34px}
+  .chip .n{font-size:10.5px;letter-spacing:.14em;font-weight:700}
+  .chip .d{font-size:10px;color:var(--dim);letter-spacing:.02em;line-height:1.35}
+  .cta{
+    margin-top:20px;font-size:13px;letter-spacing:.26em;color:var(--accent);
+    animation:pulse 1.5s ease-in-out infinite;text-transform:uppercase
+  }
+  @keyframes pulse{0%,100%{opacity:.45}50%{opacity:1}}
+  button{
+    margin-top:16px;font:inherit;font-size:13px;letter-spacing:.2em;text-transform:uppercase;
+    padding:13px 30px;border-radius:999px;cursor:pointer;color:#04120f;font-weight:700;
+    background:linear-gradient(180deg,#7dffe6,#25d6b4);border:none;
+    box-shadow:0 6px 30px rgba(56,245,208,.32);transition:transform .12s ease, box-shadow .12s ease;
+  }
+  button:hover{transform:translateY(-2px);box-shadow:0 10px 40px rgba(56,245,208,.45)}
+  button:active{transform:translateY(0)}
+  .big{font-size:clamp(30px,7vw,64px);letter-spacing:.1em;margin:0;color:var(--accent2);
+    text-shadow:0 0 44px rgba(255,47,110,.45);font-weight:800}
+  .stats{display:flex;gap:34px;margin:24px 0 4px;flex-wrap:wrap;justify-content:center}
+  .stat .v{font-size:clamp(22px,4vw,38px);font-weight:700;letter-spacing:.04em}
+  .stat .k{font-size:10px;letter-spacing:.24em;color:var(--dim);text-transform:uppercase;margin-top:4px}
+  .newbest{color:#ffd75e;font-size:12px;letter-spacing:.24em;margin-top:8px}
+  .foot{position:fixed;bottom:14px;left:0;right:0;text-align:center;color:#3d4762;font-size:10px;letter-spacing:.2em}
+</style>
+</head>
+<body>
+<canvas id="c"></canvas>
+
+<div class="screen on" id="startScreen">
+  <h1>WILD&nbsp;ORBS</h1>
+  <p class="tag">every orb breaks the rules differently</p>
+  <div class="keys">
+    <kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> / <kbd>&larr;</kbd><kbd>&uarr;</kbd><kbd>&rarr;</kbd> fly &nbsp;·&nbsp;
+    <kbd>SPACE</kbd> fire &nbsp;·&nbsp; <kbd>P</kbd> pause &nbsp;·&nbsp; <kbd>M</kbd> mute
+  </div>
+  <div class="bestiary" id="bestiary"></div>
+  <button onclick="startGame()">LAUNCH</button>
+  <div class="cta">press space to start</div>
+</div>
+
+<div class="screen" id="pauseScreen">
+  <h1 style="font-size:clamp(28px,6vw,64px)">PAUSED</h1>
+  <div class="cta">press P to resume</div>
+</div>
+
+<div class="screen" id="overScreen">
+  <p class="big">GAME OVER</p>
+  <div class="stats">
+    <div class="stat"><div class="v" id="finalScore">0</div><div class="k">score</div></div>
+    <div class="stat"><div class="v" id="finalWave">1</div><div class="k">wave</div></div>
+    <div class="stat"><div class="v" id="finalBest">0</div><div class="k">best</div></div>
+  </div>
+  <div class="newbest" id="newbest" style="display:none">◆ NEW PERSONAL BEST ◆</div>
+  <button onclick="startGame()">RUN IT BACK</button>
+  <div class="cta">press space to restart</div>
+</div>
+
+<div class="foot">WILD ORBS · single-file canvas arcade</div>
+
+<script>
+'use strict';
+/* ==========================================================================
+   WILD ORBS — an Asteroids-shaped shooter where every orb misbehaves.
+   Single file. No assets, no CDNs. WebAudio synthesis, canvas rendering.
+   ========================================================================== */
+
+/* ---------------------------------------------------------------- canvas -- */
+const canvas = document.getElementById('c');
+const ctx = canvas.getContext('2d', { alpha:false });
+let W = 0, H = 0, DPR = 1;
+
+function resize(){
+  DPR = Math.min(window.devicePixelRatio || 1, 2);
+  W = window.innerWidth; H = window.innerHeight;
+  canvas.width  = Math.max(1, Math.floor(W * DPR));
+  canvas.height = Math.max(1, Math.floor(H * DPR));
+  canvas.style.width = W + 'px';
+  canvas.style.height = H + 'px';
+  ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
+  makeStars();
+}
+addEventListener('resize', resize);
+
+/* ------------------------------------------------------------------ math -- */
+const TAU = Math.PI * 2;
+const rand    = (a,b) => a + Math.random() * (b - a);
+const randInt = (a,b) => Math.floor(rand(a, b + 1));
+const pick    = a => a[(Math.random() * a.length) | 0];
+const clamp   = (v,a,b) => v < a ? a : v > b ? b : v;
+const lerp    = (a,b,t) => a + (b - a) * t;
+
+// toroidal (screen-wrapped) shortest delta — makes homing/gravity feel right
+function wd(d, size){ if (d >  size * 0.5) d -= size; else if (d < -size * 0.5) d += size; return d; }
+function dxy(ax, ay, bx, by){ return [ wd(bx - ax, W), wd(by - ay, H) ]; }
+function dist(ax, ay, bx, by){ const dx = wd(bx-ax,W), dy = wd(by-ay,H); return Math.hypot(dx,dy); }
+function wrapPos(o){
+  if (o.x < 0) o.x += W; else if (o.x >= W) o.x -= W;
+  if (o.y < 0) o.y += H; else if (o.y >= H) o.y -= H;
+}
+function hexA(h, a){
+  const n = parseInt(h.slice(1), 16);
+  return 'rgba(' + ((n>>16)&255) + ',' + ((n>>8)&255) + ',' + (n&255) + ',' + a + ')';
+}
+
+/* --------------------------------------------------------- glow sprites --- */
+/* Pre-rendered radial-gradient sprites. Way cheaper than shadowBlur, and the
+   whole game is drawn additively on top of them.                             */
+const glowCache = new Map();
+function glowSprite(color, r){
+  r = Math.max(4, Math.round(r / 4) * 4);         // quantize → bounded cache
+  const key = color + '|' + r;
+  let cv = glowCache.get(key);
+  if (cv) return cv;
+  cv = document.createElement('canvas');
+  const s = r * 2;
+  cv.width = cv.height = s;
+  const g = cv.getContext('2d');
+  const grd = g.createRadialGradient(r, r, 0, r, r, r);
+  grd.addColorStop(0,    hexA(color, 1));
+  grd.addColorStop(0.28, hexA(color, 0.55));
+  grd.addColorStop(0.62, hexA(color, 0.16));
+  grd.addColorStop(1,    hexA(color, 0));
+  g.fillStyle = grd;
+  g.fillRect(0, 0, s, s);
+  glowCache.set(key, cv);
+  return cv;
+}
+function drawGlow(g, x, y, r, color, alpha){
+  const sp = glowSprite(color, r);
+  g.globalAlpha = alpha;
+  g.drawImage(sp, x - r, y - r, r * 2, r * 2);
+  g.globalAlpha = 1;
+}
+
+/* ----------------------------------------------------------------- audio -- */
+const Snd = {
+  ctx:null, master:null, musicGain:null, sfxGain:null,
+  noiseBuf:null, thrustGain:null, muted:false, ready:false,
+  nextBeat:0, beat:0,
+
+  init(){
+    if (this.ready) return;
+    const AC = window.AudioContext || window.webkitAudioContext;
+    if (!AC) return;
+    this.ctx = new AC();
+    this.master = this.ctx.createGain(); this.master.gain.value = 0.62;
+    this.master.connect(this.ctx.destination);
+    this.sfxGain = this.ctx.createGain(); this.sfxGain.gain.value = 1; this.sfxGain.connect(this.master);
+    this.musicGain = this.ctx.createGain(); this.musicGain.gain.value = 0.34; this.musicGain.connect(this.master);
+
+    // shared noise buffer
+    const len = this.ctx.sampleRate * 2;
+    this.noiseBuf = this.ctx.createBuffer(1, len, this.ctx.sampleRate);
+    const d = this.noiseBuf.getChannelData(0);
+    for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1;
+
+    // continuous thrust rumble, gain-gated
+    const src = this.ctx.createBufferSource();
+    src.buffer = this.noiseBuf; src.loop = true;
+    const bp = this.ctx.createBiquadFilter();
+    bp.type = 'bandpass'; bp.frequency.value = 320; bp.Q.value = 0.9;
+    const lp = this.ctx.createBiquadFilter();
+    lp.type = 'lowpass'; lp.frequency.value = 900;
+    this.thrustGain = this.ctx.createGain(); this.thrustGain.gain.value = 0;
+    src.connect(bp); bp.connect(lp); lp.connect(this.thrustGain); this.thrustGain.connect(this.sfxGain);
+    src.start();
+
+    this.nextBeat = this.ctx.currentTime + 0.2;
+    this.ready = true;
+  },
+  resume(){ if (this.ctx && this.ctx.state === 'suspended') this.ctx.resume(); },
+  toggleMute(){
+    this.muted = !this.muted;
+    if (this.master) this.master.gain.setTargetAtTime(this.muted ? 0 : 0.62, this.ctx.currentTime, 0.02);
+  },
+  thrust(on){
+    if (!this.ready) return;
+    this.thrustGain.gain.setTargetAtTime(on ? 0.16 : 0, this.ctx.currentTime, on ? 0.03 : 0.09);
+  },
+
+  /* one-shot oscillator with a pitch envelope */
+  tone(o){
+    if (!this.ready) return;
+    const t = this.ctx.currentTime, dur = o.dur ?? 0.14;
+    const osc = this.ctx.createOscillator();
+    osc.type = o.type || 'square';
+    osc.frequency.setValueAtTime(o.f0, t);
+    if (o.f1 != null){
+      if (o.exp === false) osc.frequency.linearRampToValueAtTime(o.f1, t + dur);
+      else osc.frequency.exponentialRampToValueAtTime(Math.max(1, o.f1), t + dur);
+    }
+    if (o.detune) osc.detune.value = o.detune;
+    const g = this.ctx.createGain();
+    const v = (o.vol ?? 0.2);
+    g.gain.setValueAtTime(0.0001, t + (o.at ?? 0));
+    g.gain.exponentialRampToValueAtTime(v, t + (o.at ?? 0) + 0.008);
+    g.gain.exponentialRampToValueAtTime(0.0001, t + (o.at ?? 0) + dur);
+    let node = osc;
+    if (o.filter){
+      const f = this.ctx.createBiquadFilter();
+      f.type = o.filter; f.frequency.value = o.ff ?? 1200; f.Q.value = o.fq ?? 1;
+      osc.connect(f); node = f;
+    }
+    node.connect(g);
+    if (o.pan != null && this.ctx.createStereoPanner){
+      const p = this.ctx.createStereoPanner(); p.pan.value = o.pan;
+      g.connect(p); p.connect(o.bus || this.sfxGain);
+    } else g.connect(o.bus || this.sfxGain);
+    osc.start(t + (o.at ?? 0));
+    osc.stop(t + (o.at ?? 0) + dur + 0.05);
+  },
+
+  /* one-shot filtered noise burst */
+  noise(o){
+    if (!this.ready) return;
+    const t = this.ctx.currentTime, dur = o.dur ?? 0.2;
+    const src = this.ctx.createBufferSource();
+    src.buffer = this.noiseBuf;
+    src.playbackRate.value = o.rate ?? 1;
+    const f = this.ctx.createBiquadFilter();
+    f.type = o.filter || 'lowpass';
+    f.frequency.setValueAtTime(o.f0 ?? 2000, t + (o.at ?? 0));
+    if (o.f1 != null) f.frequency.exponentialRampToValueAtTime(Math.max(20, o.f1), t + (o.at ?? 0) + dur);
+    f.Q.value = o.q ?? 1;
+    const g = this.ctx.createGain();
+    g.gain.setValueAtTime(0.0001, t + (o.at ?? 0));
+    g.gain.exponentialRampToValueAtTime(o.vol ?? 0.25, t + (o.at ?? 0) + 0.01);
+    g.gain.exponentialRampToValueAtTime(0.0001, t + (o.at ?? 0) + dur);
+    src.connect(f); f.connect(g); g.connect(this.sfxGain);
+    src.start(t + (o.at ?? 0));
+    src.stop(t + (o.at ?? 0) + dur + 0.05);
+  },
+
+  /* --- named sfx --- */
+  shoot(){ this.tone({ type:'square', f0:900, f1:260, dur:0.09, vol:0.10 });
+           this.noise({ filter:'highpass', f0:1800, dur:0.05, vol:0.05 }); },
+  playerHit(){
+    this.noise({ filter:'lowpass', f0:1400, f1:60, dur:0.9, vol:0.4, rate:0.7 });
+    this.tone({ type:'sawtooth', f0:180, f1:28, dur:0.8, vol:0.24 });
+  },
+  respawn(){ this.tone({ type:'triangle', f0:220, f1:880, dur:0.35, vol:0.14 }); },
+  waveUp(){
+    [0,0.09,0.18].forEach((at,i)=> this.tone({ type:'triangle', f0:[440,660,880][i], dur:0.24, vol:0.13, at }));
+  },
+  extraLife(){ [0,0.08,0.16,0.24].forEach((at,i)=> this.tone({type:'square',f0:[523,659,784,1047][i],dur:0.18,vol:0.1,at})); },
+  gameOver(){
+    [0,0.16,0.32,0.5].forEach((at,i)=> this.tone({ type:'sawtooth', f0:[330,262,196,130][i], f1:[300,240,180,60][i], dur:0.5, vol:0.16, at, filter:'lowpass', ff:900 }));
+  },
+
+  /* --- pulsing bassline; tempo climbs with the wave --- */
+  music(){
+    if (!this.ready || state !== 'playing') return;
+    const t = this.ctx.currentTime;
+    const bpm = Math.min(150, 82 + wave * 5);
+    const spb = 60 / bpm;
+    if (this.nextBeat < t) this.nextBeat = t + 0.02;   // never schedule into the past
+    while (this.nextBeat < t + 0.12){
+      const at = Math.max(0, this.nextBeat - t);
+      const step = this.beat % 8;
+      const notes = [55, 55, 82.4, 55, 65.4, 55, 73.4, 61.7];
+      this.tone({ type:'triangle', f0:notes[step], dur:spb*0.85, vol:0.13, at, bus:this.musicGain, filter:'lowpass', ff:420 });
+      if (step % 4 === 0){
+        this.noise({ filter:'lowpass', f0:220, f1:50, dur:0.16, vol:0.16, at });
+      }
+      if (step === 6) this.noise({ filter:'highpass', f0:6000, dur:0.05, vol:0.035, at });
+      this.beat++; this.nextBeat += spb;
+    }
+  }
+};
+
+/* ------------------------------------------------------------ orb design -- */
+/* Each type = a colour identity, a silhouette, and a rule it breaks.         */
+const TYPES = {
+  splitter: {
+    name:'SPLITTER', c:'#ff2f4e', c2:'#ffc0c8', r:34, hp:1, sc:100, spd:1,
+    blurb:'Cracks into smaller, faster splitters',
+    sfx(){ Snd.noise({filter:'bandpass', f0:1400, f1:300, q:2, dur:0.28, vol:0.24});
+           Snd.tone({type:'square', f0:420, f1:120, dur:0.16, vol:0.12}); },
+    destroy(o){
+      if (o.tier <= 1) return;
+      const n = o.tier === 3 ? 3 : 2;
+      for (let i = 0; i < n; i++){
+        const a = rand(0, TAU);
+        const ch = new Orb(o.x + Math.cos(a) * o.r * 0.6, o.y + Math.sin(a) * o.r * 0.6, 'splitter', o.tier - 1);
+        const sp = Math.hypot(o.vx, o.vy) * 1.25 + 60;
+        ch.vx = Math.cos(a) * sp; ch.vy = Math.sin(a) * sp;
+        orbs.push(ch);
+      }
+    }
+  },
+
+  swarm: {
+    name:'HIVE', c:'#8dff3a', c2:'#e6ffb8', r:32, hp:1, sc:150, spd:0.85,
+    blurb:'Bursts into a homing swarm that hunts you',
+    sfx(){ Snd.tone({type:'sawtooth', f0:180, f1:1400, dur:0.3, vol:0.14, filter:'bandpass', ff:900, fq:6});
+           Snd.noise({filter:'bandpass', f0:2600, q:8, dur:0.35, vol:0.1}); },
+    destroy(o){
+      for (let i = 0; i < 5; i++){
+        const a = i / 5 * TAU + rand(-0.3, 0.3);
+        const s = new Orb(o.x + Math.cos(a) * 24, o.y + Math.sin(a) * 24, 'swarmling', 1);
+        s.vx = Math.cos(a) * 150; s.vy = Math.sin(a) * 150;
+        orbs.push(s);
+      }
+    }
+  },
+
+  swarmling: {
+    name:'SWARMLING', c:'#b6ff5e', c2:'#f2ffd6', r:11, hp:1, sc:40, spd:1, hidden:true, life:14,
+    blurb:'', sfx(){ Snd.tone({type:'square', f0:1200, f1:400, dur:0.07, vol:0.07}); },
+    move(o, dt){
+      if (!ship || ship.dead) return;
+      const [dx, dy] = dxy(o.x, o.y, ship.x, ship.y);
+      const d = Math.max(1, Math.hypot(dx, dy));
+      o.vx += (dx / d) * 300 * dt;
+      o.vy += (dy / d) * 300 * dt;
+      const sp = Math.hypot(o.vx, o.vy), max = 235;
+      if (sp > max){ o.vx = o.vx / sp * max; o.vy = o.vy / sp * max; }
+    }
+  },
+
+  gravity: {
+    name:'WELL', c:'#3b6bff', c2:'#b9cdff', r:33, hp:1, sc:200, spd:0.8,
+    blurb:'Collapses into a gravity well that eats your shots',
+    sfx(){ Snd.tone({type:'sine', f0:420, f1:38, dur:0.9, vol:0.3});
+           Snd.noise({filter:'lowpass', f0:800, f1:80, dur:0.7, vol:0.15}); },
+    destroy(o){ effects.push(new GravityWell(o.x, o.y)); }
+  },
+
+  phase: {
+    name:'PHASE', c:'#ffe14d', c2:'#fffbdc', r:30, hp:3, sc:250, spd:1.15,
+    blurb:'Blinks somewhere else every time you clip it',
+    sfx(){ Snd.tone({type:'sine', f0:1600, f1:300, dur:0.18, vol:0.15});
+           Snd.tone({type:'sine', f0:1180, f1:2400, dur:0.18, vol:0.1, detune:12}); },
+    hurt(o){
+      spawnRing(o.x, o.y, o.r * 1.6, '#ffe14d', 0.35);
+      burst(o.x, o.y, '#ffe14d', '#fffbdc', o.r * 0.6, 12);
+      let tries = 0, nx, ny;
+      do { nx = rand(60, W - 60); ny = rand(60, H - 60); tries++; }
+      while (ship && dist(nx, ny, ship.x, ship.y) < 200 && tries < 24);
+      o.x = nx; o.y = ny; o.ghostT = 0.5;
+      spawnRing(o.x, o.y, o.r * 2.2, '#ffe14d', 0.45);
+      Snd.tone({type:'sine', f0:300, f1:1700, dur:0.16, vol:0.12});
+    }
+  },
+
+  blackhole: {
+    name:'SINGULARITY', c:'#a24bff', c2:'#e7c6ff', r:34, hp:1, sc:300, spd:0.7,
+    blurb:'Opens a black hole that devours everything near it',
+    sfx(){ Snd.noise({filter:'lowpass', f0:400, f1:30, dur:1.4, vol:0.36, rate:0.5});
+           Snd.tone({type:'sine', f0:160, f1:18, dur:1.4, vol:0.28}); },
+    destroy(o){ effects.push(new BlackHole(o.x, o.y)); }
+  },
+
+  inverter: {
+    name:'INVERTER', c:'#ff9a1f', c2:'#3bd7ff', r:31, hp:1, sc:200, spd:1,
+    blurb:'Flips your controls for six long seconds',
+    sfx(){ Snd.tone({type:'sawtooth', f0:700, f1:180, dur:0.5, vol:0.2, filter:'lowpass', ff:1400});
+           Snd.tone({type:'sawtooth', f0:180, f1:700, dur:0.5, vol:0.16, filter:'lowpass', ff:1400}); },
+    destroy(o){
+      reverseT = 6;
+      spawnRing(o.x, o.y, 300, '#ff9a1f', 0.7);
+      addShake(0.45);
+      floaters.push(new Floater(o.x, o.y - 30, 'CONTROLS REVERSED!', '#ff9a1f', 1.7, 20));
+    }
+  },
+
+  shatter: {
+    name:'SHATTER', c:'#ff43e0', c2:'#ffd4f7', r:31, hp:1, sc:150, spd:1.1,
+    blurb:'Explodes into shards that ricochet off the walls',
+    sfx(){ Snd.noise({filter:'highpass', f0:2600, dur:0.4, vol:0.24});
+           [0,0.05,0.1].forEach((at,i)=>Snd.tone({type:'triangle', f0:[2400,1900,3100][i], f1:900, dur:0.1, vol:0.08, at})); },
+    destroy(o){
+      const n = 8;
+      for (let i = 0; i < n; i++){
+        const a = i / n * TAU + rand(-0.2, 0.2);
+        shards.push(new Shard(o.x + Math.cos(a) * o.r * 0.5, o.y + Math.sin(a) * o.r * 0.5, a, rand(240, 400)));
+      }
+    }
+  },
+
+  timebubble: {
+    name:'STASIS', c:'#28e6ff', c2:'#d3f8ff', r:32, hp:1, sc:220, spd:0.95,
+    blurb:'Drops a bubble where time crawls',
+    sfx(){ Snd.tone({type:'sine', f0:1500, f1:220, dur:0.9, vol:0.2, filter:'lowpass', ff:2400});
+           Snd.tone({type:'sine', f0:760, f1:110, dur:1.1, vol:0.13, detune:-20}); },
+    destroy(o){ effects.push(new TimeBubble(o.x, o.y)); }
+  },
+
+  chain: {
+    name:'CHAIN BOMB', c:'#ff6a00', c2:'#ffdda8', r:32, hp:1, sc:180, spd:0.95,
+    blurb:'Detonates — and sets off every orb nearby',
+    sfx(){ Snd.noise({filter:'lowpass', f0:1800, f1:70, dur:0.5, vol:0.34, rate:0.8});
+           Snd.tone({type:'triangle', f0:260, f1:40, dur:0.4, vol:0.2}); },
+    destroy(o){ effects.push(new Detonation(o.x, o.y, 200, 0.07)); }
+  },
+
+  mirror: {
+    name:'MIRROR', c:'#cfe4ff', c2:'#ffffff', r:31, hp:1, sc:200, spd:1,
+    blurb:'Clones itself across the screen when broken',
+    sfx(){ Snd.tone({type:'square', f0:1400, f1:900, dur:0.14, vol:0.11, pan:-0.8});
+           Snd.tone({type:'square', f0:1400, f1:900, dur:0.14, vol:0.11, pan:0.8, at:0.07}); },
+    destroy(o){
+      if (o.gen >= 2) return;
+      const nx = W - o.x, ny = H - o.y;
+      const cl = new Orb(nx, ny, 'mirror', 3, o.gen + 1);
+      cl.vx = -o.vx; cl.vy = -o.vy;
+      orbs.push(cl);
+      spawnRing(nx, ny, 90, '#cfe4ff', 0.5);
+    }
+  },
+
+  rubber: {
+    name:'RUBBER', c:'#00ffa2', c2:'#ccffec', r:30, hp:4, sc:280, spd:1.05, bounce:true,
+    blurb:'Bounces off walls — faster with every hit it takes',
+    sfx(){ Snd.tone({type:'sine', f0:180, f1:900, dur:0.16, vol:0.16});
+           Snd.tone({type:'sine', f0:900, f1:200, dur:0.2, vol:0.13, at:0.13}); },
+    hurt(o){
+      const sp = Math.hypot(o.vx, o.vy) * 1.5 + 40;
+      const a = Math.atan2(o.vy, o.vx) + rand(-0.4, 0.4);
+      o.vx = Math.cos(a) * sp; o.vy = Math.sin(a) * sp;
+      o.squish = 1;
+      spawnRing(o.x, o.y, o.r * 1.7, '#00ffa2', 0.3);
+      Snd.tone({type:'sine', f0:300, f1:1100, dur:0.1, vol:0.12});
+    }
+  },
+
+  nova: {
+    name:'NOVA', c:'#ffd23d', c2:'#ffffff', r:33, hp:1, sc:240, spd:0.9,
+    blurb:'Erupts in a shockwave that guts everything it touches',
+    sfx(){ Snd.noise({filter:'bandpass', f0:400, f1:5000, q:1.2, dur:0.55, vol:0.3});
+           [0,0.02,0.04].forEach((at,i)=>Snd.tone({type:'sawtooth', f0:[220,330,440][i], f1:[110,165,220][i], dur:0.6, vol:0.12, at, filter:'lowpass', ff:2200})); },
+    destroy(o){ effects.push(new Shockwave(o.x, o.y, 380, '#ffd23d')); addShake(0.6); }
+  }
+};
+const SPAWNABLE = Object.keys(TYPES).filter(k => !TYPES[k].hidden);
+
+/* Which orbs show up, wave by wave. */
+const UNLOCKS = [
+  ['splitter','shatter'],
+  ['chain','swarm'],
+  ['rubber','phase'],
+  ['gravity','mirror'],
+  ['timebubble','nova'],
+  ['blackhole','inverter']
+];
+function poolFor(w){
+  const p = [];
+  for (let i = 0; i < UNLOCKS.length; i++) if (w >= i + 1) p.push(...UNLOCKS[i]);
+  return p.length ? p : UNLOCKS[0];
+}
+
+/* ------------------------------------------------------- orb silhouettes -- */
+/* Pure drawing, no globals — reused by the game and by the start-screen chips */
+function drawOrbBody(g, key, r, t, o){
+  const T = TYPES[key];
+  g.save();
+  switch(key){
+
+  case 'splitter': {
+    g.rotate(t * 0.6);
+    g.beginPath();
+    const n = 10;
+    for (let i = 0; i < n; i++){
+      const a = i / n * TAU;
+      const rr = r * (i % 2 ? 0.6 : 1);
+      i ? g.lineTo(Math.cos(a)*rr, Math.sin(a)*rr) : g.moveTo(Math.cos(a)*rr, Math.sin(a)*rr);
+    }
+    g.closePath();
+    const grd = g.createRadialGradient(0,0,r*0.1,0,0,r);
+    grd.addColorStop(0, T.c2); grd.addColorStop(0.5, T.c); grd.addColorStop(1, '#5a0512');
+    g.fillStyle = grd; g.fill();
+    g.strokeStyle = T.c2; g.lineWidth = 1.6; g.stroke();
+    g.beginPath(); g.arc(0,0,r*0.26,0,TAU); g.fillStyle = '#fff'; g.globalAlpha = 0.85; g.fill();
+    break;
+  }
+
+  case 'swarm': {
+    g.beginPath(); g.arc(0,0,r*0.44,0,TAU);
+    const grd = g.createRadialGradient(0,0,0,0,0,r*0.44);
+    grd.addColorStop(0,'#fff'); grd.addColorStop(1,T.c);
+    g.fillStyle = grd; g.fill();
+    for (let i = 0; i < 6; i++){
+      const a = t * 1.7 + i / 6 * TAU;
+      const rr = r * (0.78 + 0.1 * Math.sin(t * 3 + i));
+      const x = Math.cos(a)*rr, y = Math.sin(a)*rr;
+      g.beginPath(); g.moveTo(0,0); g.lineTo(x,y);
+      g.strokeStyle = hexA(T.c, 0.28); g.lineWidth = 1; g.stroke();
+      g.beginPath(); g.arc(x, y, r*0.17, 0, TAU);
+      g.fillStyle = T.c2; g.fill();
+    }
+    break;
+  }
+
+  case 'swarmling': {
+    g.rotate(o ? Math.atan2(o.vy, o.vx) : t * 3);
+    g.beginPath(); g.moveTo(r*1.1, 0); g.lineTo(-r*0.7, r*0.7); g.lineTo(-r*0.3, 0); g.lineTo(-r*0.7, -r*0.7);
+    g.closePath(); g.fillStyle = T.c; g.fill();
+    g.strokeStyle = '#fff'; g.lineWidth = 0.8; g.stroke();
+    break;
+  }
+
+  case 'gravity': {
+    g.beginPath(); g.arc(0,0,r,0,TAU);
+    const grd = g.createRadialGradient(0,0,r*0.15,0,0,r);
+    grd.addColorStop(0,'#0a0f2e'); grd.addColorStop(0.7,'#12205e'); grd.addColorStop(1, T.c);
+    g.fillStyle = grd; g.fill();
+    for (let i = 0; i < 3; i++){
+      const ph = ((t * 0.55) + i / 3) % 1;
+      const rr = r * (1.35 - ph * 1.15);
+      g.beginPath(); g.arc(0, 0, Math.max(1, rr), 0, TAU);
+      g.strokeStyle = hexA(T.c2, 0.75 * ph); g.lineWidth = 1.8; g.stroke();
+    }
+    g.beginPath(); g.arc(0,0,r*0.2,0,TAU); g.fillStyle = '#fff'; g.fill();
+    break;
+  }
+
+  case 'phase': {
+    for (let i = 1; i <= 2; i++){
+      const off = Math.sin(t * 5 + i) * r * 0.5;
+      g.beginPath(); g.arc(off, -off * 0.4, r * 0.9, 0, TAU);
+      g.fillStyle = hexA(T.c, 0.13); g.fill();
+    }
+    g.beginPath(); g.arc(0,0,r,0,TAU);
+    const grd = g.createRadialGradient(0,0,0,0,0,r);
+    grd.addColorStop(0, hexA(T.c2, 0.9)); grd.addColorStop(0.6, hexA(T.c, 0.5)); grd.addColorStop(1, hexA(T.c, 0.08));
+    g.fillStyle = grd; g.fill();
+    g.save(); g.rotate(t * 1.4);
+    g.setLineDash([6, 7]); g.beginPath(); g.arc(0,0,r*0.95,0,TAU);
+    g.strokeStyle = T.c2; g.lineWidth = 1.8; g.stroke();
+    g.restore();
+    break;
+  }
+
+  case 'blackhole': {
+    g.save(); g.rotate(t * 1.5);
+    for (let i = 0; i < 24; i++){
+      const a0 = i / 24 * TAU;
+      g.beginPath();
+      g.arc(0, 0, r * 0.86, a0, a0 + 0.2);
+      g.strokeStyle = hexA(i % 2 ? T.c2 : T.c, 0.35 + 0.6 * Math.abs(Math.sin(a0 * 2 + t)));
+      g.lineWidth = r * 0.24; g.stroke();
+    }
+    g.restore();
+    g.beginPath(); g.arc(0,0,r*0.62,0,TAU); g.fillStyle = '#000'; g.fill();
+    g.strokeStyle = hexA(T.c2, 0.9); g.lineWidth = 1.4; g.stroke();
+    break;
+  }
+
+  case 'inverter': {
+    g.rotate(Math.sin(t * 1.5) * 0.55);
+    for (const half of [0, 1]){
+      g.beginPath(); g.moveTo(0, 0);
+      g.arc(0, 0, r * 0.9, -Math.PI/2 + half * Math.PI, Math.PI/2 + half * Math.PI);
+      g.closePath();
+      const grd = g.createLinearGradient(0, -r, 0, r);
+      grd.addColorStop(0, half ? T.c2 : T.c);
+      grd.addColorStop(1, half ? '#07394d' : '#4a2400');
+      g.fillStyle = grd; g.fill();
+    }
+    g.save(); g.rotate(t * 2.2);
+    g.beginPath(); g.arc(0,0,r*0.62, 0.35, 2.5); g.strokeStyle = T.c2; g.lineWidth = r*0.15; g.stroke();
+    g.restore();
+    g.save(); g.rotate(-t * 2.7);
+    g.beginPath(); g.arc(0,0,r*0.62, 0.35 + Math.PI, 2.5 + Math.PI); g.strokeStyle = T.c; g.lineWidth = r*0.15; g.stroke();
+    g.restore();
+    g.fillStyle = '#fff';
+    for (const s of [-1, 1]){
+      g.beginPath();
+      g.moveTo(s * r * 0.16, -r * 0.22); g.lineTo(s * r * 0.46, 0); g.lineTo(s * r * 0.16, r * 0.22);
+      g.closePath(); g.fill();
+    }
+    break;
+  }
+
+  case 'shatter': {
+    g.rotate(t * 0.4);
+    const pts = [];
+    const JAG = [1, 0.62, 0.94, 0.55, 1.05, 0.68, 0.86];
+    for (let i = 0; i < JAG.length; i++){
+      const a = i / JAG.length * TAU;
+      const rr = r * JAG[i];
+      pts.push([Math.cos(a)*rr, Math.sin(a)*rr]);
+    }
+    g.beginPath(); pts.forEach((p,i)=> i ? g.lineTo(p[0],p[1]) : g.moveTo(p[0],p[1])); g.closePath();
+    const grd = g.createLinearGradient(-r,-r,r,r);
+    grd.addColorStop(0, T.c2); grd.addColorStop(0.45, T.c); grd.addColorStop(1, '#520045');
+    g.fillStyle = grd; g.fill();
+    g.strokeStyle = T.c2; g.lineWidth = 1.4; g.stroke();
+    g.beginPath();
+    pts.forEach(p => { g.moveTo(0,0); g.lineTo(p[0],p[1]); });
+    g.strokeStyle = hexA(T.c2, 0.4); g.lineWidth = 0.8; g.stroke();
+    break;
+  }
+
+  case 'timebubble': {
+    g.beginPath(); g.arc(0,0,r,0,TAU);
+    const grd = g.createRadialGradient(-r*0.3,-r*0.3,r*0.05,0,0,r);
+    grd.addColorStop(0,'#ffffff'); grd.addColorStop(0.35,hexA(T.c,0.75)); grd.addColorStop(1,hexA(T.c,0.16));
+    g.fillStyle = grd; g.fill();
+    g.strokeStyle = hexA('#ffffff',0.75); g.lineWidth = 1.4; g.stroke();
+    g.strokeStyle = '#04303a'; g.lineWidth = 2.2; g.lineCap = 'round';
+    g.beginPath(); g.moveTo(0,0); g.lineTo(Math.cos(t*2 - 1.57) * r * 0.55, Math.sin(t*2 - 1.57) * r * 0.55); g.stroke();
+    g.beginPath(); g.moveTo(0,0); g.lineTo(Math.cos(t*0.5 - 1.57) * r * 0.34, Math.sin(t*0.5 - 1.57) * r * 0.34); g.stroke();
+    break;
+  }
+
+  case 'chain': {
+    const bl = 0.5 + 0.5 * Math.sin(t * 9);
+    g.beginPath(); g.arc(0,0,r*0.72,0,TAU);
+    const grd = g.createRadialGradient(0,0,0,0,0,r*0.72);
+    grd.addColorStop(0,'#fff8e6'); grd.addColorStop(0.4, T.c2); grd.addColorStop(1, T.c);
+    g.fillStyle = grd; g.fill();
+    for (let i = 0; i < 2; i++){
+      const ph = ((t * 1.1) + i / 2) % 1;
+      g.beginPath(); g.arc(0,0, r * (0.75 + ph * 0.5), 0, TAU);
+      g.strokeStyle = hexA(T.c, (1 - ph) * 0.8); g.lineWidth = 2; g.stroke();
+    }
+    g.beginPath(); g.arc(0,0,r*0.28,0,TAU);
+    g.fillStyle = 'rgba(255,255,255,' + (0.35 + bl * 0.65) + ')'; g.fill();
+    break;
+  }
+
+  case 'mirror': {
+    g.rotate(t * 0.5);
+    g.beginPath(); g.arc(0,0,r,0,TAU);
+    const grd = g.createLinearGradient(-r,-r,r,r);
+    grd.addColorStop(0,'#ffffff'); grd.addColorStop(0.3,'#7f97b8');
+    grd.addColorStop(0.5,'#ffffff'); grd.addColorStop(0.7,'#5d7395'); grd.addColorStop(1,'#ffffff');
+    g.fillStyle = grd; g.fill();
+    g.beginPath(); g.moveTo(0,-r); g.lineTo(0,r);
+    g.strokeStyle = 'rgba(10,16,30,.55)'; g.lineWidth = 1.6; g.stroke();
+    const sw = ((t * 0.6) % 1) * 2 - 1;
+    g.save(); g.beginPath(); g.arc(0,0,r,0,TAU); g.clip();
+    g.beginPath(); g.moveTo(sw*r - r*0.2, -r); g.lineTo(sw*r + r*0.2, -r); g.lineTo(sw*r + r*0.5, r); g.lineTo(sw*r + r*0.1, r);
+    g.closePath(); g.fillStyle = 'rgba(255,255,255,.55)'; g.fill();
+    g.restore();
+    g.beginPath(); g.arc(0,0,r,0,TAU); g.strokeStyle = '#fff'; g.lineWidth = 1.3; g.stroke();
+    break;
+  }
+
+  case 'rubber': {
+    const sq = o ? (o.squish || 0) : 0.35 + 0.35 * Math.sin(t * 4);
+    const a = o ? Math.atan2(o.vy, o.vx) : t;
+    g.rotate(a);
+    g.beginPath();
+    g.ellipse(0, 0, r * (1 + sq * 0.35), r * (1 - sq * 0.3), 0, 0, TAU);
+    const grd = g.createRadialGradient(-r*0.3,-r*0.3,r*0.08,0,0,r);
+    grd.addColorStop(0,'#ffffff'); grd.addColorStop(0.35, T.c); grd.addColorStop(1,'#00543a');
+    g.fillStyle = grd; g.fill();
+    g.strokeStyle = T.c2; g.lineWidth = 1.6; g.stroke();
+    if (o && o.hp != null){
+      for (let i = 0; i < o.hp; i++){
+        g.beginPath(); g.arc(-r*0.35 + i * r*0.26, r*0.42, r*0.09, 0, TAU);
+        g.fillStyle = '#022'; g.fill();
+      }
+    }
+    break;
+  }
+
+  case 'nova': {
+    g.rotate(t * 0.9);
+    const spikes = 8;
+    g.beginPath();
+    for (let i = 0; i < spikes * 2; i++){
+      const a = i / (spikes * 2) * TAU;
+      const rr = i % 2 ? r * 0.42 : r * (1 + 0.14 * Math.sin(t * 6 + i));
+      i ? g.lineTo(Math.cos(a)*rr, Math.sin(a)*rr) : g.moveTo(Math.cos(a)*rr, Math.sin(a)*rr);
+    }
+    g.closePath();
+    const grd = g.createRadialGradient(0,0,0,0,0,r);
+    grd.addColorStop(0,'#ffffff'); grd.addColorStop(0.4, T.c2); grd.addColorStop(1, T.c);
+    g.fillStyle = grd; g.fill();
+    g.beginPath(); g.arc(0,0,r*0.3,0,TAU); g.fillStyle = '#fff'; g.fill();
+    break;
+  }
+
+  default: {
+    g.beginPath(); g.arc(0,0,r,0,TAU); g.fillStyle = T.c; g.fill();
+  }}
+  g.restore();
+}
+
+/* ---------------------------------------------------------------- world --- */
+let state = 'start';           // start | playing | paused | gameover
+let score = 0, lives = 3, wave = 0, nextExtra = 5000;
+let best = parseInt(localStorage.getItem('wildorbs.best') || '0', 10) || 0;
+let orbs = [], bullets = [], shards = [], particles = [], effects = [], floaters = [], stars = [];
+let ship = null;
+let shake = 0, reverseT = 0, hitFlash = 0, waveTransition = 0, gameOverDelay = 0;
+let combo = 0, comboTimer = 0, elapsed = 0;
+let bubbles = [];              // cached time-bubbles for the slow-field lookup
+
+const PARTICLE_CAP = 900;
+function addShake(v){ shake = clamp(shake + v, 0, 1.2); }
+function comboMult(){ return clamp(1 + Math.floor(combo / 4), 1, 8); }
+
+/* ------------------------------------------------------------------ ship -- */
+class Ship {
+  constructor(){
+    this.x = W/2; this.y = H/2; this.a = -Math.PI/2;
+    this.vx = 0; this.vy = 0; this.r = 13;
+    this.cool = 0; this.invuln = 3; this.dead = false; this.deadT = 0;
+    this.thrusting = false; this.flame = 0;
+  }
+  update(dt){
+    if (this.dead){
+      this.deadT -= dt;
+      if (this.deadT <= 0){
+        if (lives <= 0){ endGame(); }
+        else { this.respawn(); }
+      }
+      return;
+    }
+    const rev = reverseT > 0 ? -1 : 1;
+    let turn = 0;
+    if (keys['ArrowLeft']  || keys['KeyA']) turn -= 1;
+    if (keys['ArrowRight'] || keys['KeyD']) turn += 1;
+    this.a += turn * rev * 3.7 * dt;
+
+    this.thrusting = !!(keys['ArrowUp'] || keys['KeyW']);
+    const brake = !!(keys['ArrowDown'] || keys['KeyS']);
+    if (this.thrusting){
+      const acc = 430 * rev;
+      this.vx += Math.cos(this.a) * acc * dt;
+      this.vy += Math.sin(this.a) * acc * dt;
+      this.flame = Math.min(1, this.flame + dt * 8);
+      if (Math.random() < dt * 60){
+        const ba = this.a + Math.PI + rand(-0.35, 0.35);
+        addParticle(this.x + Math.cos(ba) * 14, this.y + Math.sin(ba) * 14,
+          Math.cos(ba) * rand(70, 190) + this.vx * 0.3, Math.sin(ba) * rand(70, 190) + this.vy * 0.3,
+          rand(2.5, 5), rand(0.25, 0.5), rev > 0 ? '#38f5d0' : '#ff9a1f');
+      }
+    } else this.flame = Math.max(0, this.flame - dt * 6);
+    Snd.thrust(this.thrusting && state === 'playing');
+
+    if (brake){ this.vx *= Math.pow(0.15, dt); this.vy *= Math.pow(0.15, dt); }
+
+    applyFields(this, dt, 'ship');
+
+    const drag = Math.pow(0.55, dt);
+    this.vx *= drag; this.vy *= drag;
+    const sp = Math.hypot(this.vx, this.vy), max = 520;
+    if (sp > max){ this.vx = this.vx / sp * max; this.vy = this.vy / sp * max; }
+
+    const tf = slowAt(this.x, this.y, true);
+    this.x += this.vx * dt * tf;
+    this.y += this.vy * dt * tf;
+    wrapPos(this);
+
+    if (this.invuln > 0) this.invuln -= dt;
+    if (this.cool > 0) this.cool -= dt;
+    if (keys['Space'] && this.cool <= 0) this.fire();
+  }
+  fire(){
+    this.cool = 0.155;
+    const nx = Math.cos(this.a), ny = Math.sin(this.a);
+    const b = new Bullet(this.x + nx * 16, this.y + ny * 16,
+                         nx * 660 + this.vx * 0.35, ny * 660 + this.vy * 0.35);
+    bullets.push(b);
+    this.vx -= nx * 26; this.vy -= ny * 26;               // recoil
+    for (let i = 0; i < 5; i++){
+      const a = this.a + rand(-0.5, 0.5);
+      addParticle(this.x + nx * 16, this.y + ny * 16,
+        Math.cos(a) * rand(80, 260), Math.sin(a) * rand(80, 260), rand(1.6, 3), rand(0.1, 0.22), '#ffe98a');
+    }
+    Snd.shoot();
+  }
+  kill(){
+    if (this.dead || this.invuln > 0) return;
+    this.dead = true; this.deadT = 1.5;
+    lives--;
+    hitFlash = 1; addShake(1.0);
+    Snd.playerHit(); Snd.thrust(false);
+    for (let i = 0; i < 70; i++){
+      const a = rand(0, TAU), s = rand(60, 520);
+      addParticle(this.x, this.y, Math.cos(a)*s, Math.sin(a)*s, rand(2, 6), rand(0.5, 1.3),
+        pick(['#ffffff','#38f5d0','#ff2f6e','#ffe98a']));
+    }
+    spawnRing(this.x, this.y, 240, '#ffffff', 0.8);
+    combo = 0;
+  }
+  respawn(){
+    this.dead = false; this.x = W/2; this.y = H/2;
+    this.vx = this.vy = 0; this.a = -Math.PI/2; this.invuln = 3;
+    Snd.respawn();
+    spawnRing(this.x, this.y, 140, '#38f5d0', 0.6);
+  }
+  draw(g){
+    if (this.dead) return;
+    const blink = this.invuln > 0 && (Math.floor(elapsed * 12) % 2 === 0);
+    g.save();
+    g.translate(this.x, this.y);
+    g.rotate(this.a);
+    if (this.flame > 0.02){
+      const fl = this.flame * (0.7 + Math.random() * 0.5);
+      g.beginPath();
+      g.moveTo(-9, -5.5); g.lineTo(-9 - 26 * fl, 0); g.lineTo(-9, 5.5);
+      g.closePath();
+      const grd = g.createLinearGradient(-9, 0, -9 - 30 * fl, 0);
+      grd.addColorStop(0, reverseT > 0 ? '#ffd08a' : '#bffff2');
+      grd.addColorStop(0.4, reverseT > 0 ? '#ff9a1f' : '#38f5d0');
+      grd.addColorStop(1, 'rgba(0,0,0,0)');
+      g.fillStyle = grd; g.fill();
+    }
+    if (!blink){
+      g.beginPath();
+      g.moveTo(17, 0); g.lineTo(-11, 10.5); g.lineTo(-5, 0); g.lineTo(-11, -10.5);
+      g.closePath();
+      const grd = g.createLinearGradient(-11, 0, 17, 0);
+      grd.addColorStop(0, '#123a4d'); grd.addColorStop(0.6, '#d8fbff'); grd.addColorStop(1, '#ffffff');
+      g.fillStyle = grd; g.fill();
+      g.strokeStyle = '#8ff6ff'; g.lineWidth = 1.4; g.stroke();
+      g.beginPath(); g.arc(2, 0, 3, 0, TAU); g.fillStyle = '#38f5d0'; g.fill();
+    }
+    g.restore();
+    if (this.invuln > 0){
+      const p = 0.35 + 0.25 * Math.sin(elapsed * 9);
+      g.beginPath(); g.arc(this.x, this.y, 26 + Math.sin(elapsed * 5) * 2, 0, TAU);
+      g.strokeStyle = hexA('#38f5d0', p); g.lineWidth = 1.6; g.stroke();
+    }
+  }
+}
+
+/* --------------------------------------------------------------- bullet --- */
+class Bullet {
+  constructor(x, y, vx, vy){
+    this.x = x; this.y = y; this.vx = vx; this.vy = vy;
+    this.life = 1.25; this.dead = false; this.trail = [];
+  }
+  update(dt){
+    applyFields(this, dt, 'bullet');
+    const tf = slowAt(this.x, this.y);
+    this.x += this.vx * dt * tf; this.y += this.vy * dt * tf;
+    this.trail.push(this.x, this.y);
+    if (this.trail.length > 12) this.trail.splice(0, 2);
+    wrapPos(this);
+    this.life -= dt;
+    if (this.life <= 0) this.dead = true;
+  }
+  draw(g){
+    const n = this.trail.length;
+    if (n >= 6){
+      g.beginPath(); let started = false;
+      for (let i = 0; i < n; i += 2){
+        const px = this.trail[i], py = this.trail[i+1];
+        if (Math.abs(px - this.x) > W * 0.5 || Math.abs(py - this.y) > H * 0.5){ started = false; continue; }
+        if (started) g.lineTo(px, py); else { g.moveTo(px, py); started = true; }
+      }
+      g.lineTo(this.x, this.y);
+      g.strokeStyle = 'rgba(255,233,138,.30)'; g.lineWidth = 2.2; g.lineCap = 'round'; g.stroke();
+    }
+    for (let i = 0; i < n; i += 2){
+      const t = (i / Math.max(2, n - 2));
+      const px = this.trail[i], py = this.trail[i+1];
+      if (Math.abs(px - this.x) > W * 0.5 || Math.abs(py - this.y) > H * 0.5) continue;
+      drawGlow(g, px, py, 4 + t * 4, '#ffe98a', 0.1 + t * 0.25);
+    }
+    drawGlow(g, this.x, this.y, 14, '#fff3b0', 0.85);
+    g.beginPath(); g.arc(this.x, this.y, 2.4, 0, TAU);
+    g.fillStyle = '#ffffff'; g.fill();
+  }
+}
+
+/* ---------------------------------------------------------------- shard --- */
+class Shard {
+  constructor(x, y, a, sp){
+    this.x = x; this.y = y;
+    this.vx = Math.cos(a) * sp; this.vy = Math.sin(a) * sp;
+    this.r = 7; this.rot = rand(0, TAU); this.spin = rand(-9, 9);
+    this.life = 7.5; this.dead = false;
+  }
+  update(dt){
+    const tf = slowAt(this.x, this.y);
+    applyFields(this, dt, 'orb');
+    this.x += this.vx * dt * tf; this.y += this.vy * dt * tf;
+    this.rot += this.spin * dt * tf;
+    if (this.x < this.r){ this.x = this.r; this.vx = Math.abs(this.vx); this.bounce(); }
+    if (this.x > W - this.r){ this.x = W - this.r; this.vx = -Math.abs(this.vx); this.bounce(); }
+    if (this.y < this.r){ this.y = this.r; this.vy = Math.abs(this.vy); this.bounce(); }
+    if (this.y > H - this.r){ this.y = H - this.r; this.vy = -Math.abs(this.vy); this.bounce(); }
+    this.life -= dt;
+    if (this.life <= 0){ this.dead = true; burst(this.x, this.y, '#ff43e0', '#ffd4f7', 8, 6); }
+    if (Math.random() < dt * 22)
+      addParticle(this.x, this.y, rand(-30,30), rand(-30,30), rand(1,2.4), rand(0.15,0.35), '#ff43e0');
+  }
+  bounce(){
+    Snd.tone({type:'square', f0:1500, f1:600, dur:0.05, vol:0.045});
+    burst(this.x, this.y, '#ff43e0', '#ffd4f7', 8, 4);
+  }
+  draw(g){
+    g.save(); g.translate(this.x, this.y); g.rotate(this.rot);
+    g.beginPath(); g.moveTo(9, 0); g.lineTo(-5, 6); g.lineTo(-2, 0); g.lineTo(-5, -6);
+    g.closePath();
+    g.fillStyle = '#ff43e0'; g.fill();
+    g.strokeStyle = '#ffd4f7'; g.lineWidth = 1; g.stroke();
+    g.restore();
+    drawGlow(g, this.x, this.y, 16, '#ff43e0', 0.35);
+  }
+}
+
+/* ------------------------------------------------------------------ orb --- */
+const TIER_SCALE = { 1:0.48, 2:0.72, 3:1 };
+class Orb {
+  constructor(x, y, type, tier = 3, gen = 0){
+    const T = TYPES[type];
+    this.x = x; this.y = y; this.type = type; this.tier = tier; this.gen = gen;
+    this.r = T.r * (TIER_SCALE[tier] || 1);
+    this.hp = T.hp; this.maxhp = T.hp;
+    this.spin = rand(0, TAU); this.spinV = rand(-0.9, 0.9);
+    this.flash = 0; this.dead = false; this.squish = 0; this.ghostT = 0;
+    this.life = T.life || 0;
+    const a = rand(0, TAU);
+    const base = rand(42, 92) * (T.spd || 1) * (1 + wave * 0.05) * (tier < 3 ? 1.25 : 1);
+    this.vx = Math.cos(a) * base; this.vy = Math.sin(a) * base;
+    this.born = 0.35;                                        // brief spawn-in
+  }
+  update(dt){
+    const T = TYPES[this.type];
+    if (T.move) T.move(this, dt);
+    applyFields(this, dt, 'orb');
+    const tf = slowAt(this.x, this.y);
+    this.x += this.vx * dt * tf;
+    this.y += this.vy * dt * tf;
+    this.spin += this.spinV * dt * tf;
+    if (T.bounce){
+      if (this.x < this.r){ this.x = this.r; this.vx = Math.abs(this.vx); this.wallHit(); }
+      if (this.x > W - this.r){ this.x = W - this.r; this.vx = -Math.abs(this.vx); this.wallHit(); }
+      if (this.y < this.r){ this.y = this.r; this.vy = Math.abs(this.vy); this.wallHit(); }
+      if (this.y > H - this.r){ this.y = H - this.r; this.vy = -Math.abs(this.vy); this.wallHit(); }
+    } else wrapPos(this);
+    if (this.flash > 0) this.flash -= dt;
+    if (this.ghostT > 0) this.ghostT -= dt;
+    if (this.squish > 0) this.squish = Math.max(0, this.squish - dt * 2.4);
+    if (this.born > 0) this.born -= dt;
+    if (this.life > 0){
+      this.life -= dt;
+      if (this.life <= 0){ this.dead = true; burst(this.x, this.y, TYPES[this.type].c, TYPES[this.type].c2, this.r, 10); }
+    }
+    if (this.type === 'swarmling' && Math.random() < dt * 30)
+      addParticle(this.x, this.y, -this.vx*0.2, -this.vy*0.2, rand(1,2.2), rand(0.15,0.3), '#b6ff5e');
+  }
+  wallHit(){
+    this.squish = 1;
+    burst(this.x, this.y, TYPES[this.type].c, TYPES[this.type].c2, this.r * 0.5, 6);
+    Snd.tone({type:'sine', f0:420, f1:180, dur:0.08, vol:0.07});
+  }
+  draw(g){
+    const T = TYPES[this.type];
+    const spawnScale = this.born > 0 ? lerp(0.2, 1, 1 - this.born / 0.35) : 1;
+    const r = this.r * spawnScale;
+    drawGlow(g, this.x, this.y, r * 2.3, T.c, 0.30 + (this.flash > 0 ? 0.4 : 0));
+    if (this.ghostT > 0) drawGlow(g, this.x, this.y, r * 3.4, T.c2, this.ghostT * 0.5);
+    g.save();
+    g.translate(this.x, this.y);
+    g.scale(spawnScale, spawnScale);
+    drawOrbBody(g, this.type, this.r, elapsed + this.spin, this);
+    if (this.flash > 0){
+      g.globalCompositeOperation = 'lighter';
+      g.beginPath(); g.arc(0, 0, this.r, 0, TAU);
+      g.fillStyle = 'rgba(255,255,255,' + clamp(this.flash * 5, 0, 0.75) + ')'; g.fill();
+      g.globalCompositeOperation = 'source-over';
+    }
+    g.restore();
+    // hp pips for multi-hit orbs
+    if (this.maxhp > 1 && this.type !== 'rubber'){
+      for (let i = 0; i < this.maxhp; i++){
+        g.beginPath();
+        g.arc(this.x - (this.maxhp - 1) * 4 + i * 8, this.y - r - 10, 2.2, 0, TAU);
+        g.fillStyle = i < this.hp ? T.c2 : 'rgba(255,255,255,.18)';
+        g.fill();
+      }
+    }
+  }
+}
+
+/* --------------------------------------------------------------- effects -- */
+class GravityWell {
+  constructor(x, y){ this.x = x; this.y = y; this.t = 0; this.life = 6.5; this.R = 340; this.dead = false; }
+  update(dt){
+    this.t += dt;
+    if (this.t > this.life) this.dead = true;
+    if (Math.random() < dt * 30){
+      const a = rand(0, TAU), d = rand(this.R * 0.5, this.R);
+      addParticle(this.x + Math.cos(a)*d, this.y + Math.sin(a)*d, -Math.cos(a)*160, -Math.sin(a)*160,
+        rand(1.5,3), rand(0.5,1.0), '#7fa4ff');
+    }
+  }
+  field(e, dt, kind){
+    const [dx, dy] = dxy(e.x, e.y, this.x, this.y);
+    const d = Math.hypot(dx, dy);
+    if (d > this.R || d < 1) return;
+    const fall = 1 - d / this.R;
+    const k = kind === 'bullet' ? 1500 : kind === 'ship' ? 470 : 300;
+    const f = k * fall * fall * dt;
+    e.vx += (dx / d) * f; e.vy += (dy / d) * f;
+  }
+  draw(g){
+    const fade = clamp(Math.min(this.t * 3, (this.life - this.t) * 2), 0, 1);
+    drawGlow(g, this.x, this.y, this.R * 0.8, '#3b6bff', 0.14 * fade);
+    for (let i = 0; i < 4; i++){
+      const ph = ((this.t * 0.5) + i / 4) % 1;
+      g.beginPath(); g.arc(this.x, this.y, this.R * (1 - ph), 0, TAU);
+      g.strokeStyle = hexA('#8fb0ff', 0.32 * ph * fade); g.lineWidth = 1.6; g.stroke();
+    }
+    g.save(); g.translate(this.x, this.y); g.rotate(this.t * 1.6);
+    for (let i = 0; i < 3; i++){
+      g.beginPath();
+      const rr = 26 + i * 9;
+      g.arc(0, 0, rr, i * 2, i * 2 + 2.2);
+      g.strokeStyle = hexA('#cddcff', 0.7 * fade); g.lineWidth = 2; g.stroke();
+    }
+    g.restore();
+    drawGlow(g, this.x, this.y, 40, '#dbe6ff', 0.6 * fade);
+  }
+}
+
+class BlackHole {
+  constructor(x, y){ this.x = x; this.y = y; this.t = 0; this.life = 5; this.R = 330; this.core = 30; this.dead = false; }
+  update(dt){
+    this.t += dt;
+    // devour
+    for (const o of orbs){
+      if (o.dead) continue;
+      if (dist(o.x, o.y, this.x, this.y) < this.core * 1.1){
+        addScore(TYPES[o.type].sc, o.x, o.y);
+        o.dead = true;
+        burst(o.x, o.y, TYPES[o.type].c, '#ffffff', o.r, 16);
+        Snd.tone({type:'sine', f0:600, f1:60, dur:0.25, vol:0.14});
+      }
+    }
+    for (const b of bullets)
+      if (!b.dead && dist(b.x, b.y, this.x, this.y) < this.core) b.dead = true;
+    for (const s of shards)
+      if (!s.dead && dist(s.x, s.y, this.x, this.y) < this.core) s.dead = true;
+    if (ship && !ship.dead && ship.invuln <= 0 && dist(ship.x, ship.y, this.x, this.y) < this.core * 0.8) ship.kill();
+
+    if (Math.random() < dt * 90){
+      const a = rand(0, TAU), d = rand(this.core, this.R);
+      addParticle(this.x + Math.cos(a)*d, this.y + Math.sin(a)*d,
+        -Math.cos(a) * 280 + Math.cos(a + 1.57) * 200, -Math.sin(a) * 280 + Math.sin(a + 1.57) * 200,
+        rand(1.5, 3.4), rand(0.4, 0.9), pick(['#a24bff','#e7c6ff','#ffffff']));
+    }
+    if (this.t > this.life){
+      this.dead = true;
+      effects.push(new Shockwave(this.x, this.y, 300, '#a24bff'));
+      addShake(0.7);
+      Snd.noise({filter:'bandpass', f0:120, f1:3000, dur:0.5, vol:0.3});
+    }
+  }
+  field(e, dt, kind){
+    const [dx, dy] = dxy(e.x, e.y, this.x, this.y);
+    const d = Math.hypot(dx, dy);
+    if (d > this.R || d < 1) return;
+    const fall = 1 - d / this.R;
+    const k = kind === 'bullet' ? 2100 : kind === 'ship' ? 900 : 1100;
+    const f = k * fall * fall * dt;
+    e.vx += (dx / d) * f; e.vy += (dy / d) * f;
+    // tangential swirl, so things spiral rather than fall straight in
+    e.vx += (-dy / d) * f * 0.55; e.vy += (dx / d) * f * 0.55;
+  }
+  draw(g){
+    const fade = clamp(Math.min(this.t * 4, (this.life - this.t) * 3), 0, 1);
+    drawGlow(g, this.x, this.y, this.R * 0.7, '#7a1fff', 0.18 * fade);
+    g.save(); g.translate(this.x, this.y); g.rotate(this.t * 2.2);
+    for (let i = 0; i < 30; i++){
+      const a = i / 30 * TAU;
+      g.beginPath(); g.arc(0, 0, this.core * 1.5, a, a + 0.16);
+      g.strokeStyle = hexA(i % 2 ? '#e7c6ff' : '#a24bff', (0.25 + 0.7 * Math.abs(Math.sin(a * 3 + this.t * 4))) * fade);
+      g.lineWidth = this.core * 0.5; g.stroke();
+    }
+    g.restore();
+    const prevOp = g.globalCompositeOperation;
+    g.globalCompositeOperation = 'source-over';
+    g.beginPath(); g.arc(this.x, this.y, this.core * fade, 0, TAU);
+    g.fillStyle = '#000'; g.fill();
+    g.strokeStyle = hexA('#ffffff', 0.85 * fade); g.lineWidth = 1.6; g.stroke();
+    g.globalCompositeOperation = prevOp;
+  }
+}
+
+class TimeBubble {
+  constructor(x, y){ this.x = x; this.y = y; this.t = 0; this.life = 7.5; this.R = 195; this.dead = false; }
+  update(dt){
+    this.t += dt;
+    if (this.t > this.life) this.dead = true;
+    if (Math.random() < dt * 18){
+      const a = rand(0, TAU), d = rand(0, this.R);
+      addParticle(this.x + Math.cos(a)*d, this.y + Math.sin(a)*d, rand(-12,12), rand(-22,-4),
+        rand(1.4,2.8), rand(0.9,1.6), '#28e6ff');
+    }
+  }
+  draw(g){
+    const fade = clamp(Math.min(this.t * 3, (this.life - this.t) * 2), 0, 1);
+    const rr = this.R * (this.t < 0.3 ? this.t / 0.3 : 1);
+    drawGlow(g, this.x, this.y, rr, '#28e6ff', 0.10 * fade);
+    g.beginPath(); g.arc(this.x, this.y, rr, 0, TAU);
+    g.fillStyle = hexA('#28e6ff', 0.05 * fade); g.fill();
+    g.strokeStyle = hexA('#a8f2ff', 0.5 * fade); g.lineWidth = 1.8; g.stroke();
+    for (let i = 0; i < 2; i++){
+      const ph = ((this.t * 0.35) + i / 2) % 1;
+      g.beginPath(); g.arc(this.x, this.y, rr * (0.3 + ph * 0.7), 0, TAU);
+      g.strokeStyle = hexA('#d3f8ff', 0.22 * (1 - ph) * fade); g.lineWidth = 1; g.stroke();
+    }
+    // slow clock in the middle
+    g.save(); g.translate(this.x, this.y);
+    g.beginPath(); g.arc(0, 0, 15, 0, TAU);
+    g.strokeStyle = hexA('#d3f8ff', 0.45 * fade); g.lineWidth = 1.4; g.stroke();
+    g.beginPath(); g.moveTo(0,0);
+    g.lineTo(Math.cos(this.t * 1.1 - 1.57) * 11, Math.sin(this.t * 1.1 - 1.57) * 11);
+    g.strokeStyle = hexA('#ffffff', 0.6 * fade); g.lineWidth = 1.6; g.stroke();
+    g.restore();
+  }
+}
+
+class Shockwave {
+  constructor(x, y, maxR = 380, color = '#ff2f6e'){
+    this.x = x; this.y = y; this.r = 10; this.maxR = maxR;
+    this.color = color; this.hit = new Set(); this.dead = false;
+  }
+  update(dt){
+    const prev = this.r;
+    this.r += 780 * dt;
+    if (this.r > this.maxR) this.dead = true;
+    for (const o of orbs){
+      if (o.dead || this.hit.has(o)) continue;
+      const d = dist(o.x, o.y, this.x, this.y);
+      if (d <= this.r && d >= prev - o.r - 20){
+        this.hit.add(o);
+        const [dx, dy] = dxy(this.x, this.y, o.x, o.y);
+        const dd = Math.max(1, Math.hypot(dx, dy));
+        o.vx += dx / dd * 200; o.vy += dy / dd * 200;
+        hitOrb(o);
+      }
+    }
+    for (const s of shards){
+      if (s.dead || this.hit.has(s)) continue;
+      const d = dist(s.x, s.y, this.x, this.y);
+      if (d <= this.r){ this.hit.add(s); s.dead = true; addScore(25, s.x, s.y); }
+    }
+    if (ship && !ship.dead){
+      const d = dist(ship.x, ship.y, this.x, this.y);
+      if (Math.abs(d - this.r) < 30){
+        const [dx, dy] = dxy(this.x, this.y, ship.x, ship.y);
+        const dd = Math.max(1, Math.hypot(dx, dy));
+        ship.vx += dx / dd * 620 * 0.016; ship.vy += dy / dd * 620 * 0.016;
+      }
+    }
+  }
+  draw(g){
+    const p = this.r / this.maxR;
+    g.beginPath(); g.arc(this.x, this.y, this.r, 0, TAU);
+    g.strokeStyle = hexA(this.color, (1 - p) * 0.9); g.lineWidth = 10 * (1 - p) + 1.5; g.stroke();
+    g.beginPath(); g.arc(this.x, this.y, this.r * 0.86, 0, TAU);
+    g.strokeStyle = hexA('#ffffff', (1 - p) * 0.5); g.lineWidth = 2; g.stroke();
+  }
+}
+
+class Detonation {
+  constructor(x, y, R, delay){
+    this.x = x; this.y = y; this.R = R; this.delay = delay;
+    this.t = 0; this.fired = false; this.dead = false;
+  }
+  update(dt){
+    this.t += dt;
+    if (!this.fired && this.t >= this.delay){
+      this.fired = true;
+      addShake(0.45);
+      burst(this.x, this.y, '#ff6a00', '#ffdda8', this.R * 0.25, 40);
+      spawnRing(this.x, this.y, this.R, '#ff6a00', 0.6);
+      for (const o of orbs){
+        if (o.dead) continue;
+        if (dist(o.x, o.y, this.x, this.y) < this.R){
+          const [dx, dy] = dxy(this.x, this.y, o.x, o.y);
+          const dd = Math.max(1, Math.hypot(dx, dy));
+          o.vx += dx / dd * 160; o.vy += dy / dd * 160;
+          hitOrb(o);
+        }
+      }
+      for (const s of shards)
+        if (!s.dead && dist(s.x, s.y, this.x, this.y) < this.R){ s.dead = true; addScore(25, s.x, s.y); }
+      if (ship && !ship.dead && dist(ship.x, ship.y, this.x, this.y) < this.R * 0.55) ship.kill();
+    }
+    if (this.t > this.delay + 0.45) this.dead = true;
+  }
+  draw(g){
+    if (!this.fired) return;
+    const p = clamp((this.t - this.delay) / 0.45, 0, 1);
+    drawGlow(g, this.x, this.y, this.R * (0.4 + p * 0.7), '#ff6a00', (1 - p) * 0.55);
+  }
+}
+
+class Ripple {
+  constructor(x, y, maxR, color, str){
+    this.x = x; this.y = y; this.r = 2; this.maxR = maxR;
+    this.color = color; this.str = str; this.dead = false;
+  }
+  update(dt){ this.r += (this.maxR * 2.4) * dt; if (this.r > this.maxR) this.dead = true; }
+  draw(g){
+    const p = this.r / this.maxR;
+    g.beginPath(); g.arc(this.x, this.y, this.r, 0, TAU);
+    g.strokeStyle = hexA(this.color, (1 - p) * this.str); g.lineWidth = 3 * (1 - p) + 0.6; g.stroke();
+  }
+}
+function spawnRing(x, y, r, color, str){ effects.push(new Ripple(x, y, r, color, str)); }
+
+/* Every effect that exerts a force gets applied here. */
+function applyFields(e, dt, kind){
+  for (let i = 0; i < effects.length; i++){
+    const f = effects[i];
+    if (f.field) f.field(e, dt, kind);
+  }
+}
+/* Time-bubble slow factor at a point. */
+function slowAt(x, y, isShip){
+  let f = 1;
+  for (let i = 0; i < bubbles.length; i++){
+    const b = bubbles[i];
+    if (dist(x, y, b.x, b.y) < b.R) f = Math.min(f, isShip ? 0.82 : 0.3);
+  }
+  return f;
+}
+
+/* ------------------------------------------------------------ particles --- */
+function addParticle(x, y, vx, vy, r, life, color){
+  if (particles.length >= PARTICLE_CAP) particles.shift();
+  particles.push({ x, y, vx, vy, r, life, max:life, color, dead:false });
+}
+function burst(x, y, c1, c2, size, n){
+  n = n || Math.round(14 + size * 0.7);
+  for (let i = 0; i < n; i++){
+    const a = rand(0, TAU), s = rand(50, 90 + size * 9);
+    addParticle(x, y, Math.cos(a)*s, Math.sin(a)*s, rand(1.6, 2 + size*0.13),
+      rand(0.3, 0.85), Math.random() < 0.55 ? c1 : c2);
+  }
+}
+function updateParticles(dt){
+  for (let i = particles.length - 1; i >= 0; i--){
+    const p = particles[i];
+    applyFields(p, dt, 'particle');
+    const tf = slowAt(p.x, p.y);
+    p.x += p.vx * dt * tf; p.y += p.vy * dt * tf;
+    const drag = Math.pow(0.22, dt);
+    p.vx *= drag; p.vy *= drag;
+    p.life -= dt;
+    if (p.life <= 0) particles.splice(i, 1);
+  }
+}
+function drawParticles(g){
+  g.globalCompositeOperation = 'lighter';
+  for (let i = 0; i < particles.length; i++){
+    const p = particles[i];
+    const t = p.life / p.max;
+    drawGlow(g, p.x, p.y, p.r * (1 + (1 - t) * 2.4), p.color, t * 0.85);
+  }
+  g.globalCompositeOperation = 'source-over';
+}
+
+/* ------------------------------------------------------------- floaters --- */
+class Floater {
+  constructor(x, y, text, color, life, size){
+    this.x = x; this.y = y; this.text = text; this.color = color;
+    this.life = life || 0.9; this.max = this.life; this.size = size || 13; this.dead = false;
+  }
+  update(dt){ this.y -= 34 * dt; this.life -= dt; if (this.life <= 0) this.dead = true; }
+  draw(g){
+    const t = this.life / this.max;
+    g.globalAlpha = clamp(t * 1.6, 0, 1);
+    g.font = '700 ' + this.size + 'px ui-monospace, Menlo, monospace';
+    g.textAlign = 'center';
+    g.fillStyle = this.color;
+    g.fillText(this.text, this.x, this.y);
+    g.globalAlpha = 1;
+    g.textAlign = 'left';
+  }
+}
+
+/* ---------------------------------------------------------------- stars --- */
+function makeStars(){
+  stars = [];
+  const n = Math.round((W * H) / 9000);
+  for (let i = 0; i < n; i++){
+    stars.push({ x: rand(0, W), y: rand(0, H), r: rand(0.4, 1.5), z: rand(0.15, 1), tw: rand(0, TAU) });
+  }
+}
+function drawStars(g, dt){
+  for (let i = 0; i < stars.length; i++){
+    const s = stars[i];
+    s.x -= s.z * 7 * dt;
+    if (s.x < 0) { s.x += W; s.y = rand(0, H); }
+    const a = 0.22 + 0.5 * s.z * (0.6 + 0.4 * Math.sin(elapsed * 1.6 + s.tw));
+    g.fillStyle = 'rgba(180,205,255,' + a.toFixed(3) + ')';
+    g.fillRect(s.x, s.y, s.r, s.r);
+  }
+}
+
+/* ---------------------------------------------------------- game control -- */
+function hitOrb(o){
+  if (o.dead) return;
+  const T = TYPES[o.type];
+  o.flash = 0.13;
+  o.hp--;
+  burst(o.x, o.y, T.c, T.c2, o.r * 0.4, 8);
+  if (o.hp > 0){
+    if (T.hurt) T.hurt(o);
+    else Snd.tone({type:'square', f0:700, f1:400, dur:0.06, vol:0.07});
+    addScore(20, o.x, o.y, false);
+    return;
+  }
+  destroyOrb(o);
+}
+function destroyOrb(o){
+  if (o.dead) return;
+  o.dead = true;
+  const T = TYPES[o.type];
+  burst(o.x, o.y, T.c, T.c2, o.r);
+  spawnRing(o.x, o.y, o.r * 3.4, T.c, 0.55);
+  addShake(clamp(o.r / 90, 0.08, 0.5));
+  if (T.sfx) T.sfx(o);
+  combo++; comboTimer = 2.4;
+  addScore(Math.round(T.sc * (TIER_SCALE[o.tier] || 1)), o.x, o.y);
+  if (T.destroy) T.destroy(o);
+}
+function addScore(v, x, y, showFloat = true){
+  const m = comboMult();
+  const gained = v * m;
+  score += gained;
+  if (showFloat && x != null){
+    floaters.push(new Floater(x, y - 14, (m > 1 ? '+' + gained + ' ×' + m : '+' + gained),
+      m > 1 ? '#ffd75e' : '#cfe4ff', 0.9, m > 1 ? 15 : 12));
+  }
+  grantExtraLives();
+}
+function grantExtraLives(){
+  let n = 0;
+  while (score >= nextExtra && n < 20){ nextExtra += 5000; lives++; n++; }
+  if (n){
+    floaters.push(new Floater(ship ? ship.x : W/2, (ship ? ship.y : H/2) - 40,
+      n > 1 ? 'EXTRA LIFE ×' + n : 'EXTRA LIFE', '#38f5d0', 1.8, 20));
+    Snd.extraLife();
+  }
+}
+
+function spawnWave(w){
+  const pool = poolFor(w);
+  const n = Math.min(22, 3 + Math.round(w * 1.6));
+  for (let i = 0; i < n; i++){
+    let x, y, tries = 0;
+    do {
+      if (Math.random() < 0.5){ x = Math.random() < 0.5 ? rand(-20, 40) : rand(W - 40, W + 20); y = rand(0, H); }
+      else { x = rand(0, W); y = Math.random() < 0.5 ? rand(-20, 40) : rand(H - 40, H + 20); }
+      tries++;
+    } while (ship && dist(x, y, ship.x, ship.y) < 240 && tries < 20);
+    x = clamp(x, 10, W - 10); y = clamp(y, 10, H - 10);
+    const o = new Orb(x, y, pick(pool), 3);
+    o.born = 0.5;
+    orbs.push(o);
+    spawnRing(x, y, 70, TYPES[o.type].c, 0.5);
+  }
+}
+
+function startGame(){
+  Snd.init(); Snd.resume();
+  score = 0; lives = 3; wave = 0; nextExtra = 5000;
+  orbs = []; bullets = []; shards = []; particles = []; effects = []; floaters = []; bubbles = [];
+  combo = 0; comboTimer = 0; reverseT = 0; shake = 0; hitFlash = 0;
+  waveTransition = 0; gameOverDelay = 0;
+  ship = new Ship();
+  state = 'playing';
+  showScreen(null);
+}
+function endGame(){
+  state = 'gameover';
+  gameOverDelay = 0.8;
+  Snd.thrust(false); Snd.gameOver();
+  const isBest = score > best;
+  if (isBest){ best = score; localStorage.setItem('wildorbs.best', String(best)); }
+  document.getElementById('finalScore').textContent = score.toLocaleString();
+  document.getElementById('finalWave').textContent = Math.max(1, wave);
+  document.getElementById('finalBest').textContent = best.toLocaleString();
+  document.getElementById('newbest').style.display = isBest ? 'block' : 'none';
+  showScreen('overScreen');
+}
+function showScreen(id){
+  for (const s of ['startScreen','pauseScreen','overScreen'])
+    document.getElementById(s).classList.toggle('on', s === id);
+  document.querySelector('.foot').style.display = (id === 'startScreen') ? 'block' : 'none';
+}
+
+/* ----------------------------------------------------------- collisions -- */
+function collide(){
+  // bullets vs orbs
+  for (let i = bullets.length - 1; i >= 0; i--){
+    const b = bullets[i];
+    if (b.dead) continue;
+    for (let j = 0; j < orbs.length; j++){
+      const o = orbs[j];
+      if (o.dead || o.born > 0.2) continue;
+      if (dist(b.x, b.y, o.x, o.y) < o.r + 3){
+        b.dead = true;
+        hitOrb(o);
+        break;
+      }
+    }
+    if (b.dead) continue;
+    // bullets vs shards
+    for (let j = 0; j < shards.length; j++){
+      const s = shards[j];
+      if (s.dead) continue;
+      if (dist(b.x, b.y, s.x, s.y) < s.r + 4){
+        b.dead = true; s.dead = true;
+        addScore(25, s.x, s.y);
+        burst(s.x, s.y, '#ff43e0', '#ffd4f7', 10, 12);
+        Snd.tone({type:'triangle', f0:1800, f1:700, dur:0.08, vol:0.08});
+        break;
+      }
+    }
+  }
+  if (!ship || ship.dead) return;
+  // ship vs orbs
+  for (let j = 0; j < orbs.length; j++){
+    const o = orbs[j];
+    if (o.dead || o.born > 0.2) continue;
+    if (dist(ship.x, ship.y, o.x, o.y) < o.r + ship.r * 0.8){
+      if (ship.invuln > 0) continue;
+      ship.kill();
+      destroyOrb(o);                  // it still does its wild thing on the way out
+      break;
+    }
+  }
+  // ship vs shards
+  for (let j = 0; j < shards.length; j++){
+    const s = shards[j];
+    if (s.dead) continue;
+    if (dist(ship.x, ship.y, s.x, s.y) < s.r + ship.r * 0.8){
+      if (ship.invuln > 0) continue;
+      s.dead = true;
+      ship.kill();
+      break;
+    }
+  }
+}
+
+/* ------------------------------------------------------------- the loop --- */
+const keys = {};
+let last = performance.now();
+
+function update(dt){
+  elapsed += dt;
+  if (shake > 0) shake = Math.max(0, shake - dt * 1.9);
+  if (hitFlash > 0) hitFlash = Math.max(0, hitFlash - dt * 2.4);
+
+  // cache slow-field sources once per frame
+  bubbles = effects.filter(e => e instanceof TimeBubble);
+
+  if (state === 'playing'){
+    if (reverseT > 0) reverseT -= dt;
+    if (comboTimer > 0){ comboTimer -= dt; if (comboTimer <= 0) combo = 0; }
+    ship.update(dt);
+
+    if (waveTransition > 0){
+      waveTransition -= dt;
+      if (waveTransition <= 0) spawnWave(wave);
+    } else if (orbs.length === 0){
+      wave++;
+      waveTransition = 1.7;
+      if (wave > 1){
+        const bonus = 250 * (wave - 1);
+        score += bonus; grantExtraLives();
+        floaters.push(new Floater(W/2, H/2 + 46, 'WAVE CLEAR  +' + bonus, '#38f5d0', 2.0, 17));
+      }
+      Snd.waveUp();
+    }
+  } else if (state === 'start'){
+    // attract mode: keep a lazy drift of orbs behind the menu
+    if (orbs.length < 11 && Math.random() < dt * 5){
+      const o = new Orb(rand(0, W), rand(0, H), pick(SPAWNABLE), 3);
+      o.vx *= 0.5; o.vy *= 0.5;
+      orbs.push(o);
+    }
+  }
+
+  for (const o of orbs) if (!o.dead) o.update(dt);
+  for (const b of bullets) if (!b.dead) b.update(dt);
+  for (const s of shards) if (!s.dead) s.update(dt);
+  for (const e of effects) if (!e.dead) e.update(dt);
+  for (const f of floaters) if (!f.dead) f.update(dt);
+  updateParticles(dt);
+
+  if (state === 'playing') collide();
+
+  if (orbs.length)    orbs    = orbs.filter(o => !o.dead);
+  if (bullets.length) bullets = bullets.filter(b => !b.dead);
+  if (shards.length)  shards  = shards.filter(s => !s.dead);
+  if (effects.length) effects = effects.filter(e => !e.dead);
+  if (floaters.length)floaters= floaters.filter(f => !f.dead);
+
+  if (gameOverDelay > 0) gameOverDelay -= dt;
+  Snd.music();
+}
+
+function render(dt){
+  // motion-trail fade instead of a hard clear
+  ctx.fillStyle = 'rgba(5,6,14,0.30)';
+  ctx.fillRect(0, 0, W, H);
+
+  ctx.save();
+  if (shake > 0.001){
+    const s = shake * shake;
+    ctx.translate(rand(-1,1) * s * 16, rand(-1,1) * s * 16);
+    ctx.rotate(rand(-1,1) * s * 0.014);
+  }
+
+  drawStars(ctx, dt);
+
+  ctx.globalCompositeOperation = 'lighter';
+  for (const e of effects) e.draw(ctx);
+  ctx.globalCompositeOperation = 'source-over';
+
+  for (const o of orbs) o.draw(ctx);
+  for (const s of shards) s.draw(ctx);
+
+  ctx.globalCompositeOperation = 'lighter';
+  for (const b of bullets) b.draw(ctx);
+  ctx.globalCompositeOperation = 'source-over';
+
+  if (ship && state !== 'start') ship.draw(ctx);
+  drawParticles(ctx);
+  for (const f of floaters) f.draw(ctx);
+
+  ctx.restore();
+
+  // reversed-controls warning tint
+  if (reverseT > 0){
+    const p = (0.10 + 0.06 * Math.sin(elapsed * 9)) * clamp(reverseT, 0, 1);
+    const g = ctx.createRadialGradient(W/2, H/2, Math.min(W,H) * 0.25, W/2, H/2, Math.max(W,H) * 0.72);
+    g.addColorStop(0, 'rgba(255,154,31,0)');
+    g.addColorStop(1, 'rgba(255,154,31,' + p.toFixed(3) + ')');
+    ctx.fillStyle = g; ctx.fillRect(0, 0, W, H);
+  }
+  if (hitFlash > 0){
+    ctx.fillStyle = 'rgba(255,255,255,' + (hitFlash * 0.32).toFixed(3) + ')';
+    ctx.fillRect(0, 0, W, H);
+  }
+
+  if (state === 'playing' || state === 'paused') drawHUD();
+  if (state === 'start') drawChips();
+}
+
+/* ------------------------------------------------------------------ HUD --- */
+function shipGlyph(g, x, y, s){
+  g.save(); g.translate(x, y); g.scale(s, s);
+  g.beginPath(); g.moveTo(8, 0); g.lineTo(-6, 5.5); g.lineTo(-3, 0); g.lineTo(-6, -5.5);
+  g.closePath();
+  g.fillStyle = '#d8fbff'; g.fill();
+  g.restore();
+}
+function drawHUD(){
+  const g = ctx;
+  g.font = '700 13px ui-monospace, Menlo, monospace';
+  g.textAlign = 'left';
+
+  g.fillStyle = 'rgba(150,170,205,.55)';
+  g.fillText('SCORE', 22, 30);
+  g.font = '700 26px ui-monospace, Menlo, monospace';
+  g.fillStyle = '#ffffff';
+  g.fillText(score.toLocaleString(), 22, 58);
+
+  g.font = '700 11px ui-monospace, Menlo, monospace';
+  g.fillStyle = 'rgba(150,170,205,.5)';
+  g.fillText('BEST ' + Math.max(best, score).toLocaleString(), 22, 76);
+
+  for (let i = 0; i < Math.min(lives, 8); i++) shipGlyph(g, 30 + i * 20, 96, 1.15);
+  if (lives > 8){
+    g.fillStyle = '#d8fbff';
+    g.fillText('+' + (lives - 8), 30 + 8 * 20, 100);
+  }
+
+  // wave + combo, right side
+  g.textAlign = 'right';
+  g.font = '700 11px ui-monospace, Menlo, monospace';
+  g.fillStyle = 'rgba(150,170,205,.55)';
+  g.fillText('WAVE', W - 22, 30);
+  g.font = '700 26px ui-monospace, Menlo, monospace';
+  g.fillStyle = '#38f5d0';
+  g.fillText(String(Math.max(1, wave)), W - 22, 58);
+
+  const m = comboMult();
+  if (m > 1){
+    g.font = '700 15px ui-monospace, Menlo, monospace';
+    g.fillStyle = '#ffd75e';
+    g.fillText('COMBO ×' + m, W - 22, 82);
+    const wpx = 90, pct = clamp(comboTimer / 2.4, 0, 1);
+    g.fillStyle = 'rgba(255,215,94,.22)'; g.fillRect(W - 22 - wpx, 90, wpx, 3);
+    g.fillStyle = '#ffd75e'; g.fillRect(W - 22 - wpx, 90, wpx * pct, 3);
+  }
+  g.textAlign = 'left';
+
+  // status banner
+  if (reverseT > 0){
+    g.textAlign = 'center';
+    g.font = '700 15px ui-monospace, Menlo, monospace';
+    g.fillStyle = 'rgba(255,154,31,' + (0.55 + 0.45 * Math.sin(elapsed * 8)).toFixed(2) + ')';
+    g.fillText('<-> CONTROLS REVERSED  ' + reverseT.toFixed(1) + 's', W/2, H - 34);
+    g.textAlign = 'left';
+  }
+
+  // wave banner
+  if (waveTransition > 0){
+    const p = 1 - Math.abs(waveTransition / 1.7 - 0.5) * 2;
+    g.save();
+    g.globalAlpha = clamp(p * 1.8, 0, 1);
+    g.textAlign = 'center';
+    g.font = '800 ' + Math.round(clamp(W * 0.07, 28, 64)) + 'px ui-monospace, Menlo, monospace';
+    g.fillStyle = '#ffffff';
+    g.fillText('WAVE ' + wave, W/2, H/2 - 6);
+    g.font = '700 13px ui-monospace, Menlo, monospace';
+    g.fillStyle = '#38f5d0';
+    const nu = UNLOCKS[wave - 1];
+    g.fillText(nu ? 'NEW: ' + nu.map(k => TYPES[k].name).join('  ·  ') : 'INCOMING', W/2, H/2 + 26);
+    g.restore();
+    g.textAlign = 'left';
+  }
+
+  if (Snd.muted){
+    g.font = '700 10px ui-monospace, Menlo, monospace';
+    g.fillStyle = 'rgba(150,170,205,.45)';
+    g.textAlign = 'center';
+    g.fillText('MUTED', W/2, 24);
+    g.textAlign = 'left';
+  }
+}
+
+/* ------------------------------------------------- start-screen bestiary -- */
+const chips = [];
+function buildBestiary(){
+  const host = document.getElementById('bestiary');
+  host.innerHTML = '';
+  for (const key of SPAWNABLE){
+    const T = TYPES[key];
+    const el = document.createElement('div');
+    el.className = 'chip';
+    const cv = document.createElement('canvas');
+    cv.width = cv.height = 34 * 2; cv.style.width = cv.style.height = '34px';
+    const txt = document.createElement('div');
+    txt.innerHTML = '<div class="n" style="color:' + T.c + '">' + T.name + '</div>' +
+                    '<div class="d">' + T.blurb + '</div>';
+    el.appendChild(cv); el.appendChild(txt);
+    host.appendChild(el);
+    const g = cv.getContext('2d');
+    g.setTransform(2, 0, 0, 2, 0, 0);
+    chips.push({ g, key });
+  }
+}
+function drawChips(){
+  for (const c of chips){
+    c.g.clearRect(0, 0, 40, 40);
+    c.g.save();
+    c.g.translate(17, 17);
+    c.g.globalCompositeOperation = 'lighter';
+    drawGlow(c.g, 0, 0, 17, TYPES[c.key].c, 0.4);
+    c.g.globalCompositeOperation = 'source-over';
+    drawOrbBody(c.g, c.key, 12, elapsed, null);
+    c.g.restore();
+  }
+}
+
+/* ----------------------------------------------------------------- input -- */
+const PREVENT = ['Space','ArrowUp','ArrowDown','ArrowLeft','ArrowRight'];
+addEventListener('keydown', e => {
+  if (PREVENT.includes(e.code)) e.preventDefault();
+  keys[e.code] = true;
+  if (e.repeat) return;
+  Snd.init(); Snd.resume();
+
+  if (e.code === 'KeyM'){ Snd.toggleMute(); return; }
+
+  if (state === 'start'){
+    if (e.code === 'Space' || e.code === 'Enter') startGame();
+  } else if (state === 'gameover'){
+    if ((e.code === 'Space' || e.code === 'Enter') && gameOverDelay <= 0) startGame();
+  } else if (state === 'playing'){
+    if (e.code === 'KeyP' || e.code === 'Escape'){ state = 'paused'; Snd.thrust(false); showScreen('pauseScreen'); }
+  } else if (state === 'paused'){
+    if (e.code === 'KeyP' || e.code === 'Escape' || e.code === 'Space'){ state = 'playing'; showScreen(null); }
+  }
+});
+addEventListener('keyup', e => { keys[e.code] = false; });
+addEventListener('blur', () => { for (const k in keys) keys[k] = false; Snd.thrust(false); });
+addEventListener('pointerdown', () => { Snd.init(); Snd.resume(); });
+
+/* ------------------------------------------------------------------ boot -- */
+function frame(now){
+  let dt = (now - last) / 1000;
+  last = now;
+  if (dt > 0.05) dt = 0.05;                 // never let a stall teleport anything
+  if (dt < 0) dt = 0;
+  if (state !== 'paused') update(dt);
+  render(dt);
+  requestAnimationFrame(frame);
+}
+
+resize();
+buildBestiary();
+ctx.fillStyle = '#05060e'; ctx.fillRect(0, 0, W, H);
+requestAnimationFrame(frame);
+</script>
+</body>
+</html>

(oldest)  ·  back to Wild Orbs Opus  ·  Wild Orbs: rebuild from the Opus artifact — 13 orb types, te 9854c68 →