[object Object]

← back to Games Agentabrams

add Wild Orbs game (model-arena battle winner) to the hub

01d801783bfcc7d1f8cc17b0639aabb40f344cc6 · 2026-09-09 07:37:30 -0700 · Steve Abrams

Asteroids-style shooter; every orb does a wild thing when shot (10 distinct
behaviors). Winner of the local model-arena Wild Orbs battle (claude-code build).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012k7pnZxHquVVfKE9REX9vL

Files touched

Diff

commit 01d801783bfcc7d1f8cc17b0639aabb40f344cc6
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Sep 9 07:37:30 2026 -0700

    add Wild Orbs game (model-arena battle winner) to the hub
    
    Asteroids-style shooter; every orb does a wild thing when shot (10 distinct
    behaviors). Winner of the local model-arena Wild Orbs battle (claude-code build).
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_012k7pnZxHquVVfKE9REX9vL
---
 games-data.js               |    8 +
 games.json                  |   90 ++-
 games/wild-orbs/WildOrbs.aa | 1753 +++++++++++++++++++++++++++++++++++++++++++
 games/wild-orbs/index.html  | 1753 +++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 3563 insertions(+), 41 deletions(-)

diff --git a/games-data.js b/games-data.js
index 2594164..6e1c4e4 100644
--- a/games-data.js
+++ b/games-data.js
@@ -2,6 +2,14 @@
    file:// fallback: <script src> works from a local file, fetch() does not. */
 window.__GAMES_DATA__ = {
   "games": [
+    {
+      "id": "wild-orbs",
+      "file": "WildOrbs.aa",
+      "title": "Wild Orbs",
+      "desc": "An Asteroids-style shooter where every orb you shoot does something WILD — splits into homing swarms, spawns black holes and gravity wells, teleports/phases, reverses your controls, drops time-slow bubbles, chain-reaction color bombs, mirror-clones, and ricocheting shards. Thrust-and-wrap ship, escalating waves, particle juice, WebAudio. Pure Canvas 2D, zero dependencies.",
+      "path": "games/wild-orbs/",
+      "added": "2026-09-09"
+    },
     {
       "id": "baguette-brawl",
       "file": "BaguetteBrawl.aa",
diff --git a/games.json b/games.json
index 3fc56d2..6dd7b35 100644
--- a/games.json
+++ b/games.json
@@ -1,10 +1,18 @@
 {
   "games": [
+    {
+      "id": "wild-orbs",
+      "file": "WildOrbs.aa",
+      "title": "Wild Orbs",
+      "desc": "An Asteroids-style shooter where every orb you shoot does something WILD — splits into homing swarms, spawns black holes and gravity wells, teleports/phases, reverses your controls, drops time-slow bubbles, chain-reaction color bombs, mirror-clones, and ricocheting shards. Thrust-and-wrap ship, escalating waves, particle juice, WebAudio. Pure Canvas 2D, zero dependencies.",
+      "path": "games/wild-orbs/",
+      "added": "2026-09-09"
+    },
     {
       "id": "baguette-brawl",
       "file": "BaguetteBrawl.aa",
       "title": "Baguette Brawl",
-      "desc": "Two crusty baguette duelists brawl beneath the Eiffel Tower \u2014 jab, heavy, guard, and charge the meter for an unblockable Crust Crusher. Solo vs. 3-tier AI or local 2-player, best of 3. Pure Canvas 2D, zero dependencies.",
+      "desc": "Two crusty baguette duelists brawl beneath the Eiffel Tower — jab, heavy, guard, and charge the meter for an unblockable Crust Crusher. Solo vs. 3-tier AI or local 2-player, best of 3. Pure Canvas 2D, zero dependencies.",
       "path": "games/baguette-brawl/",
       "added": "2026-08-31"
     },
@@ -20,7 +28,7 @@
       "id": "minesweeper",
       "file": "AbramsMines.aa",
       "title": "Abrams Minesweeper",
-      "desc": "Classic Minesweeper reborn in neon-on-dark \u2014 flood-fill sweeps, tap-to-flag, first-click-safe, best-time chasing across three difficulties. Pure vanilla JS, zero dependencies.",
+      "desc": "Classic Minesweeper reborn in neon-on-dark — flood-fill sweeps, tap-to-flag, first-click-safe, best-time chasing across three difficulties. Pure vanilla JS, zero dependencies.",
       "path": "games/minesweeper/",
       "added": "2026-07-24"
     },
@@ -44,7 +52,7 @@
       "id": "tower-stack",
       "file": "AbramsStack.aa",
       "title": "Abrams Stack",
-      "desc": "Time your taps to stack neon blocks into a sky-high tower \u2014 nail a perfect drop for combo bonuses before the overhang slices you down. Pure Canvas 2D, zero dependencies.",
+      "desc": "Time your taps to stack neon blocks into a sky-high tower — nail a perfect drop for combo bonuses before the overhang slices you down. Pure Canvas 2D, zero dependencies.",
       "path": "games/tower-stack/",
       "added": "2026-07-24"
     },
@@ -68,15 +76,15 @@
       "id": "whack-a-mole",
       "file": "AbramsWhack.aa",
       "title": "Abrams Whack",
-      "desc": "A neon whack-a-mole arcade sprint \u2014 smash glowing moles for combo multipliers, snag gold jackpots, dodge bombs that cost lives. Pure vanilla JS, zero dependencies.",
+      "desc": "A neon whack-a-mole arcade sprint — smash glowing moles for combo multipliers, snag gold jackpots, dodge bombs that cost lives. Pure vanilla JS, zero dependencies.",
       "path": "games/whack-a-mole/",
       "added": "2026-07-24"
     },
     {
       "id": "dvd-bounce",
       "file": "DVDBounce.aa",
-      "title": "DVD Bounce \u2014 Elevated",
-      "desc": "The classic bouncing-logo screensaver, elevated \u2014 glowing trails, corner-hit celebration, color shifts on every wall bounce. An ambient toy, not a scored game. Model Arena winner (Claude). Pure Canvas 2D, zero dependencies.",
+      "title": "DVD Bounce — Elevated",
+      "desc": "The classic bouncing-logo screensaver, elevated — glowing trails, corner-hit celebration, color shifts on every wall bounce. An ambient toy, not a scored game. Model Arena winner (Claude). Pure Canvas 2D, zero dependencies.",
       "path": "games/dvd-bounce/",
       "added": "2026-07-24"
     },
@@ -84,16 +92,16 @@
       "id": "rope-physics",
       "file": "VerletRope.aa",
       "title": "Verlet Rope",
-      "desc": "A physics toy \u2014 drag to swing a Verlet-integrated rope, watch it settle and whip under gravity. A generative-physics playground, not a scored game. Model Arena winner (Claude). Pure Canvas 2D, zero dependencies.",
+      "desc": "A physics toy — drag to swing a Verlet-integrated rope, watch it settle and whip under gravity. A generative-physics playground, not a scored game. Model Arena winner (Claude). Pure Canvas 2D, zero dependencies.",
       "path": "games/rope-physics/",
       "added": "2026-07-24"
     },
     {
       "id": "particle-text-starling",
       "file": "Starling.aa",
-      "icon": "\ud83d\udc26",
+      "icon": "🐦",
       "title": "Starling",
-      "desc": "Type a word or phrase and thousands of particles murmurate into it \u2014 sweep the mouse to scatter, watch them reform, click for a burst. 5 color themes, live typing, multi-line wrapping. Pure Canvas 2D, zero dependencies.",
+      "desc": "Type a word or phrase and thousands of particles murmurate into it — sweep the mouse to scatter, watch them reform, click for a burst. 5 color themes, live typing, multi-line wrapping. Pure Canvas 2D, zero dependencies.",
       "path": "games/particle-text-starling/",
       "added": "2026-07-24"
     },
@@ -101,15 +109,15 @@
       "id": "rubiks-cube",
       "file": "AbramsCube.aa",
       "title": "Abrams Cube",
-      "desc": "Pure CSS-3D twist cube \u2014 drag to orbit, scramble, solve, undo, timer.",
+      "desc": "Pure CSS-3D twist cube — drag to orbit, scramble, solve, undo, timer.",
       "path": "games/rubiks-cube/",
       "added": "2026-07-23"
     },
     {
       "id": "atmos-skyfire",
       "file": "AtmosSkyfire.aa",
-      "title": "ATMOS \u2014 Skyfire",
-      "desc": "Three.js arcade flyer \u2014 bank through a gradient sky, fire twin missiles (SPACE/click), dodge or shoot incoming birds. Steer with arrows/WASD/mouse.",
+      "title": "ATMOS — Skyfire",
+      "desc": "Three.js arcade flyer — bank through a gradient sky, fire twin missiles (SPACE/click), dodge or shoot incoming birds. Steer with arrows/WASD/mouse.",
       "path": "games/atmos-skyfire/",
       "added": "2026-07-23"
     },
@@ -117,7 +125,7 @@
       "id": "flappy-clone",
       "file": "FlappyClone.aa",
       "title": "Flappy Clone",
-      "desc": "Polished Flappy-Bird clone \u2014 Space/click/tap to flap through scrolling pipes. Parallax daytime sky, medals, persistent best, sound (M), pause (P). Pure Canvas 2D, zero dependencies.",
+      "desc": "Polished Flappy-Bird clone — Space/click/tap to flap through scrolling pipes. Parallax daytime sky, medals, persistent best, sound (M), pause (P). Pure Canvas 2D, zero dependencies.",
       "path": "games/flappy-clone/",
       "added": "2026-07-23"
     },
@@ -125,7 +133,7 @@
       "id": "flappy-sky",
       "file": "FlappySky.aa",
       "title": "Flappy Sky",
-      "desc": "Flap through the neon gates against a twinkling night sky \u2014 how far can you fly? Medals, persistent best, sound (M), pause (P/Esc). Pure Canvas 2D, procedural Web Audio, zero dependencies.",
+      "desc": "Flap through the neon gates against a twinkling night sky — how far can you fly? Medals, persistent best, sound (M), pause (P/Esc). Pure Canvas 2D, procedural Web Audio, zero dependencies.",
       "path": "games/flappy-sky/",
       "added": "2026-07-24"
     },
@@ -133,7 +141,7 @@
       "id": "tetris",
       "file": "AbramsBlocks.aa",
       "title": "Abrams Blocks",
-      "desc": "Full-featured Tetris \u2014 SRS rotation with wall kicks, 7-bag randomizer, ghost piece, hold, next-queue, level speed-up, line-clear scoring. Arrows move, \u2191/Z rotate, Space hard-drop, C hold. Touch: swipe to move, tap to rotate, fling down to drop. Pure Canvas 2D, procedural Web Audio, zero dependencies.",
+      "desc": "Full-featured Tetris — SRS rotation with wall kicks, 7-bag randomizer, ghost piece, hold, next-queue, level speed-up, line-clear scoring. Arrows move, ↑/Z rotate, Space hard-drop, C hold. Touch: swipe to move, tap to rotate, fling down to drop. Pure Canvas 2D, procedural Web Audio, zero dependencies.",
       "path": "games/tetris/",
       "added": "2026-07-24"
     },
@@ -141,7 +149,7 @@
       "id": "flappy-flight",
       "file": "FlappyFlight.aa",
       "title": "Flappy Flight",
-      "desc": "Polished Flappy-Bird-style flyer \u2014 Space/click/tap to flap through scrolling pipes. Twinkling parallax night sky, moon, medals, persistent best. True pause (P/Esc, auto-pauses off-focus), mute (M or speaker icon). Pure Canvas 2D, procedural Web Audio, zero dependencies.",
+      "desc": "Polished Flappy-Bird-style flyer — Space/click/tap to flap through scrolling pipes. Twinkling parallax night sky, moon, medals, persistent best. True pause (P/Esc, auto-pauses off-focus), mute (M or speaker icon). Pure Canvas 2D, procedural Web Audio, zero dependencies.",
       "path": "games/flappy-flight/",
       "added": "2026-07-23"
     },
@@ -149,7 +157,7 @@
       "id": "snake",
       "file": "AbramsSnake.aa",
       "title": "Abrams Snake",
-      "desc": "Classic grid snake \u2014 eat the pink, grow, and speed up as you go. Walls and your own tail are fatal. Arrows/WASD to steer, swipe on touch, P pause, M mute. Persistent best. Pure Canvas 2D, procedural Web Audio, zero dependencies.",
+      "desc": "Classic grid snake — eat the pink, grow, and speed up as you go. Walls and your own tail are fatal. Arrows/WASD to steer, swipe on touch, P pause, M mute. Persistent best. Pure Canvas 2D, procedural Web Audio, zero dependencies.",
       "path": "games/snake/",
       "added": "2026-07-24"
     },
@@ -157,7 +165,7 @@
       "id": "breakout",
       "file": "AbramsBreak.aa",
       "title": "Abrams Break",
-      "desc": "Brick-breaker \u2014 steer the paddle with mouse/arrows/drag, launch with Space or click, clear every brick, ride the ball's contact angle. Multi-row levels that get tougher, lives, tough top row, persistent best. P pause, M mute. Pure Canvas 2D, procedural Web Audio, zero dependencies.",
+      "desc": "Brick-breaker — steer the paddle with mouse/arrows/drag, launch with Space or click, clear every brick, ride the ball's contact angle. Multi-row levels that get tougher, lives, tough top row, persistent best. P pause, M mute. Pure Canvas 2D, procedural Web Audio, zero dependencies.",
       "path": "games/breakout/",
       "added": "2026-07-24"
     },
@@ -165,7 +173,7 @@
       "id": "2048",
       "file": "Abrams2048.aa",
       "title": "Abrams 2048",
-      "desc": "Sliding-tile puzzle \u2014 merge equal tiles to reach 2048 and beyond. Arrows/WASD or swipe to slide the whole board, merges pop and chime, persistent best, keep-playing after the win. Pure DOM grid, procedural Web Audio, zero dependencies.",
+      "desc": "Sliding-tile puzzle — merge equal tiles to reach 2048 and beyond. Arrows/WASD or swipe to slide the whole board, merges pop and chime, persistent best, keep-playing after the win. Pure DOM grid, procedural Web Audio, zero dependencies.",
       "path": "games/2048/",
       "added": "2026-07-24"
     },
@@ -173,7 +181,7 @@
       "id": "pong",
       "file": "AbramsPong.aa",
       "title": "Abrams Pong 2P",
-      "desc": "Two-player table tennis \u2014 Left: W/S, Right: \u2191/\u2193, first to 11. Contact-angle english off the paddle, speed-up rallies, procedural blips. Toggle 'vs CPU' to play solo. Touch: drag your own half of the screen. P pause, M mute. Pure Canvas 2D, procedural Web Audio, zero dependencies.",
+      "desc": "Two-player table tennis — Left: W/S, Right: ↑/↓, first to 11. Contact-angle english off the paddle, speed-up rallies, procedural blips. Toggle 'vs CPU' to play solo. Touch: drag your own half of the screen. P pause, M mute. Pure Canvas 2D, procedural Web Audio, zero dependencies.",
       "path": "games/pong/",
       "added": "2026-07-24"
     },
@@ -181,7 +189,7 @@
       "id": "emoji-memory",
       "file": "EmojiMemory.aa",
       "title": "Abrams Emoji Memory",
-      "desc": "Flip-and-match memory game \u2014 find every emoji pair in the fewest moves. 3D card flips, move counter + timer, persistent best-per-size, three difficulties (4\u00d74 / 6\u00d76 / 8\u00d78), and a light/dark theme toggle. Click/tap to flip, N new game. Pure DOM grid, procedural Web Audio, zero dependencies.",
+      "desc": "Flip-and-match memory game — find every emoji pair in the fewest moves. 3D card flips, move counter + timer, persistent best-per-size, three difficulties (4×4 / 6×6 / 8×8), and a light/dark theme toggle. Click/tap to flip, N new game. Pure DOM grid, procedural Web Audio, zero dependencies.",
       "path": "games/emoji-memory/",
       "added": "2026-07-24"
     },
@@ -189,7 +197,7 @@
       "id": "spirograph",
       "file": "Spirograph.aa",
       "title": "Spirograph Studio",
-      "desc": "Hypotrochoid/epitrochoid pattern toy \u2014 tune the fixed & rolling gear radii and pen offset to draw endless looping curves, layer colors, and export the art. A generative-art playground, not a scored game. Pure Canvas 2D, zero dependencies.",
+      "desc": "Hypotrochoid/epitrochoid pattern toy — tune the fixed & rolling gear radii and pen offset to draw endless looping curves, layer colors, and export the art. A generative-art playground, not a scored game. Pure Canvas 2D, zero dependencies.",
       "path": "games/spirograph/",
       "added": "2026-07-24"
     },
@@ -197,7 +205,7 @@
       "id": "constellation-clock",
       "file": "ConstellationClock.aa",
       "title": "Constellation Clock",
-      "desc": "A live clock rendered as a drifting star-field constellation \u2014 the current time drawn in connected stars against a twinkling night sky. An ambient visualizer, not a scored game. Pure Canvas 2D, zero dependencies.",
+      "desc": "A live clock rendered as a drifting star-field constellation — the current time drawn in connected stars against a twinkling night sky. An ambient visualizer, not a scored game. Pure Canvas 2D, zero dependencies.",
       "path": "games/constellation-clock/",
       "added": "2026-07-24"
     },
@@ -205,8 +213,8 @@
       "id": "backgammon",
       "file": "90210Backgammon.aa",
       "title": "90210 Backgammon",
-      "icon": "\ud83c\udfb2",
-      "desc": "A lavish Beverly Hills take on classic backgammon \u2014 roll the dice, bear off your checkers, and hit the bar against an opponent on a gold-and-marble 90210 board. Self-contained HTML, zero runtime dependencies.",
+      "icon": "🎲",
+      "desc": "A lavish Beverly Hills take on classic backgammon — roll the dice, bear off your checkers, and hit the bar against an opponent on a gold-and-marble 90210 board. Self-contained HTML, zero runtime dependencies.",
       "path": "games/backgammon/",
       "added": "2026-07-25"
     },
@@ -214,7 +222,7 @@
       "id": "chomp",
       "file": "AbramsChomp.aa",
       "title": "Abrams Chomp",
-      "desc": "Neon maze-chomper \u2014 weave through the maze eating every pellet, grab power pellets to turn the tables and chase the ghosts, and clear the board. Arrows/WASD to steer, swipe on touch. Pure Canvas 2D, zero dependencies.",
+      "desc": "Neon maze-chomper — weave through the maze eating every pellet, grab power pellets to turn the tables and chase the ghosts, and clear the board. Arrows/WASD to steer, swipe on touch. Pure Canvas 2D, zero dependencies.",
       "path": "games/chomp/",
       "added": "2026-07-25"
     },
@@ -222,7 +230,7 @@
       "id": "gems",
       "file": "NeonGems.aa",
       "title": "Neon Gems",
-      "desc": "Match-3 puzzle \u2014 swap adjacent neon gems to line up three or more, trigger cascading chain combos, and rack up the score. Click/tap or drag to swap. Pure Canvas 2D, procedural Web Audio, zero dependencies.",
+      "desc": "Match-3 puzzle — swap adjacent neon gems to line up three or more, trigger cascading chain combos, and rack up the score. Click/tap or drag to swap. Pure Canvas 2D, procedural Web Audio, zero dependencies.",
       "path": "games/gems/",
       "added": "2026-07-25"
     },
@@ -230,7 +238,7 @@
       "id": "hopper",
       "file": "AbramsHopper.aa",
       "title": "Abrams Hopper",
-      "desc": "Frogger-style road-and-river crosser \u2014 hop across busy neon traffic lanes and ride logs over the water to reach the home slots without getting squashed or wet. Arrows/WASD to hop, swipe on touch. Pure Canvas 2D, zero dependencies.",
+      "desc": "Frogger-style road-and-river crosser — hop across busy neon traffic lanes and ride logs over the water to reach the home slots without getting squashed or wet. Arrows/WASD to hop, swipe on touch. Pure Canvas 2D, zero dependencies.",
       "path": "games/hopper/",
       "added": "2026-07-25"
     },
@@ -238,7 +246,7 @@
       "id": "invaders",
       "file": "NeonInvaders.aa",
       "title": "Neon Invaders",
-      "desc": "Space-Invaders arcade shooter \u2014 slide your cannon, blast the descending grid of neon aliens before they reach the ground, and dodge their return fire. \u2190/\u2192 move, Space fire, P pause. Pure Canvas 2D, procedural Web Audio, zero dependencies.",
+      "desc": "Space-Invaders arcade shooter — slide your cannon, blast the descending grid of neon aliens before they reach the ground, and dodge their return fire. ←/→ move, Space fire, P pause. Pure Canvas 2D, procedural Web Audio, zero dependencies.",
       "path": "games/invaders/",
       "added": "2026-07-25"
     },
@@ -246,7 +254,7 @@
       "id": "missile",
       "file": "MissileCommand.aa",
       "title": "Missile Command",
-      "desc": "City-defense classic \u2014 aim and detonate interceptor missiles to destroy incoming salvos before they wipe out your cities, across escalating waves. Click/tap to fire at a point. Pure Canvas 2D, procedural Web Audio, zero dependencies.",
+      "desc": "City-defense classic — aim and detonate interceptor missiles to destroy incoming salvos before they wipe out your cities, across escalating waves. Click/tap to fire at a point. Pure Canvas 2D, procedural Web Audio, zero dependencies.",
       "path": "games/missile/",
       "added": "2026-07-25"
     },
@@ -254,7 +262,7 @@
       "id": "racer",
       "file": "NeonDrifter.aa",
       "title": "Neon Drifter",
-      "desc": "Endless top-down racer \u2014 drift a neon car down a scrolling track, weave through traffic, and chase distance as the speed ramps up. Arrows/WASD to steer, swipe on touch. Pure Canvas 2D, zero dependencies.",
+      "desc": "Endless top-down racer — drift a neon car down a scrolling track, weave through traffic, and chase distance as the speed ramps up. Arrows/WASD to steer, swipe on touch. Pure Canvas 2D, zero dependencies.",
       "path": "games/racer/",
       "added": "2026-07-25"
     },
@@ -262,7 +270,7 @@
       "id": "sokoban",
       "file": "NeonSokoban.aa",
       "title": "Neon Sokoban",
-      "desc": "Crate-pushing puzzle \u2014 push every glowing crate onto its target pad in the fewest moves; you can only push, never pull, so plan ahead. Arrows/WASD to push, U undo, R restart. Pure vanilla JS, zero dependencies.",
+      "desc": "Crate-pushing puzzle — push every glowing crate onto its target pad in the fewest moves; you can only push, never pull, so plan ahead. Arrows/WASD to push, U undo, R restart. Pure vanilla JS, zero dependencies.",
       "path": "games/sokoban/",
       "added": "2026-07-25"
     },
@@ -270,7 +278,7 @@
       "id": "sudoku",
       "file": "NeonSudoku.aa",
       "title": "Neon Sudoku",
-      "desc": "Classic 9\u00d79 Sudoku reborn in neon \u2014 fill the grid so every row, column, and box holds 1\u20139, with pencil marks, mistake highlighting, and multiple difficulties. Click a cell then type, or tap the number pad. Pure vanilla JS, zero dependencies.",
+      "desc": "Classic 9×9 Sudoku reborn in neon — fill the grid so every row, column, and box holds 1–9, with pencil marks, mistake highlighting, and multiple difficulties. Click a cell then type, or tap the number pad. Pure vanilla JS, zero dependencies.",
       "path": "games/sudoku/",
       "added": "2026-07-25"
     },
@@ -278,7 +286,7 @@
       "id": "tron",
       "file": "AbramsTron.aa",
       "title": "Abrams Tron",
-      "desc": "Light-cycle duel \u2014 race a neon trail across the grid, box out the AI rider, and force it to crash while you thread your own wall. Arrows/WASD to turn, P pause. Pure Canvas 2D, procedural Web Audio, zero dependencies.",
+      "desc": "Light-cycle duel — race a neon trail across the grid, box out the AI rider, and force it to crash while you thread your own wall. Arrows/WASD to turn, P pause. Pure Canvas 2D, procedural Web Audio, zero dependencies.",
       "path": "games/tron/",
       "added": "2026-07-25"
     },
@@ -286,7 +294,7 @@
       "id": "wordle",
       "file": "AbramsWordle.aa",
       "title": "Abrams Wordle",
-      "desc": "Guess the hidden five-letter word in six tries \u2014 green marks the right letter in the right spot, yellow the right letter misplaced, gray the misses. On-screen + physical keyboard, persistent stats. Pure vanilla JS, zero dependencies.",
+      "desc": "Guess the hidden five-letter word in six tries — green marks the right letter in the right spot, yellow the right letter misplaced, gray the misses. On-screen + physical keyboard, persistent stats. Pure vanilla JS, zero dependencies.",
       "path": "games/wordle/",
       "added": "2026-07-25"
     },
@@ -294,8 +302,8 @@
       "id": "trivia-90210",
       "file": "90210Trivia.aa",
       "title": "90210 Trivia",
-      "icon": "\ud83d\udc8e",
-      "desc": "A glam Beverly Hills pop-culture trivia game \u2014 beat the clock on 90210, Rodeo Drive, and Pink Palace questions, stack a streak multiplier, and climb from Peach Pit Local to Rodeo Drive Royalty. Self-contained HTML, zero runtime dependencies.",
+      "icon": "💎",
+      "desc": "A glam Beverly Hills pop-culture trivia game — beat the clock on 90210, Rodeo Drive, and Pink Palace questions, stack a streak multiplier, and climb from Peach Pit Local to Rodeo Drive Royalty. Self-contained HTML, zero runtime dependencies.",
       "path": "games/trivia-90210/",
       "added": "2026-07-25"
     },
@@ -303,8 +311,8 @@
       "id": "rodeo-runner",
       "file": "RodeoDriveRunner.aa",
       "title": "Rodeo Drive Runner",
-      "icon": "\ud83c\udfce\ufe0f",
-      "desc": "A glam Beverly Hills endless runner \u2014 cruise a gold convertible down Rodeo Drive, swerve the traffic, and snag champagne and diamonds to stack a combo multiplier. Speed ramps the longer you last. Pure Canvas 2D, zero runtime dependencies.",
+      "icon": "🏎️",
+      "desc": "A glam Beverly Hills endless runner — cruise a gold convertible down Rodeo Drive, swerve the traffic, and snag champagne and diamonds to stack a combo multiplier. Speed ramps the longer you last. Pure Canvas 2D, zero runtime dependencies.",
       "path": "games/rodeo-runner/",
       "added": "2026-07-25"
     },
@@ -312,8 +320,8 @@
       "id": "beverly-hills-slots",
       "file": "PinkPalaceSlots.aa",
       "title": "Beverly Hills Slots",
-      "icon": "\ud83c\udfb0",
-      "desc": "A glam 90210 slot machine \u2014 spin a gold-and-marble 3-reel cabinet, chase the Pink Palace diamond jackpot, set your bet, and ride a persistent best-bank. Self-contained HTML, zero runtime dependencies.",
+      "icon": "🎰",
+      "desc": "A glam 90210 slot machine — spin a gold-and-marble 3-reel cabinet, chase the Pink Palace diamond jackpot, set your bet, and ride a persistent best-bank. Self-contained HTML, zero runtime dependencies.",
       "path": "games/beverly-hills-slots/",
       "added": "2026-07-25"
     }
diff --git a/games/wild-orbs/WildOrbs.aa b/games/wild-orbs/WildOrbs.aa
new file mode 100644
index 0000000..2faf026
--- /dev/null
+++ b/games/wild-orbs/WildOrbs.aa
@@ -0,0 +1,1753 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>ORBFALL — Wild Orb Protocol</title>
+<style>
+  *{margin:0;padding:0;box-sizing:border-box;}
+  html,body{width:100%;height:100%;overflow:hidden;background:#03030a;
+    font-family:"Trebuchet MS","Segoe UI",system-ui,sans-serif;-webkit-font-smoothing:antialiased;}
+  #wrap{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;
+    background:radial-gradient(ellipse at 50% 40%,#0b0b22 0%,#04040e 55%,#000 100%);}
+  canvas{display:block;touch-action:none;}
+  #ui{position:absolute;inset:0;pointer-events:none;overflow:hidden;}
+  .screen{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;
+    text-align:center;color:#e8f6ff;pointer-events:auto;overflow-y:auto;padding:24px 16px;
+    background:radial-gradient(ellipse at 50% 45%,rgba(22,10,62,.74) 0%,rgba(3,3,12,.95) 70%);
+    backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);
+    animation:fadeIn .35s ease both;}
+  @keyframes fadeIn{from{opacity:0}to{opacity:1}}
+  .hidden{display:none!important;}
+  h1{font-size:clamp(40px,9.4vw,104px);letter-spacing:.15em;font-weight:900;line-height:1;
+     background:linear-gradient(180deg,#ffffff 0%,#cfeaff 34%,#8be9ff 52%,#b06cff 100%);
+     -webkit-background-clip:text;background-clip:text;color:transparent;
+     filter:drop-shadow(0 0 26px rgba(120,180,255,.42));margin-bottom:.08em;}
+  h1 .sub{display:block;font-size:.185em;letter-spacing:.6em;color:#8fd6ff;
+     -webkit-text-fill-color:#8fd6ff;margin-top:.55em;opacity:.9;filter:none;}
+  .tag{font-size:clamp(12px,1.7vw,16px);color:#9fb4d8;letter-spacing:.2em;text-transform:uppercase;margin-bottom:20px;}
+  .keys{display:flex;gap:8px;flex-wrap:wrap;justify-content:center;margin-bottom:18px;max-width:min(780px,94vw);}
+  .key{border:1px solid rgba(140,190,255,.26);background:rgba(20,32,64,.48);padding:7px 13px;border-radius:8px;
+    font-size:12.5px;color:#cfe4ff;letter-spacing:.07em;}
+  .key b{color:#7ff0d0;}
+  .orblist{display:grid;grid-template-columns:repeat(auto-fit,minmax(168px,1fr));gap:7px;max-width:min(940px,95vw);margin-bottom:22px;}
+  .orbcard{display:flex;align-items:center;gap:9px;background:rgba(12,18,40,.5);
+    border:1px solid rgba(120,170,255,.14);border-radius:10px;padding:7px 10px;font-size:11px;
+    color:#a9bfe0;text-align:left;letter-spacing:.02em;transition:border-color .2s,transform .2s,background .2s;}
+  .orbcard:hover{border-color:rgba(150,200,255,.4);transform:translateY(-2px);background:rgba(18,26,54,.65);}
+  .dot{width:16px;height:16px;border-radius:50%;flex:0 0 auto;background:currentColor;
+    box-shadow:0 0 12px currentColor,inset 0 0 6px rgba(255,255,255,.55);}
+  .btn{pointer-events:auto;cursor:pointer;border:none;border-radius:999px;padding:15px 46px;font-size:18px;
+    font-weight:800;letter-spacing:.2em;color:#04121a;font-family:inherit;text-transform:uppercase;
+    background:linear-gradient(135deg,#7ff0d0,#63c8ff 55%,#b06cff);
+    box-shadow:0 0 34px rgba(99,200,255,.5),0 8px 26px rgba(0,0,0,.5);
+    transition:transform .12s ease,box-shadow .12s ease;}
+  .btn:hover{transform:translateY(-2px) scale(1.03);box-shadow:0 0 54px rgba(140,220,255,.8),0 10px 30px rgba(0,0,0,.55);}
+  .btn:active{transform:translateY(1px) scale(.98);}
+  .small{margin-top:16px;font-size:11.5px;color:#6f84a8;letter-spacing:.13em;}
+  #gameover h2{font-size:clamp(34px,7vw,74px);letter-spacing:.12em;color:#ff6f8f;font-weight:900;
+    text-shadow:0 0 34px rgba(255,80,120,.6);margin-bottom:.12em;}
+  .statrow{display:flex;gap:30px;flex-wrap:wrap;justify-content:center;margin:16px 0 22px;}
+  .stat{min-width:104px;}
+  .stat .v{font-size:31px;font-weight:800;color:#8be9ff;text-shadow:0 0 18px rgba(100,200,255,.55);}
+  .stat .l{font-size:10.5px;letter-spacing:.22em;color:#7f93b6;text-transform:uppercase;margin-top:3px;}
+  #newbest{color:#ffe066;font-weight:900;letter-spacing:.25em;font-size:13px;margin-bottom:6px;
+    text-shadow:0 0 20px rgba(255,220,80,.7);animation:pulseBest 1.1s ease-in-out infinite;}
+  @keyframes pulseBest{0%,100%{opacity:.55}50%{opacity:1}}
+  #toast{position:absolute;left:50%;top:13%;transform:translateX(-50%);font-size:clamp(15px,3vw,29px);
+    font-weight:900;letter-spacing:.16em;text-transform:uppercase;color:#fff;opacity:0;
+    text-shadow:0 0 24px currentColor;white-space:nowrap;}
+  #wavebanner{position:absolute;left:50%;top:42%;transform:translate(-50%,-50%);font-size:clamp(28px,7.6vw,82px);
+    font-weight:900;letter-spacing:.3em;color:#fff;opacity:0;text-shadow:0 0 44px #63c8ff;white-space:nowrap;}
+  #pausetag{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);font-size:clamp(26px,6vw,60px);
+    font-weight:900;letter-spacing:.4em;color:#cfe6ff;text-shadow:0 0 40px #63c8ff;display:none;}
+</style>
+</head>
+<body>
+<div id="wrap">
+  <canvas id="cv"></canvas>
+  <div id="ui">
+    <div id="toast"></div>
+    <div id="wavebanner"></div>
+    <div id="pausetag">PAUSED</div>
+
+    <div id="start" class="screen">
+      <h1>ORBFALL<span class="sub">wild orb protocol</span></h1>
+      <div class="tag">Ten orbs. Ten kinds of chaos. Shoot carefully.</div>
+      <div class="keys">
+        <div class="key"><b>← →</b> / <b>A D</b> rotate</div>
+        <div class="key"><b>↑</b> / <b>W</b> thrust</div>
+        <div class="key"><b>SPACE</b> fire</div>
+        <div class="key"><b>SHIFT</b> blink</div>
+        <div class="key"><b>P</b> pause</div>
+        <div class="key"><b>M</b> mute</div>
+      </div>
+      <div class="orblist" id="orblist"></div>
+      <button class="btn" id="startBtn">Launch</button>
+      <div class="small">Screen wraps · inertia is real · chain your pops for combo</div>
+    </div>
+
+    <div id="gameover" class="screen hidden">
+      <h2>SIGNAL LOST</h2>
+      <div class="tag" id="goSub">the orbs got you</div>
+      <div id="newbest" style="display:none">◆ NEW RECORD ◆</div>
+      <div class="statrow">
+        <div class="stat"><div class="v" id="goScore">0</div><div class="l">Score</div></div>
+        <div class="stat"><div class="v" id="goWave">1</div><div class="l">Wave</div></div>
+        <div class="stat"><div class="v" id="goPopped">0</div><div class="l">Orbs Popped</div></div>
+        <div class="stat"><div class="v" id="goCombo">0</div><div class="l">Best Combo</div></div>
+        <div class="stat"><div class="v" id="goBest">0</div><div class="l">Best</div></div>
+      </div>
+      <button class="btn" id="againBtn">Again</button>
+      <div class="small">Press ENTER to relaunch</div>
+    </div>
+  </div>
+</div>
+
+<script>
+"use strict";
+/* =====================================================================
+   ORBFALL — single-file canvas arcade. No assets, no CDNs.
+   ===================================================================== */
+
+const cv  = document.getElementById('cv');
+const ctx = cv.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;
+  cv.width  = Math.floor(W*DPR);
+  cv.height = Math.floor(H*DPR);
+  cv.style.width  = W+'px';
+  cv.style.height = H+'px';
+  ctx.setTransform(DPR,0,0,DPR,0,0);
+  buildStars();
+  buildNebula();
+}
+window.addEventListener('resize', resize);
+
+/* ---------------- math ---------------- */
+const TAU=Math.PI*2;
+const rand=(a=1,b=0)=> b+Math.random()*(a-b);
+const randi=(a,b)=> Math.floor(rand(a,b));
+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;
+const easeOut=t=> 1-Math.pow(1-t,3);
+
+function wrapDelta(a,b,size){ let d=b-a; if(d>size/2) d-=size; if(d<-size/2) d+=size; return d; }
+function wrapPos(o,pad){
+  const p = pad||40;
+  if(o.x < -p) o.x += W+p*2; else if(o.x > W+p) o.x -= W+p*2;
+  if(o.y < -p) o.y += H+p*2; else if(o.y > H+p) o.y -= H+p*2;
+}
+function torDist(ax,ay,bx,by){
+  const dx=wrapDelta(ax,bx,W), dy=wrapDelta(ay,by,H);
+  return Math.hypot(dx,dy);
+}
+
+/* ---------------- audio ---------------- */
+const Sound = (()=>{
+  let A=null, master=null, comp=null, muted=false, started=false;
+  function ensure(){
+    if(A) return A;
+    try{
+      A = new (window.AudioContext||window.webkitAudioContext)();
+      comp = A.createDynamicsCompressor();
+      comp.threshold.value=-14; comp.knee.value=22; comp.ratio.value=8;
+      comp.attack.value=0.004; comp.release.value=0.22;
+      master = A.createGain(); master.gain.value = 0.46;
+      master.connect(comp); comp.connect(A.destination);
+    }catch(e){ A=null; }
+    return A;
+  }
+  function res(){ if(A && A.state==='suspended') A.resume(); }
+  function tone(o){
+    const a=ensure(); if(!a||muted) return; res();
+    const t0=a.currentTime+(o.delay||0), dur=o.dur||0.2;
+    const osc=a.createOscillator(), g=a.createGain();
+    osc.type=o.type||'sine';
+    osc.frequency.setValueAtTime(o.f0||440,t0);
+    if(o.f1!=null){
+      if(o.exp) osc.frequency.exponentialRampToValueAtTime(Math.max(1,o.f1), t0+dur);
+      else osc.frequency.linearRampToValueAtTime(o.f1, t0+dur);
+    }
+    const vol=(o.vol==null?0.18:o.vol);
+    g.gain.setValueAtTime(0.0001,t0);
+    g.gain.exponentialRampToValueAtTime(Math.max(0.0002,vol), t0+(o.atk||0.008));
+    g.gain.exponentialRampToValueAtTime(0.0001, t0+dur);
+    let node=osc;
+    if(o.filter){
+      const f=a.createBiquadFilter(); f.type=o.filter; f.frequency.value=o.fc||900; f.Q.value=o.q||1;
+      node.connect(f); f.connect(g);
+    } else node.connect(g);
+    g.connect(master);
+    osc.start(t0); osc.stop(t0+dur+0.06);
+  }
+  function noise(o){
+    const a=ensure(); if(!a||muted) return; res();
+    const dur=o.dur||0.3, len=Math.max(1,Math.floor(a.sampleRate*dur));
+    const buf=a.createBuffer(1,len,a.sampleRate), d=buf.getChannelData(0);
+    const dec=o.decay||2;
+    for(let i=0;i<len;i++) d[i]=(Math.random()*2-1)*Math.pow(1-i/len,dec);
+    const s=a.createBufferSource(); s.buffer=buf;
+    const f=a.createBiquadFilter(); f.type=o.filter||'lowpass';
+    const t0=a.currentTime+(o.delay||0);
+    f.frequency.setValueAtTime(o.fc||1200,t0);
+    if(o.fc1!=null) f.frequency.exponentialRampToValueAtTime(Math.max(40,o.fc1), t0+dur);
+    const g=a.createGain();
+    g.gain.setValueAtTime(o.vol==null?0.22:o.vol,t0);
+    g.gain.exponentialRampToValueAtTime(0.0001,t0+dur);
+    s.connect(f); f.connect(g); g.connect(master);
+    s.start(t0); s.stop(t0+dur+0.03);
+  }
+  return {
+    ensure, unlock(){ ensure(); res(); started=true; },
+    toggle(){ muted=!muted; if(master) master.gain.value = muted?0:0.46; return muted; },
+    shoot(p){ tone({type:'square',f0:820+p*120,f1:170,dur:0.075,vol:0.055,exp:true,filter:'lowpass',fc:2600}); },
+    thrust(){ noise({dur:0.06,fc:460,vol:0.028,decay:1}); },
+    pop(hue,c){ tone({type:'triangle',f0:280+hue*1.3+c*24,f1:88,dur:0.2,vol:0.11,exp:true});
+                noise({dur:0.2,fc:2600,fc1:220,vol:0.085}); },
+    boom(){ noise({dur:0.72,fc:1500,fc1:55,vol:0.3,decay:1.6}); tone({type:'sine',f0:130,f1:28,dur:0.62,vol:0.2,exp:true}); },
+    hurt(){ tone({type:'sawtooth',f0:210,f1:46,dur:0.5,vol:0.19,exp:true}); noise({dur:0.42,fc:820,fc1:70,vol:0.19}); },
+    blackhole(){ tone({type:'sine',f0:38,f1:230,dur:1.25,vol:0.16,exp:true});
+                 noise({dur:1.25,fc:110,fc1:1900,vol:0.09,decay:0.6}); },
+    swarm(){ tone({type:'sawtooth',f0:500,f1:1500,dur:0.3,vol:0.08,exp:true,filter:'bandpass',fc:1200,q:3}); },
+    phase(){ tone({type:'sine',f0:1500,f1:270,dur:0.26,vol:0.1,exp:true});
+             tone({type:'sine',f0:680,f1:2100,dur:0.26,vol:0.06,exp:true,delay:0.05}); },
+    reverse(){ tone({type:'square',f0:155,f1:540,dur:0.44,vol:0.1,exp:true});
+               tone({type:'square',f0:540,f1:155,dur:0.44,vol:0.08,exp:true,delay:0.12}); },
+    shard(){ for(let i=0;i<5;i++) tone({type:'square',f0:880+i*230,f1:390,dur:0.09,vol:0.04,exp:true,delay:i*0.028}); },
+    slow(){ tone({type:'sine',f0:640,f1:110,dur:0.95,vol:0.12,exp:true}); },
+    chain(i){ tone({type:'triangle',f0:380+i*95,f1:150,dur:0.15,vol:0.085,exp:true}); },
+    mirror(){ tone({type:'triangle',f0:780,f1:1620,dur:0.19,vol:0.085,exp:true});
+              tone({type:'triangle',f0:1620,f1:780,dur:0.19,vol:0.065,exp:true,delay:0.04}); },
+    rubber(){ tone({type:'sine',f0:210,f1:720,dur:0.2,vol:0.11,exp:true}); },
+    bounce(){ tone({type:'sine',f0:520,f1:260,dur:0.08,vol:0.05,exp:true}); },
+    gravity(){ tone({type:'sine',f0:940,f1:66,dur:0.8,vol:0.13,exp:true}); },
+    wave(){ [523,659,784,1046].forEach((f,i)=>tone({type:'triangle',f0:f,dur:0.3,vol:0.1,delay:i*0.085})); },
+    extra(){ [660,880,1320,1760].forEach((f,i)=>tone({type:'square',f0:f,dur:0.15,vol:0.09,delay:i*0.065})); },
+    over(){ [440,330,247,165].forEach((f,i)=>tone({type:'sawtooth',f0:f,f1:f*0.5,dur:0.5,vol:0.13,exp:true,delay:i*0.16})); },
+    blink(){ tone({type:'sine',f0:1900,f1:400,dur:0.17,vol:0.075,exp:true}); },
+    combo(n){ tone({type:'triangle',f0:520+Math.min(n,12)*70,dur:0.1,vol:0.06}); }
+  };
+})();
+
+/* ---------------- orb types ---------------- */
+const ORB_TYPES = [
+  {key:'swarm',   name:'Swarmling',  hue:135, c1:'#8dff9f', c2:'#0d4d28', desc:'Splits into a homing swarm'},
+  {key:'gravity', name:'Sinkhole',   hue:280, c1:'#c58bff', c2:'#320c60', desc:'Bursts into a gravity well'},
+  {key:'phase',   name:'Phaser',     hue:190, c1:'#7ff0ff', c2:'#083f52', desc:'Blinks out and teleports'},
+  {key:'void',    name:'Voidcore',   hue:352, c1:'#ff5b7f', c2:'#20030c', desc:'Spawns a hungry black hole'},
+  {key:'reverse', name:'Invertex',   hue:42,  c1:'#ffd166', c2:'#5c3800', desc:'Reverses your controls'},
+  {key:'shard',   name:'Glassbone',  hue:212, c1:'#bcd8ff', c2:'#1c2942', desc:'Shatters into ricochet shards'},
+  {key:'slow',    name:'Chronobub',  hue:168, c1:'#7ff0d0', c2:'#09453c', desc:'Drops a time-slow bubble'},
+  {key:'chain',   name:'Chromabomb', hue:322, c1:'#ff8bd8', c2:'#4f0937', desc:'Chain-reaction colour bomb'},
+  {key:'mirror',  name:'Twinshell',  hue:62,  c1:'#f6ff8b', c2:'#4c5000', desc:'Mirror-clones across the void'},
+  {key:'rubber',  name:'Bounceball', hue:22,  c1:'#ffa46b', c2:'#511c00', desc:'Rubber-bands off hard walls'}
+];
+const TYPE_BY_KEY = {};
+ORB_TYPES.forEach(t=> TYPE_BY_KEY[t.key]=t);
+
+(function buildLegend(){
+  const el=document.getElementById('orblist');
+  ORB_TYPES.forEach(t=>{
+    const d=document.createElement('div');
+    d.className='orbcard';
+    d.innerHTML='<span class="dot" style="color:'+t.c1+'"></span>'+
+      '<span><b style="color:'+t.c1+'">'+t.name+'</b><br>'+t.desc+'</span>';
+    el.appendChild(d);
+  });
+})();
+
+/* ---------------- input ---------------- */
+const keys = Object.create(null);
+window.addEventListener('keydown', e=>{
+  const k=e.key.toLowerCase();
+  if([' ','arrowup','arrowdown','arrowleft','arrowright'].indexOf(k)>=0) e.preventDefault();
+  if(keys[k]) return;
+  keys[k]=true;
+  Sound.unlock();
+  if(k==='p' && G.state==='play'){ G.paused=!G.paused; document.getElementById('pausetag').style.display=G.paused?'block':'none'; }
+  if(k==='m'){ toast(Sound.toggle()?'MUTED':'SOUND ON','#9fb4d8'); }
+  if(k==='enter'){ if(G.state==='start'||G.state==='over') startGame(); }
+  if(k==='shift' && G.state==='play' && !G.paused) blink();
+}, {passive:false});
+window.addEventListener('keyup', e=>{ keys[e.key.toLowerCase()]=false; });
+window.addEventListener('blur', ()=>{ for(const k in keys) keys[k]=false; });
+
+/* ---------------- game state ---------------- */
+const G = {
+  state:'start', paused:false,
+  score:0, disp:0, best:+(localStorage.getItem('orbfall_best')||0),
+  lives:3, wave:0, popped:0, nextExtra:5000,
+  combo:0, comboT:0, bestCombo:0,
+  shake:0, shakeX:0, shakeY:0, rot:0,
+  slowT:0, reverseT:0,
+  flash:0, flashCol:'#fff',
+  chroma:0,
+  t:0, waveClearT:0, respawnT:0
+};
+
+let ship=null;
+let bullets=[], orbs=[], parts=[], shards=[], swarm=[], wells=[], holes=[],
+    bubbles=[], ripples=[], floats=[], sparks=[], debris=[];
+
+let stars=[], neb=[];
+function buildStars(){
+  stars=[];
+  const n=Math.floor((W*H)/5200);
+  for(let i=0;i<n;i++){
+    const layer=randi(0,3);
+    stars.push({x:rand(W),y:rand(H),r:rand(0.4+layer*0.55,0.25),
+      a:rand(0.95,0.18),tw:rand(TAU),ts:rand(2.4,0.5),l:layer,
+      h:pick([200,205,215,45,280])});
+  }
+}
+function buildNebula(){
+  neb=[];
+  for(let i=0;i<5;i++){
+    neb.push({x:rand(W),y:rand(H),r:rand(Math.max(W,H)*0.55,Math.max(W,H)*0.22),
+      h:pick([258,196,318,168]),a:rand(0.09,0.035),vx:rand(0.06,-0.06),vy:rand(0.05,-0.05)});
+  }
+}
+resize();
+
+/* ---------------- HUD helpers ---------------- */
+const toastEl=document.getElementById('toast');
+let toastT=0;
+function toast(txt,col){
+  toastEl.textContent=txt;
+  toastEl.style.color=col||'#fff';
+  toastT=1.6;
+}
+const bannerEl=document.getElementById('wavebanner');
+let bannerT=0;
+function banner(txt){ bannerEl.textContent=txt; bannerT=1.9; }
+
+function addFloat(x,y,txt,col,size){
+  floats.push({x,y,txt,col:col||'#fff',size:size||16,life:1,vy:-38,vx:rand(16,-16)});
+}
+
+/* ---------------- particles ---------------- */
+function P(x,y,vx,vy,life,size,col,glow,drag,kind){
+  parts.push({x,y,vx,vy,life,max:life,size,col,glow:glow!==false,drag:drag||0.985,kind:kind||0,rot:rand(TAU),vr:rand(6,-6)});
+}
+function burst(x,y,n,col,spd,size,life){
+  for(let i=0;i<n;i++){
+    const a=rand(TAU), s=rand(spd,spd*0.15);
+    P(x,y,Math.cos(a)*s,Math.sin(a)*s,rand(life||0.8,(life||0.8)*0.4),rand(size||3,1),col);
+  }
+}
+function ring(x,y,col,r0,r1,life,w){
+  ripples.push({x,y,col,r:r0,r1:r1,life:life||0.6,max:life||0.6,w:w||3});
+}
+function sparkLine(x,y,ang,len,col,life){
+  sparks.push({x,y,ang,len,col,life:life||0.25,max:life||0.25});
+}
+function shakeIt(v,rotAmt){ G.shake=Math.min(38,G.shake+v); if(rotAmt) G.rot+=rotAmt; }
+function flash(col,amt){ G.flash=Math.max(G.flash,amt||0.5); G.flashCol=col||'#fff'; }
+
+/* ---------------- ship ---------------- */
+function makeShip(){
+  return {x:W/2,y:H/2,vx:0,vy:0,a:-Math.PI/2,r:13,
+    cd:0, inv:2.6, alive:true, thrust:0, blinkCd:0, trail:[], eng:0, recoil:0};
+}
+function blink(){
+  if(!ship||!ship.alive||ship.blinkCd>0) return;
+  const d=132;
+  const ox=ship.x, oy=ship.y;
+  ship.x+=Math.cos(ship.a)*d; ship.y+=Math.sin(ship.a)*d;
+  wrapPos(ship,20);
+  ship.blinkCd=3.4; ship.inv=Math.max(ship.inv,0.65);
+  Sound.blink();
+  ring(ox,oy,'#8be9ff',6,44,0.5,3);
+  ring(ship.x,ship.y,'#b06cff',40,6,0.45,3);
+  for(let i=0;i<16;i++){
+    const a=rand(TAU);
+    P(ox,oy,Math.cos(a)*rand(120,20),Math.sin(a)*rand(120,20),0.5,rand(3,1),'#8be9ff');
+    P(ship.x,ship.y,Math.cos(a)*rand(60,10),Math.sin(a)*rand(60,10),0.4,rand(2.5,1),'#c9a4ff');
+  }
+  addFloat(ship.x,ship.y-24,'BLINK','#8be9ff',13);
+}
+
+/* ---------------- orbs ---------------- */
+function spawnOrb(type,x,y,size,vx,vy,gen){
+  const t = type || pick(ORB_TYPES).key;
+  const sz = size || rand(46,34);
+  const spd = rand(52,16)*(1+G.wave*0.045);
+  const ang = rand(TAU);
+  const o = {
+    type:t, x:(x==null?0:x), y:(y==null?0:y),
+    vx:(vx==null? Math.cos(ang)*spd : vx),
+    vy:(vy==null? Math.sin(ang)*spd : vy),
+    r:sz, gen:gen||0, hp:sz>34?2:1,
+    a:rand(TAU), spin:rand(1.4,-1.4),
+    pulse:rand(TAU), wob:rand(TAU),
+    hit:0, born:0, ghost:0, seed:rand(1000),
+    verts:[]
+  };
+  const n = 11 + ((sz/9)|0);
+  for(let i=0;i<n;i++) o.verts.push(rand(1.12,0.86));
+  if(x==null){
+    // spawn away from ship
+    let tries=0;
+    do{
+      o.x=rand(W); o.y=rand(H); tries++;
+    } while(ship && torDist(o.x,o.y,ship.x,ship.y) < 210 && tries<60);
+  }
+  orbs.push(o);
+  return o;
+}
+
+function startWave(){
+  G.wave++;
+  const n = Math.min(4+Math.floor(G.wave*1.35), 17);
+  for(let i=0;i<n;i++) spawnOrb();
+  banner('WAVE '+G.wave);
+  Sound.wave();
+  ring(W/2,H/2,'#63c8ff',20,Math.max(W,H)*0.6,1.1,4);
+}
+
+/* ---------------- scoring ---------------- */
+function addScore(v,x,y,label){
+  const mult = 1 + Math.min(G.combo,15)*0.12;
+  const gain = Math.round(v*mult);
+  G.score += gain;
+  if(x!=null) addFloat(x,y,'+'+gain,'#ffe9a8',14+Math.min(8,G.combo));
+  if(label) addFloat(x,y-20,label,'#ffffff',12);
+  while(G.score >= G.nextExtra){
+    G.nextExtra += 5000 + G.lives*1500;
+    G.lives++;
+    Sound.extra();
+    toast('EXTRA LIFE','#7ff0d0');
+    flash('#7ff0d0',0.35);
+  }
+}
+function bumpCombo(){
+  G.combo++; G.comboT=1.8;
+  if(G.combo>G.bestCombo) G.bestCombo=G.combo;
+  if(G.combo>2){ Sound.combo(G.combo); }
+  if(G.combo===5) toast('COMBO x5','#ffd166');
+  if(G.combo===10) toast('COMBO x10 !','#ff8bd8');
+  if(G.combo===18) toast('UNREAL x18','#7ff0ff');
+}
+
+/* ---------------- WILD EFFECTS ---------------- */
+function popOrb(o, idx, srcX, srcY, chainDepth){
+  const T = TYPE_BY_KEY[o.type];
+  orbs.splice(idx,1);
+  G.popped++;
+  bumpCombo();
+  Sound.pop(T.hue, Math.min(G.combo,10));
+  burst(o.x,o.y,Math.floor(o.r*0.85),T.c1,rand(230,110),3.4,0.85);
+  burst(o.x,o.y,Math.floor(o.r*0.45),'#ffffff',rand(150,60),2.1,0.5);
+  ring(o.x,o.y,T.c1,o.r*0.5,o.r*2.5,0.5,3);
+  for(let i=0;i<5;i++){
+    debris.push({x:o.x,y:o.y,vx:rand(120,-120),vy:rand(120,-120),
+      r:rand(o.r*0.26,o.r*0.10),a:rand(TAU),vr:rand(5,-5),life:1.1,max:1.1,col:T.c2,edge:T.c1});
+  }
+  shakeIt(3.5+o.r*0.06);
+  addScore(Math.round(80 + (60-o.r)*3), o.x, o.y-o.r*0.6);
+
+  const gen=o.gen;
+  switch(o.type){
+
+    case 'swarm': {
+      Sound.swarm();
+      const n = o.r>36?6:4;
+      for(let i=0;i<n;i++){
+        const a=(i/n)*TAU+rand(0.5);
+        swarm.push({x:o.x+Math.cos(a)*o.r*0.5, y:o.y+Math.sin(a)*o.r*0.5,
+          vx:Math.cos(a)*160, vy:Math.sin(a)*160, r:6.5, life:9.5, max:9.5,
+          hue:T.hue, wig:rand(TAU), trail:[]});
+      }
+      ring(o.x,o.y,'#8dff9f',10,o.r*3,0.6,2);
+      addFloat(o.x,o.y-o.r,'SWARM!','#8dff9f',15);
+      break;
+    }
+
+    case 'gravity': {
+      Sound.gravity();
+      wells.push({x:o.x,y:o.y,r:150+o.r*2.2,str:1500,life:5.2,max:5.2,a:0});
+      ring(o.x,o.y,'#c58bff',o.r,200,0.9,4);
+      ring(o.x,o.y,'#8be9ff',o.r*0.4,150,1.2,2);
+      addFloat(o.x,o.y-o.r,'GRAVITY WELL','#c58bff',15);
+      flash('#7a3ccf',0.18);
+      break;
+    }
+
+    case 'phase': {
+      Sound.phase();
+      if(gen<2){
+        const nx=rand(W), ny=rand(H);
+        const c = spawnOrb('phase', nx, ny, Math.max(20,o.r*0.86), rand(90,-90), rand(90,-90), gen+1);
+        c.ghost=0.6; c.born=0.35;
+        ring(o.x,o.y,'#7ff0ff',o.r,4,0.5,3);
+        ring(nx,ny,'#7ff0ff',4,o.r*2.2,0.6,3);
+        for(let i=0;i<20;i++){
+          const a=rand(TAU);
+          P(nx,ny,Math.cos(a)*rand(180,40),Math.sin(a)*rand(180,40),0.55,rand(3,1),'#7ff0ff');
+        }
+        // ghost trail between the two
+        for(let i=0;i<10;i++){
+          const t=i/10;
+          P(lerp(o.x,nx,t),lerp(o.y,ny,t),0,0,0.4,rand(4,2),'#7ff0ff',true,0.9);
+        }
+        addFloat(o.x,o.y-o.r,'PHASED','#7ff0ff',15);
+      }
+      break;
+    }
+
+    case 'void': {
+      Sound.blackhole();
+      holes.push({x:o.x,y:o.y,r:22,rTarget:44,life:7.5,max:7.5,a:0,pull:2600,eaten:0});
+      flash('#ff5b7f',0.32);
+      shakeIt(16,0.02);
+      ring(o.x,o.y,'#ff5b7f',6,260,1.0,5);
+      ring(o.x,o.y,'#ffffff',4,120,0.6,2);
+      addFloat(o.x,o.y-o.r,'BLACK HOLE','#ff5b7f',17);
+      break;
+    }
+
+    case 'reverse': {
+      Sound.reverse();
+      G.reverseT = Math.max(G.reverseT, 6.5);
+      toast('CONTROLS REVERSED','#ffd166');
+      flash('#ffd166',0.25);
+      ring(o.x,o.y,'#ffd166',o.r,240,0.8,4);
+      for(let i=0;i<26;i++){
+        const a=rand(TAU);
+        P(o.x,o.y,Math.cos(a)*rand(200,50),Math.sin(a)*rand(200,50),0.9,rand(4,1.5),'#ffd166');
+      }
+      break;
+    }
+
+    case 'shard': {
+      Sound.shard();
+      const n=9;
+      for(let i=0;i<n;i++){
+        const a=(i/n)*TAU+rand(0.4);
+        const sp=rand(430,270);
+        shards.push({x:o.x,y:o.y,vx:Math.cos(a)*sp,vy:Math.sin(a)*sp,
+          life:4.2,max:4.2,bounces:0,a:rand(TAU),vr:rand(14,-14),len:rand(17,10),trail:[]});
+      }
+      ring(o.x,o.y,'#bcd8ff',o.r*0.4,o.r*3,0.45,2);
+      addFloat(o.x,o.y-o.r,'SHATTER','#bcd8ff',15);
+      break;
+    }
+
+    case 'slow': {
+      Sound.slow();
+      bubbles.push({x:o.x,y:o.y,r:16,rTarget:210+o.r*1.6,life:6.5,max:6.5,a:0});
+      ring(o.x,o.y,'#7ff0d0',10,220,1.0,3);
+      addFloat(o.x,o.y-o.r,'TIME BUBBLE','#7ff0d0',15);
+      break;
+    }
+
+    case 'chain': {
+      const depth=chainDepth||0;
+      Sound.chain(depth);
+      const R = 230;
+      ring(o.x,o.y,'#ff8bd8',10,R,0.55,4);
+      flash('#ff8bd8',0.14);
+      shakeIt(5);
+      let hitAny=0;
+      for(let j=orbs.length-1;j>=0;j--){
+        const q=orbs[j];
+        if(torDist(o.x,o.y,q.x,q.y) < R + q.r){
+          hitAny++;
+          const qq=q, jj=j;
+          // slight cascade delay via scheduled pop
+          scheduled.push({t:0.09*(hitAny), fn:()=>{
+            const k=orbs.indexOf(qq);
+            if(k>=0) popOrb(qq,k,o.x,o.y,depth+1);
+          }});
+        }
+      }
+      if(hitAny) addFloat(o.x,o.y-o.r,'CHAIN x'+hitAny,'#ff8bd8',16);
+      else addFloat(o.x,o.y-o.r,'BOOM','#ff8bd8',15);
+      // colour shrapnel
+      for(let i=0;i<34;i++){
+        const a=rand(TAU);
+        P(o.x,o.y,Math.cos(a)*rand(340,90),Math.sin(a)*rand(340,90),rand(0.9,0.4),rand(4,1.5),
+          'hsl('+randi(280,360)+',100%,72%)');
+      }
+      break;
+    }
+
+    case 'mirror': {
+      Sound.mirror();
+      if(gen<2 && o.r>19){
+        const nr=o.r*0.72;
+        const mk=(mx,my)=>{
+          const c=spawnOrb('mirror',mx,my,nr,rand(140,-140),rand(140,-140),gen+1);
+          c.born=0.4;
+          ring(mx,my,'#f6ff8b',4,nr*3,0.55,3);
+        };
+        mk((o.x+W*0.5)%W, o.y);
+        mk(o.x, (o.y+H*0.5)%H);
+        mk((o.x+W*0.5)%W, (o.y+H*0.5)%H);
+        addFloat(o.x,o.y-o.r,'MIRROR x3','#f6ff8b',15);
+      }
+      break;
+    }
+
+    case 'rubber': {
+      Sound.rubber();
+      const n=3;
+      for(let i=0;i<n;i++){
+        const a=rand(TAU), sp=rand(300,190);
+        const c=spawnOrb('rubber',o.x,o.y,Math.max(15,o.r*0.5),Math.cos(a)*sp,Math.sin(a)*sp,gen+1);
+        c.bouncy=true; c.hp=1; c.born=0.25;
+      }
+      ring(o.x,o.y,'#ffa46b',o.r*0.5,o.r*3.2,0.5,3);
+      addFloat(o.x,o.y-o.r,'RUBBER!','#ffa46b',15);
+      break;
+    }
+  }
+}
+
+/* scheduled callbacks (chain cascade) */
+let scheduled=[];
+
+/* ---------------- damage ---------------- */
+function killShip(){
+  if(!ship.alive || ship.inv>0) return;
+  ship.alive=false;
+  G.lives--;
+  Sound.boom(); Sound.hurt();
+  flash('#ff6f8f',0.6);
+  shakeIt(34,0.045);
+  burst(ship.x,ship.y,74,'#ffd9e2',rand(380,120),4,1.3);
+  burst(ship.x,ship.y,40,'#8be9ff',rand(280,80),3,1.0);
+  ring(ship.x,ship.y,'#ff6f8f',8,240,0.9,5);
+  for(let i=0;i<8;i++){
+    debris.push({x:ship.x,y:ship.y,vx:rand(200,-200),vy:rand(200,-200),
+      r:rand(9,4),a:rand(TAU),vr:rand(8,-8),life:1.6,max:1.6,col:'#233',edge:'#9fe9ff'});
+  }
+  G.combo=0;
+  if(G.lives<=0){ gameOver(); }
+  else { G.respawnT=1.5; toast(G.lives+' SHIP'+(G.lives>1?'S':'')+' LEFT','#ff9fb4'); }
+}
+function respawn(){
+  ship=makeShip();
+  ship.inv=2.8;
+  ring(ship.x,ship.y,'#7ff0d0',80,10,0.7,3);
+}
+
+/* ---------------- game flow ---------------- */
+function startGame(){
+  G.state='play'; G.paused=false;
+  G.score=0; G.disp=0; G.lives=3; G.wave=0; G.popped=0; G.nextExtra=5000;
+  G.combo=0; G.comboT=0; G.bestCombo=0;
+  G.slowT=0; G.reverseT=0; G.shake=0; G.rot=0; G.flash=0; G.chroma=0;
+  G.waveClearT=0; G.respawnT=0;
+  bullets=[];orbs=[];parts=[];shards=[];swarm=[];wells=[];holes=[];
+  bubbles=[];ripples=[];floats=[];sparks=[];debris=[];scheduled=[];
+  ship=makeShip();
+  document.getElementById('start').classList.add('hidden');
+  document.getElementById('gameover').classList.add('hidden');
+  document.getElementById('pausetag').style.display='none';
+  Sound.unlock();
+  startWave();
+}
+function gameOver(){
+  G.state='over';
+  Sound.over();
+  const isBest = G.score>G.best;
+  if(isBest){ G.best=G.score; localStorage.setItem('orbfall_best',String(G.best)); }
+  document.getElementById('goScore').textContent=G.score;
+  document.getElementById('goWave').textContent=G.wave;
+  document.getElementById('goPopped').textContent=G.popped;
+  document.getElementById('goCombo').textContent=G.bestCombo;
+  document.getElementById('goBest').textContent=G.best;
+  document.getElementById('newbest').style.display=isBest?'block':'none';
+  document.getElementById('goSub').textContent = isBest? 'a new personal record' : pick([
+    'the orbs got you','chaos wins this round','signal terminated','try not to shoot the voidcores']);
+  setTimeout(()=>document.getElementById('gameover').classList.remove('hidden'),620);
+}
+document.getElementById('startBtn').addEventListener('click',startGame);
+document.getElementById('againBtn').addEventListener('click',startGame);
+
+/* ---------------- update ---------------- */
+function update(dt){
+  G.t+=dt;
+
+  // scheduled cascades
+  for(let i=scheduled.length-1;i>=0;i--){
+    scheduled[i].t-=dt;
+    if(scheduled[i].t<=0){ const f=scheduled[i].fn; scheduled.splice(i,1); f(); }
+  }
+
+  // time scale from bubbles
+  let ts=1;
+  if(ship){
+    for(let i=0;i<bubbles.length;i++){
+      const b=bubbles[i];
+      if(torDist(ship.x,ship.y,b.x,b.y) < b.r) { ts=0.42; break; }
+    }
+  }
+  G.slowT = lerp(G.slowT, ts<1?1:0, 1-Math.pow(0.001,dt));
+  const wdt = dt*ts;   // world dt (affected by slow bubbles)
+
+  if(G.comboT>0){ G.comboT-=dt; if(G.comboT<=0) G.combo=0; }
+  if(G.reverseT>0){ G.reverseT-=dt; if(G.reverseT<=0) toast('CONTROLS RESTORED','#7ff0d0'); }
+  G.chroma = lerp(G.chroma, (G.reverseT>0?1:0)*0.6 + G.flash*0.8, 0.12);
+
+  /* ---- ship ---- */
+  if(ship && ship.alive){
+    const rev = G.reverseT>0 ? -1 : 1;
+    const left  = keys['arrowleft']||keys['a'];
+    const right = keys['arrowright']||keys['d'];
+    const up    = keys['arrowup']||keys['w'];
+    let turn=0;
+    if(left)  turn-=1;
+    if(right) turn+=1;
+    ship.a += turn*rev*3.5*dt;
+
+    ship.thrust = lerp(ship.thrust, up?1:0, 1-Math.pow(0.0008,dt));
+    if(up){
+      const acc = 335*(G.reverseT>0?1:1);
+      ship.vx += Math.cos(ship.a)*acc*dt;
+      ship.vy += Math.sin(ship.a)*acc*dt;
+      ship.eng+=dt;
+      if(ship.eng>0.045){
+        ship.eng=0;
+        Sound.thrust();
+        const bx=ship.x-Math.cos(ship.a)*15, by=ship.y-Math.sin(ship.a)*15;
+        for(let i=0;i<2;i++){
+          const sp=rand(190,90), a=ship.a+Math.PI+rand(0.45,-0.45);
+          P(bx,by,Math.cos(a)*sp+ship.vx*0.35,Math.sin(a)*sp+ship.vy*0.35,
+            rand(0.45,0.2),rand(3.6,1.6),
+            pick(['#8be9ff','#7ff0d0','#ffffff','#b06cff']),true,0.93);
+        }
+      }
+    }
+    const drag=Math.pow(0.994,dt*60);
+    ship.vx*=drag; ship.vy*=drag;
+    const sp=Math.hypot(ship.vx,ship.vy), MAX=560;
+    if(sp>MAX){ ship.vx=ship.vx/sp*MAX; ship.vy=ship.vy/sp*MAX; }
+    ship.x+=ship.vx*dt; ship.y+=ship.vy*dt;
+    wrapPos(ship,20);
+
+    ship.trail.push({x:ship.x,y:ship.y,a:ship.a});
+    if(ship.trail.length>14) ship.trail.shift();
+
+    if(ship.cd>0) ship.cd-=dt;
+    if(ship.inv>0) ship.inv-=dt;
+    if(ship.blinkCd>0) ship.blinkCd-=dt;
+    if(ship.recoil>0) ship.recoil-=dt*6;
+
+    if(keys[' '] && ship.cd<=0){
+      ship.cd=0.135;
+      ship.recoil=1;
+      const mx=ship.x+Math.cos(ship.a)*17, my=ship.y+Math.sin(ship.a)*17;
+      bullets.push({x:mx,y:my,
+        vx:Math.cos(ship.a)*620+ship.vx*0.45,
+        vy:Math.sin(ship.a)*620+ship.vy*0.45,
+        life:1.25,r:3.1,trail:[],hue:190+Math.min(G.combo,10)*8});
+      ship.vx-=Math.cos(ship.a)*22; ship.vy-=Math.sin(ship.a)*22;
+      Sound.shoot(Math.min(G.combo,8)/8);
+      sparkLine(mx,my,ship.a,18,'#cffcff',0.12);
+      for(let i=0;i<3;i++){
+        const a=ship.a+rand(0.5,-0.5);
+        P(mx,my,Math.cos(a)*rand(200,60),Math.sin(a)*rand(200,60),0.2,rand(2.4,1),'#cffcff');
+      }
+    }
+  } else if(G.respawnT>0){
+    G.respawnT-=dt;
+    if(G.respawnT<=0 && G.lives>0) respawn();
+  }
+
+  /* ---- gravity sources act on ship + bullets + orbs ---- */
+  function applyPull(o,src,strength,radius,dtx){
+    const dx=wrapDelta(o.x,src.x,W), dy=wrapDelta(o.y,src.y,H);
+    const d=Math.hypot(dx,dy);
+    if(d<1 || d>radius) return d;
+    const f=strength/(d*d+800);
+    o.vx+=dx*f*dtx; o.vy+=dy*f*dtx;
+    return d;
+  }
+
+  for(let i=wells.length-1;i>=0;i--){
+    const w=wells[i];
+    w.life-=dt; w.a+=dt*2.2;
+    if(w.life<=0){ wells.splice(i,1); ring(w.x,w.y,'#c58bff',w.r,4,0.4,2); continue; }
+    const k=Math.min(1,w.life/0.7);
+    if(ship&&ship.alive) applyPull(ship,w,w.str*k,w.r,dt);
+    for(const b of bullets) applyPull(b,w,w.str*1.7*k,w.r,dt);
+    for(const o of orbs) applyPull(o,w,w.str*0.55*k,w.r,wdt);
+    for(const s of shards) applyPull(s,w,w.str*0.9*k,w.r,wdt);
+    if(Math.random()<0.6){
+      const a=rand(TAU), rr=w.r*rand(1,0.55);
+      P(w.x+Math.cos(a)*rr,w.y+Math.sin(a)*rr,-Math.cos(a)*90,-Math.sin(a)*90,0.6,rand(2.6,1),'#c58bff');
+    }
+  }
+
+  for(let i=holes.length-1;i>=0;i--){
+    const h=holes[i];
+    h.life-=dt; h.a+=dt*3.2;
+    h.r=lerp(h.r,h.rTarget*(h.life<1?h.life:1),0.08);
+    if(h.life<=0){
+      holes.splice(i,1);
+      ring(h.x,h.y,'#ff5b7f',h.r*3,4,0.6,4);
+      flash('#ff5b7f',0.2); shakeIt(9);
+      burst(h.x,h.y,40,'#ff9fb4',rand(320,120),3,0.9);
+      continue;
+    }
+    const R=430;
+    if(ship&&ship.alive){
+      const d=applyPull(ship,h,h.pull,R,dt);
+      if(d<h.r*0.65){ killShip(); }
+    }
+    for(let j=bullets.length-1;j>=0;j--){
+      const b=bullets[j];
+      const d=applyPull(b,h,h.pull*1.6,R,dt);
+      if(d<h.r*0.6){ bullets.splice(j,1); h.eaten++; burst(h.x,h.y,4,'#ff9fb4',60,2,0.35); }
+    }
+    for(let j=orbs.length-1;j>=0;j--){
+      const o=orbs[j];
+      const d=applyPull(o,h,h.pull*0.8,R,wdt);
+      if(d<h.r*0.55){
+        orbs.splice(j,1); G.popped++;
+        addScore(50,o.x,o.y,'CONSUMED');
+        burst(o.x,o.y,16,TYPE_BY_KEY[o.type].c1,120,2.4,0.5);
+        h.rTarget=Math.min(80,h.rTarget+2.5);
+      }
+    }
+    for(let j=swarm.length-1;j>=0;j--){
+      const s=swarm[j];
+      const d=applyPull(s,h,h.pull,R,wdt);
+      if(d<h.r*0.6){ swarm.splice(j,1); burst(h.x,h.y,6,'#8dff9f',80,2,0.4); }
+    }
+    // accretion glitter
+    for(let k=0;k<2;k++){
+      const a=rand(TAU), rr=h.r*rand(3.4,1.5);
+      P(h.x+Math.cos(a)*rr,h.y+Math.sin(a)*rr,
+        Math.cos(a+1.5)*90-Math.cos(a)*140, Math.sin(a+1.5)*90-Math.sin(a)*140,
+        0.55,rand(2.4,1),pick(['#ff5b7f','#ffd0dd','#b06cff']));
+    }
+  }
+
+  /* ---- bubbles ---- */
+  for(let i=bubbles.length-1;i>=0;i--){
+    const b=bubbles[i];
+    b.life-=dt; b.a+=dt;
+    b.r=lerp(b.r,b.rTarget*(b.life<1?b.life:1),0.07);
+    if(b.life<=0){ bubbles.splice(i,1); ring(b.x,b.y,'#7ff0d0',b.r,6,0.5,3); }
+  }
+
+  /* ---- bullets ---- */
+  for(let i=bullets.length-1;i>=0;i--){
+    const b=bullets[i];
+    b.life-=dt;
+    b.x+=b.vx*dt; b.y+=b.vy*dt;
+    wrapPos(b,10);
+    b.trail.push({x:b.x,y:b.y});
+    if(b.trail.length>9) b.trail.shift();
+    if(b.life<=0){ bullets.splice(i,1); continue; }
+
+    let hit=false;
+    for(let j=orbs.length-1;j>=0;j--){
+      const o=orbs[j];
+      if(o.born>0) continue;
+      if(torDist(b.x,b.y,o.x,o.y) < o.r*0.92){
+        bullets.splice(i,1); hit=true;
+        o.hp--; o.hit=1;
+        burst(b.x,b.y,7,'#ffffff',rand(160,60),2,0.34);
+        if(o.hp<=0) popOrb(o,j,b.x,b.y,0);
+        else {
+          Sound.bounce();
+          shakeIt(2);
+          const T=TYPE_BY_KEY[o.type];
+          burst(b.x,b.y,9,T.c1,rand(190,70),2.4,0.45);
+          o.vx+=b.vx*0.055; o.vy+=b.vy*0.055;
+        }
+        break;
+      }
+    }
+    if(hit) continue;
+
+    // bullets kill swarmlings
+    for(let j=swarm.length-1;j>=0;j--){
+      const s=swarm[j];
+      if(torDist(b.x,b.y,s.x,s.y) < s.r+3){
+        swarm.splice(j,1); bullets.splice(i,1);
+        addScore(30,s.x,s.y);
+        burst(s.x,s.y,14,'#8dff9f',rand(200,70),2.4,0.5);
+        bumpCombo();
+        Sound.pop(135,3);
+        break;
+      }
+    }
+  }
+
+  /* ---- orbs ---- */
+  for(let i=orbs.length-1;i>=0;i--){
+    const o=orbs[i];
+    if(o.born>0) o.born-=dt;
+    o.a+=o.spin*wdt;
+    o.pulse+=wdt*2.4;
+    o.wob+=wdt*1.6;
+    if(o.hit>0) o.hit-=dt*3.2;
+
+    if(o.bouncy){
+      o.x+=o.vx*wdt; o.y+=o.vy*wdt;
+      let bounced=false;
+      if(o.x<o.r){ o.x=o.r; o.vx=Math.abs(o.vx)*1.02; bounced=true; }
+      if(o.x>W-o.r){ o.x=W-o.r; o.vx=-Math.abs(o.vx)*1.02; bounced=true; }
+      if(o.y<o.r){ o.y=o.r; o.vy=Math.abs(o.vy)*1.02; bounced=true; }
+      if(o.y>H-o.r){ o.y=H-o.r; o.vy=-Math.abs(o.vy)*1.02; bounced=true; }
+      if(bounced){
+        Sound.rubber(); shakeIt(2.5);
+        ring(o.x,o.y,'#ffa46b',o.r*0.5,o.r*2.4,0.35,3);
+        burst(o.x,o.y,10,'#ffa46b',rand(220,80),2.4,0.4);
+      }
+      const s=Math.hypot(o.vx,o.vy);
+      if(s>420){ o.vx=o.vx/s*420; o.vy=o.vy/s*420; }
+    } else {
+      o.x+=o.vx*wdt; o.y+=o.vy*wdt;
+      wrapPos(o,o.r+8);
+    }
+
+    if(o.type==='phase'){
+      o.ghost = 0.5+0.5*Math.sin(G.t*2.2+o.seed);
+      if(Math.random()<0.004 && o.gen<2){
+        // spontaneous micro-blink
+        const ox=o.x, oy=o.y;
+        o.x=rand(W); o.y=rand(H);
+        ring(ox,oy,'#7ff0ff',o.r,4,0.35,2);
+        ring(o.x,o.y,'#7ff0ff',4,o.r*2,0.4,2);
+      }
+    }
+
+    // ship collision
+    if(ship && ship.alive && ship.inv<=0 && o.born<=0){
+      if(torDist(ship.x,ship.y,o.x,o.y) < o.r*0.82+ship.r*0.7) killShip();
+    }
+  }
+
+  /* ---- swarmlings ---- */
+  for(let i=swarm.length-1;i>=0;i--){
+    const s=swarm[i];
+    s.life-=wdt; s.wig+=wdt*9;
+    if(s.life<=0){ swarm.splice(i,1); burst(s.x,s.y,8,'#8dff9f',80,2,0.4); continue; }
+    if(ship&&ship.alive){
+      const dx=wrapDelta(s.x,ship.x,W), dy=wrapDelta(s.y,ship.y,H);
+      const d=Math.hypot(dx,dy)||1;
+      const acc=340;
+      s.vx+=(dx/d)*acc*wdt; s.vy+=(dy/d)*acc*wdt;
+      s.vx+=Math.cos(s.wig)*36*wdt; s.vy+=Math.sin(s.wig*1.3)*36*wdt;
+    }
+    const sp=Math.hypot(s.vx,s.vy), M=250;
+    if(sp>M){ s.vx=s.vx/sp*M; s.vy=s.vy/sp*M; }
+    s.vx*=Math.pow(0.99,wdt*60); s.vy*=Math.pow(0.99,wdt*60);
+    s.x+=s.vx*wdt; s.y+=s.vy*wdt;
+    wrapPos(s,12);
+    s.trail.push({x:s.x,y:s.y});
+    if(s.trail.length>7) s.trail.shift();
+    if(Math.random()<0.25) P(s.x,s.y,rand(24,-24),rand(24,-24),0.35,rand(2.2,0.8),'#8dff9f');
+    if(ship&&ship.alive&&ship.inv<=0 && torDist(s.x,s.y,ship.x,ship.y)<s.r+ship.r*0.7) killShip();
+  }
+
+  /* ---- shards ---- */
+  for(let i=shards.length-1;i>=0;i--){
+    const s=shards[i];
+    s.life-=wdt; s.a+=s.vr*wdt;
+    if(s.life<=0){ shards.splice(i,1); continue; }
+    s.x+=s.vx*wdt; s.y+=s.vy*wdt;
+    let b=false;
+    if(s.x<0){ s.x=0; s.vx=Math.abs(s.vx); b=true; }
+    if(s.x>W){ s.x=W; s.vx=-Math.abs(s.vx); b=true; }
+    if(s.y<0){ s.y=0; s.vy=Math.abs(s.vy); b=true; }
+    if(s.y>H){ s.y=H; s.vy=-Math.abs(s.vy); b=true; }
+    if(b){
+      s.bounces++;
+      Sound.bounce();
+      burst(s.x,s.y,7,'#bcd8ff',rand(190,70),2.2,0.35);
+      ring(s.x,s.y,'#bcd8ff',2,26,0.3,2);
+      if(s.bounces>7){ shards.splice(i,1); continue; }
+    }
+    s.trail.push({x:s.x,y:s.y});
+    if(s.trail.length>8) s.trail.shift();
+    // shards damage orbs
+    for(let j=orbs.length-1;j>=0;j--){
+      const o=orbs[j];
+      if(o.born>0) continue;
+      if(torDist(s.x,s.y,o.x,o.y)<o.r*0.85){
+        shards.splice(i,1);
+        o.hp--; o.hit=1;
+        if(o.hp<=0) popOrb(o,j,s.x,s.y,0);
+        else burst(s.x,s.y,8,'#bcd8ff',150,2,0.4);
+        break;
+      }
+    }
+    if(ship&&ship.alive&&ship.inv<=0 && torDist(s.x,s.y,ship.x,ship.y)<ship.r*0.8) killShip();
+  }
+
+  /* ---- particles / fx ---- */
+  for(let i=parts.length-1;i>=0;i--){
+    const p=parts[i];
+    p.life-=dt;
+    if(p.life<=0){ parts.splice(i,1); continue; }
+    p.x+=p.vx*dt; p.y+=p.vy*dt;
+    p.vx*=Math.pow(p.drag,dt*60); p.vy*=Math.pow(p.drag,dt*60);
+    p.rot+=p.vr*dt;
+  }
+  for(let i=debris.length-1;i>=0;i--){
+    const d=debris[i];
+    d.life-=dt;
+    if(d.life<=0){ debris.splice(i,1); continue; }
+    d.x+=d.vx*dt; d.y+=d.vy*dt; d.a+=d.vr*dt;
+    d.vx*=Math.pow(0.975,dt*60); d.vy*=Math.pow(0.975,dt*60);
+  }
+  for(let i=ripples.length-1;i>=0;i--){
+    const r=ripples[i]; r.life-=dt;
+    if(r.life<=0){ ripples.splice(i,1); continue; }
+    const t=1-r.life/r.max;
+    r.cur = lerp(r.r, r.r1, easeOut(t));
+  }
+  for(let i=sparks.length-1;i>=0;i--){ sparks[i].life-=dt; if(sparks[i].life<=0) sparks.splice(i,1); }
+  for(let i=floats.length-1;i>=0;i--){
+    const f=floats[i]; f.life-=dt*1.25;
+    if(f.life<=0){ floats.splice(i,1); continue; }
+    f.y+=f.vy*dt; f.x+=f.vx*dt; f.vy*=Math.pow(0.94,dt*60);
+  }
+
+  /* ---- camera / flash ---- */
+  G.shake*=Math.pow(0.0009,dt);
+  if(G.shake<0.05) G.shake=0;
+  const s=G.shake;
+  G.shakeX=rand(s,-s); G.shakeY=rand(s,-s);
+  G.rot*=Math.pow(0.002,dt);
+  G.flash*=Math.pow(0.0022,dt);
+  G.disp += (G.score-G.disp)*Math.min(1,dt*7);
+
+  /* ---- toast / banner ---- */
+  if(toastT>0){ toastT-=dt; toastEl.style.opacity=String(clamp(toastT>1.2?(1.6-toastT)/0.4:toastT/1.2,0,1)); }
+  else toastEl.style.opacity='0';
+  if(bannerT>0){
+    bannerT-=dt;
+    const k=clamp(bannerT>1.4?(1.9-bannerT)/0.5:bannerT/1.4,0,1);
+    bannerEl.style.opacity=String(k);
+    bannerEl.style.transform='translate(-50%,-50%) scale('+(1+(1-k)*0.16)+')';
+  } else bannerEl.style.opacity='0';
+
+  /* ---- wave clear ---- */
+  if(G.state==='play' && orbs.length===0 && G.respawnT<=0){
+    G.waveClearT+=dt;
+    if(G.waveClearT>1.1){
+      G.waveClearT=0;
+      addScore(400+G.wave*120, W/2, H*0.4, 'WAVE CLEAR');
+      startWave();
+    }
+  } else G.waveClearT=0;
+
+  /* ---- stars ---- */
+  for(const st of stars){ st.tw+=dt*st.ts; }
+  for(const n of neb){
+    n.x+=n.vx*dt*10; n.y+=n.vy*dt*10;
+    if(n.x<-n.r) n.x=W+n.r; if(n.x>W+n.r) n.x=-n.r;
+    if(n.y<-n.r) n.y=H+n.r; if(n.y>H+n.r) n.y=-n.r;
+  }
+}
+
+/* ---------------- drawing ---------------- */
+function drawBG(){
+  ctx.fillStyle='#04040c';
+  ctx.fillRect(0,0,W,H);
+
+  // nebula clouds
+  ctx.globalCompositeOperation='lighter';
+  for(const n of neb){
+    const g=ctx.createRadialGradient(n.x,n.y,0,n.x,n.y,n.r);
+    g.addColorStop(0,'hsla('+n.h+',85%,58%,'+n.a+')');
+    g.addColorStop(0.55,'hsla('+n.h+',85%,45%,'+(n.a*0.35)+')');
+    g.addColorStop(1,'hsla('+n.h+',85%,40%,0)');
+    ctx.fillStyle=g;
+    ctx.beginPath(); ctx.arc(n.x,n.y,n.r,0,TAU); ctx.fill();
+  }
+  ctx.globalCompositeOperation='source-over';
+
+  // stars with parallax based on ship velocity
+  let px=0, py=0;
+  if(ship){ px=-ship.vx*0.012; py=-ship.vy*0.012; }
+  for(const st of stars){
+    const tw=0.55+0.45*Math.sin(st.tw);
+    const a=st.a*tw;
+    const sx=st.x+px*(st.l+1), sy=st.y+py*(st.l+1);
+    ctx.fillStyle='hsla('+st.h+',80%,'+(72+st.l*8)+'%,'+a+')';
+    ctx.beginPath();
+    ctx.arc(((sx%W)+W)%W, ((sy%H)+H)%H, st.r, 0, TAU);
+    ctx.fill();
+  }
+  // vignette
+  const vg=ctx.createRadialGradient(W/2,H/2,Math.min(W,H)*0.3,W/2,H/2,Math.max(W,H)*0.78);
+  vg.addColorStop(0,'rgba(0,0,0,0)');
+  vg.addColorStop(1,'rgba(0,0,0,0.72)');
+  ctx.fillStyle=vg; ctx.fillRect(0,0,W,H);
+}
+
+/* draw with toroidal repetition when near edges */
+function wrapDraw(x,y,r,fn){
+  const offs=[[0,0]];
+  if(x<r) offs.push([W,0]); else if(x>W-r) offs.push([-W,0]);
+  if(y<r) offs.push([0,H]); else if(y>H-r) offs.push([0,-H]);
+  if(offs.length===3) offs.push([offs[1][0],offs[2][1]]);
+  for(let i=0;i<offs.length;i++){
+    ctx.save(); ctx.translate(offs[i][0],offs[i][1]); fn(); ctx.restore();
+  }
+}
+
+function drawOrb(o){
+  const T=TYPE_BY_KEY[o.type];
+  const pulse=1+Math.sin(o.pulse)*0.045;
+  const R=o.r*pulse*(o.born>0? (1-o.born/0.5)*0.5+0.5 : 1);
+  const alpha = o.type==='phase' ? 0.45+0.55*o.ghost : 1;
+
+  ctx.save();
+  ctx.globalAlpha=alpha;
+  ctx.translate(o.x,o.y);
+  ctx.rotate(o.a);
+
+  // outer glow
+  ctx.globalCompositeOperation='lighter';
+  const gg=ctx.createRadialGradient(0,0,R*0.35,0,0,R*2.15);
+  gg.addColorStop(0,hexA(T.c1,0.36));
+  gg.addColorStop(0.5,hexA(T.c1,0.12));
+  gg.addColorStop(1,hexA(T.c1,0));
+  ctx.fillStyle=gg;
+  ctx.beginPath(); ctx.arc(0,0,R*2.15,0,TAU); ctx.fill();
+  ctx.globalCompositeOperation='source-over';
+
+  // body blob
+  ctx.beginPath();
+  const n=o.verts.length;
+  for(let i=0;i<=n;i++){
+    const idx=i%n;
+    const a=(i/n)*TAU;
+    const wob=1+Math.sin(o.wob+idx*1.7)*0.035;
+    const rr=R*o.verts[idx]*wob;
+    const x=Math.cos(a)*rr, y=Math.sin(a)*rr;
+    if(i===0) ctx.moveTo(x,y);
+    else{
+      const pa=((i-1)/n)*TAU;
+      const pidx=(i-1)%n;
+      const prr=R*o.verts[pidx]*(1+Math.sin(o.wob+pidx*1.7)*0.035);
+      const cx=(Math.cos(pa)*prr+x)/2, cy=(Math.sin(pa)*prr+y)/2;
+      ctx.quadraticCurveTo(Math.cos((pa+a)/2)*((prr+rr)/2)*1.06, Math.sin((pa+a)/2)*((prr+rr)/2)*1.06, x,y);
+      void cx; void cy;
+    }
+  }
+  ctx.closePath();
+
+  const bg=ctx.createRadialGradient(-R*0.32,-R*0.36,R*0.06,0,0,R*1.15);
+  bg.addColorStop(0,'#ffffff');
+  bg.addColorStop(0.14,T.c1);
+  bg.addColorStop(0.62,T.c2);
+  bg.addColorStop(1,'#04040c');
+  ctx.fillStyle=bg;
+  ctx.fill();
+
+  ctx.lineWidth=1.8;
+  ctx.strokeStyle=hexA(T.c1,0.85);
+  ctx.stroke();
+
+  // hit flash
+  if(o.hit>0){
+    ctx.globalCompositeOperation='lighter';
+    ctx.fillStyle='rgba(255,255,255,'+(o.hit*0.5)+')';
+    ctx.fill();
+    ctx.globalCompositeOperation='source-over';
+  }
+
+  // type-specific interior glyph
+  ctx.globalCompositeOperation='lighter';
+  ctx.lineWidth=1.6;
+  ctx.strokeStyle=hexA(T.c1,0.95);
+  const g=G.t;
+  switch(o.type){
+    case 'swarm':
+      for(let i=0;i<5;i++){
+        const a=g*1.6+i*TAU/5, rr=R*0.5;
+        ctx.beginPath(); ctx.arc(Math.cos(a)*rr,Math.sin(a)*rr,R*0.13,0,TAU);
+        ctx.fillStyle=hexA(T.c1,0.9); ctx.fill();
+      }
+      break;
+    case 'gravity':
+      for(let i=0;i<3;i++){
+        ctx.beginPath();
+        for(let k=0;k<28;k++){
+          const t=k/27, aa=t*TAU*1.5+g*1.5+i*2.1, rr=R*0.82*(1-t*0.85);
+          const x=Math.cos(aa)*rr, y=Math.sin(aa)*rr;
+          k?ctx.lineTo(x,y):ctx.moveTo(x,y);
+        }
+        ctx.stroke();
+      }
+      break;
+    case 'phase':
+      for(let i=0;i<3;i++){
+        ctx.globalAlpha=alpha*(0.5-i*0.13);
+        ctx.beginPath(); ctx.arc(Math.sin(g*3+i)*R*0.2,0,R*(0.7-i*0.17),0,TAU); ctx.stroke();
+      }
+      ctx.globalAlpha=alpha;
+      break;
+    case 'void':
+      ctx.fillStyle='#000';
+      ctx.globalCompositeOperation='source-over';
+      ctx.beginPath(); ctx.arc(0,0,R*0.44,0,TAU); ctx.fill();
+      ctx.globalCompositeOperation='lighter';
+      ctx.beginPath(); ctx.arc(0,0,R*0.46,-g*2,-g*2+2.4); ctx.stroke();
+      ctx.beginPath(); ctx.arc(0,0,R*0.62,-g*1.4+3, -g*1.4+5); ctx.stroke();
+      break;
+    case 'reverse':
+      ctx.beginPath();
+      ctx.arc(0,0,R*0.5,0.6,0.6+4.2); ctx.stroke();
+      ctx.beginPath();
+      ctx.moveTo(Math.cos(0.6+4.2)*R*0.5,Math.sin(0.6+4.2)*R*0.5);
+      ctx.lineTo(Math.cos(0.6+4.2)*R*0.5+R*0.2,Math.sin(0.6+4.2)*R*0.5-R*0.06);
+      ctx.lineTo(Math.cos(0.6+4.2)*R*0.5+R*0.03,Math.sin(0.6+4.2)*R*0.5+R*0.2);
+      ctx.closePath(); ctx.fillStyle=hexA(T.c1,0.9); ctx.fill();
+      break;
+    case 'shard':
+      for(let i=0;i<5;i++){
+        const a=i*TAU/5+g*0.4;
+        ctx.beginPath();
+        ctx.moveTo(0,0);
+        ctx.lineTo(Math.cos(a)*R*0.82,Math.sin(a)*R*0.82);
+        ctx.stroke();
+      }
+      break;
+    case 'slow': {
+      ctx.beginPath(); ctx.arc(0,0,R*0.55,0,TAU); ctx.stroke();
+      const ha=-Math.PI/2+ (g*0.55%TAU);
+      const ma=-Math.PI/2+ (g*3.1%TAU);
+      ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(Math.cos(ha)*R*0.3,Math.sin(ha)*R*0.3); ctx.stroke();
+      ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(Math.cos(ma)*R*0.5,Math.sin(ma)*R*0.5); ctx.stroke();
+      break;
+    }
+    case 'chain':
+      for(let i=0;i<4;i++){
+        ctx.strokeStyle='hsla('+((g*80+i*70)%360)+',100%,72%,0.9)';
+        ctx.beginPath();
+        ctx.arc(0,0,R*(0.24+i*0.16),g*(i%2?1.5:-1.5),g*(i%2?1.5:-1.5)+2.0);
+        ctx.stroke();
+      }
+      break;
+    case 'mirror':
+      ctx.beginPath();
+      ctx.moveTo(0,-R*0.75); ctx.lineTo(0,R*0.75); ctx.stroke();
+      ctx.beginPath();
+      ctx.moveTo(-R*0.75,0); ctx.lineTo(R*0.75,0); ctx.stroke();
+      ctx.globalAlpha=alpha*0.5;
+      ctx.beginPath(); ctx.arc(-R*0.34,0,R*0.24,0,TAU); ctx.stroke();
+      ctx.beginPath(); ctx.arc(R*0.34,0,R*0.24,0,TAU); ctx.stroke();
+      ctx.globalAlpha=alpha;
+      break;
+    case 'rubber':
+      for(let i=0;i<3;i++){
+        ctx.beginPath();
+        ctx.arc(0,0,R*(0.28+i*0.2)*(1+Math.sin(g*4+i)*0.07),0,TAU);
+        ctx.stroke();
+      }
+      break;
+  }
+  ctx.globalCompositeOperation='source-over';
+
+  // specular highlight
+  ctx.globalAlpha=alpha*0.55;
+  const hg=ctx.createRadialGradient(-R*0.38,-R*0.42,0,-R*0.38,-R*0.42,R*0.55);
+  hg.addColorStop(0,'rgba(255,255,255,0.9)');
+  hg.addColorStop(1,'rgba(255,255,255,0)');
+  ctx.fillStyle=hg;
+  ctx.beginPath(); ctx.arc(-R*0.34,-R*0.38,R*0.55,0,TAU); ctx.fill();
+
+  ctx.restore();
+}
+
+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 drawShip(){
+  if(!ship) return;
+  const blink = ship.inv>0 && (Math.floor(G.t*14)%2===0);
+  ctx.save();
+  ctx.translate(ship.x,ship.y);
+
+  // engine plume
+  if(ship.thrust>0.02){
+    ctx.save();
+    ctx.rotate(ship.a);
+    ctx.globalCompositeOperation='lighter';
+    const L=(26+Math.sin(G.t*40)*7)*ship.thrust;
+    const pg=ctx.createLinearGradient(-12,0,-12-L,0);
+    pg.addColorStop(0,'rgba(255,255,255,'+(0.85*ship.thrust)+')');
+    pg.addColorStop(0.28,'rgba(140,235,255,'+(0.6*ship.thrust)+')');
+    pg.addColorStop(1,'rgba(176,108,255,0)');
+    ctx.fillStyle=pg;
+    ctx.beginPath();
+    ctx.moveTo(-11,-6.5); ctx.lineTo(-12-L,0); ctx.lineTo(-11,6.5); ctx.closePath();
+    ctx.fill();
+    ctx.restore();
+  }
+
+  ctx.rotate(ship.a);
+  const rec = ship.recoil>0? -ship.recoil*2.4 : 0;
+  ctx.translate(rec,0);
+
+  ctx.globalAlpha = blink? 0.32 : 1;
+
+  // hull glow
+  ctx.globalCompositeOperation='lighter';
+  const g=ctx.createRadialGradient(0,0,2,0,0,26);
+  g.addColorStop(0,'rgba(140,235,255,0.4)');
+  g.addColorStop(1,'rgba(140,235,255,0)');
+  ctx.fillStyle=g;
+  ctx.beginPath(); ctx.arc(0,0,26,0,TAU); ctx.fill();
+  ctx.globalCompositeOperation='source-over';
+
+  // hull
+  ctx.beginPath();
+  ctx.moveTo(17,0);
+  ctx.lineTo(-9,-11);
+  ctx.lineTo(-5,0);
+  ctx.lineTo(-9,11);
+  ctx.closePath();
+  const hg=ctx.createLinearGradient(-10,0,17,0);
+  hg.addColorStop(0,'#16324a');
+  hg.addColorStop(0.55,'#9fe9ff');
+  hg.addColorStop(1,'#ffffff');
+  ctx.fillStyle=hg;
+  ctx.fill();
+  ctx.lineWidth=1.7;
+  ctx.strokeStyle= G.reverseT>0? '#ffd166' : '#cff6ff';
+  ctx.stroke();
+
+  // cockpit
+  ctx.beginPath(); ctx.ellipse(4,0,4.6,2.9,0,0,TAU);
+  ctx.fillStyle= G.reverseT>0? 'rgba(255,209,102,.95)' : 'rgba(127,240,208,.95)';
+  ctx.fill();
+
+  // shield ring when invulnerable
+  ctx.restore();
+  if(ship.inv>0){
+    ctx.save();
+    ctx.translate(ship.x,ship.y);
+    ctx.globalCompositeOperation='lighter';
+    const k=clamp(ship.inv/2.8,0,1);
+    ctx.strokeStyle='rgba(127,240,208,'+(0.35+0.3*Math.sin(G.t*9))*k+')';
+    ctx.lineWidth=2;
+    ctx.beginPath(); ctx.arc(0,0,20+Math.sin(G.t*5)*2.4,0,TAU); ctx.stroke();
+    ctx.restore();
+  }
+  // blink ready pip
+  if(ship.blinkCd<=0){
+    ctx.save();
+    ctx.globalCompositeOperation='lighter';
+    ctx.fillStyle='rgba(139,233,255,'+(0.4+0.3*Math.sin(G.t*6))+')';
+    ctx.beginPath(); ctx.arc(ship.x,ship.y,3.2,0,TAU); ctx.fill();
+    ctx.restore();
+  }
+}
+
+function draw(){
+  drawBG();
+
+  ctx.save();
+  ctx.translate(W/2+G.shakeX, H/2+G.shakeY);
+  ctx.rotate(G.rot);
+  ctx.translate(-W/2,-H/2);
+
+  ctx.globalCompositeOperation='lighter';
+
+  /* bubbles (time slow) */
+  for(const b of bubbles){
+    const k=clamp(b.life/b.max,0,1);
+    const g=ctx.createRadialGradient(b.x,b.y,b.r*0.1,b.x,b.y,b.r);
+    g.addColorStop(0,'rgba(127,240,208,0.02)');
+    g.addColorStop(0.75,'rgba(127,240,208,'+(0.06*k)+')');
+    g.addColorStop(0.98,'rgba(127,240,208,'+(0.28*k)+')');
+    g.addColorStop(1,'rgba(127,240,208,0)');
+    ctx.fillStyle=g;
+    ctx.beginPath(); ctx.arc(b.x,b.y,b.r,0,TAU); ctx.fill();
+    ctx.strokeStyle='rgba(180,255,235,'+(0.3*k)+')';
+    ctx.lineWidth=1.4;
+    ctx.beginPath(); ctx.arc(b.x,b.y,b.r,0,TAU); ctx.stroke();
+    // clock ticks
+    for(let i=0;i<12;i++){
+      const a=i*TAU/12+b.a*0.4;
+      ctx.strokeStyle='rgba(127,240,208,'+(0.22*k)+')';
+      ctx.beginPath();
+      ctx.moveTo(b.x+Math.cos(a)*b.r*0.9,b.y+Math.sin(a)*b.r*0.9);
+      ctx.lineTo(b.x+Math.cos(a)*b.r,b.y+Math.sin(a)*b.r);
+      ctx.stroke();
+    }
+  }
+
+  /* gravity wells */
+  for(const w of wells){
+    const k=clamp(w.life/w.max,0,1);
+    const g=ctx.createRadialGradient(w.x,w.y,2,w.x,w.y,w.r);
+    g.addColorStop(0,'rgba(197,139,255,'+(0.5*k)+')');
+    g.addColorStop(0.35,'rgba(140,90,220,'+(0.18*k)+')');
+    g.addColorStop(1,'rgba(90,40,180,0)');
+    ctx.fillStyle=g;
+    ctx.beginPath(); ctx.arc(w.x,w.y,w.r,0,TAU); ctx.fill();
+    ctx.lineWidth=1.3;
+    for(let i=0;i<4;i++){
+      ctx.strokeStyle='rgba(197,139,255,'+(0.28*k)+')';
+      ctx.beginPath();
+      for(let s=0;s<40;s++){
+        const t=s/39;
+        const aa=t*TAU*1.6 + w.a*(i%2?1:-1) + i*1.57;
+        const rr=w.r*(0.12+t*0.86);
+        const x=w.x+Math.cos(aa)*rr, y=w.y+Math.sin(aa)*rr;
+        s?ctx.lineTo(x,y):ctx.moveTo(x,y);
+      }
+      ctx.stroke();
+    }
+  }
+
+  /* black holes */
+  for(const h of holes){
+    const k=clamp(h.life/h.max,0,1);
+    // accretion disk
+    ctx.save();
+    ctx.translate(h.x,h.y); ctx.rotate(h.a*0.5);
+    for(let i=0;i<3;i++){
+      ctx.strokeStyle='rgba(255,'+(80+i*50)+','+(130+i*40)+','+(0.5*k)+')';
+      ctx.lineWidth=2.6-i*0.6;
+      ctx.beginPath();
+      ctx.ellipse(0,0,h.r*(2.0+i*0.7),h.r*(0.62+i*0.24),h.a*0.3,0,TAU);
+      ctx.stroke();
+    }
+    ctx.restore();
+    const g=ctx.createRadialGradient(h.x,h.y,h.r*0.5,h.x,h.y,h.r*3.4);
+    g.addColorStop(0,'rgba(255,91,127,'+(0.5*k)+')');
+    g.addColorStop(0.4,'rgba(176,108,255,'+(0.16*k)+')');
+    g.addColorStop(1,'rgba(90,20,90,0)');
+    ctx.fillStyle=g;
+    ctx.beginPath(); ctx.arc(h.x,h.y,h.r*3.4,0,TAU); ctx.fill();
+  }
+  ctx.globalCompositeOperation='source-over';
+  for(const h of holes){
+    // event horizon (true black)
+    ctx.fillStyle='#000';
+    ctx.beginPath(); ctx.arc(h.x,h.y,h.r*0.9,0,TAU); ctx.fill();
+    ctx.strokeStyle='rgba(255,255,255,0.5)';
+    ctx.lineWidth=1.2;
+    ctx.beginPath(); ctx.arc(h.x,h.y,h.r*0.9,0,TAU); ctx.stroke();
+  }
+  ctx.globalCompositeOperation='lighter';
+
+  /* ripples */
+  for(const r of ripples){
+    const k=clamp(r.life/r.max,0,1);
+    ctx.strokeStyle=hexA(r.col,k*0.85);
+    ctx.lineWidth=r.w*k+0.4;
+    ctx.beginPath(); ctx.arc(r.x,r.y,r.cur||r.r,0,TAU); ctx.stroke();
+  }
+
+  /* debris chunks */
+  ctx.globalCompositeOperation='source-over';
+  for(const d of debris){
+    const k=clamp(d.life/d.max,0,1);
+    ctx.save();
+    ctx.globalAlpha=k;
+    ctx.translate(d.x,d.y); ctx.rotate(d.a);
+    ctx.beginPath();
+    ctx.moveTo(d.r,0);
+    ctx.lineTo(-d.r*0.6,d.r*0.8);
+    ctx.lineTo(-d.r*0.3,-d.r*0.7);
+    ctx.closePath();
+    ctx.fillStyle=d.col; ctx.fill();
+    ctx.strokeStyle=d.edge; ctx.lineWidth=1; ctx.stroke();
+    ctx.restore();
+  }
+  ctx.globalAlpha=1;
+
+  /* orbs */
+  for(const o of orbs) wrapDraw(o.x,o.y,o.r*2.4,()=>drawOrb(o));
+
+  /* swarmlings */
+  ctx.globalCompositeOperation='lighter';
+  for(const s of swarm){
+    const k=clamp(s.life/s.max,0,1);
+    for(let i=0;i<s.trail.length;i++){
+      const p=s.trail[i], t=i/s.trail.length;
+      if(Math.abs(p.x-s.x)>W/2||Math.abs(p.y-s.y)>H/2) continue;
+      ctx.fillStyle='rgba(141,255,159,'+(t*0.35*k)+')';
+      ctx.beginPath(); ctx.arc(p.x,p.y,s.r*t*0.8,0,TAU); ctx.fill();
+    }
+    const g=ctx.createRadialGradient(s.x,s.y,0,s.x,s.y,s.r*3);
+    g.addColorStop(0,'rgba(220,255,225,'+(0.95*k)+')');
+    g.addColorStop(0.28,'rgba(141,255,159,'+(0.6*k)+')');
+    g.addColorStop(1,'rgba(141,255,159,0)');
+    ctx.fillStyle=g;
+    ctx.beginPath(); ctx.arc(s.x,s.y,s.r*3,0,TAU); ctx.fill();
+    ctx.fillStyle='#eaffe9';
+    ctx.beginPath(); ctx.arc(s.x,s.y,s.r*0.55,0,TAU); ctx.fill();
+  }
+
+  /* shards */
+  for(const s of shards){
+    const k=clamp(s.life/s.max,0,1);
+    for(let i=1;i<s.trail.length;i++){
+      const a=s.trail[i-1], b=s.trail[i];
+      if(Math.abs(a.x-b.x)>W/2||Math.abs(a.y-b.y)>H/2) continue;
+      ctx.strokeStyle='rgba(188,216,255,'+(i/s.trail.length*0.35*k)+')';
+      ctx.lineWidth=2.2*(i/s.trail.length);
+      ctx.beginPath(); ctx.moveTo(a.x,a.y); ctx.lineTo(b.x,b.y); ctx.stroke();
+    }
+    ctx.save();
+    ctx.translate(s.x,s.y); ctx.rotate(s.a);
+    ctx.fillStyle='rgba(226,240,255,'+(0.95*k)+')';
+    ctx.beginPath();
+    ctx.moveTo(s.len,0); ctx.lineTo(-s.len*0.34,s.len*0.3); ctx.lineTo(-s.len*0.2,-s.len*0.28);
+    ctx.closePath(); ctx.fill();
+    ctx.strokeStyle='rgba(255,255,255,'+(0.8*k)+')';
+    ctx.lineWidth=1; ctx.stroke();
+    ctx.restore();
+  }
+
+  /* bullets */
+  for(const b of bullets){
+    for(let i=1;i<b.trail.length;i++){
+      const p=b.trail[i-1], q=b.trail[i], t=i/b.trail.length;
+      if(Math.abs(p.x-q.x)>W/2||Math.abs(p.y-q.y)>H/2) continue;
+      ctx.strokeStyle='hsla('+b.hue+',100%,'+(60+t*30)+'%,'+(t*0.6)+')';
+      ctx.lineWidth=b.r*2*t;
+      ctx.lineCap='round';
+      ctx.beginPath(); ctx.moveTo(p.x,p.y); ctx.lineTo(q.x,q.y); ctx.stroke();
+    }
+    const g=ctx.createRadialGradient(b.x,b.y,0,b.x,b.y,b.r*4.5);
+    g.addColorStop(0,'#ffffff');
+    g.addColorStop(0.3,'hsla('+b.hue+',100%,72%,0.85)');
+    g.addColorStop(1,'hsla('+b.hue+',100%,60%,0)');
+    ctx.fillStyle=g;
+    ctx.beginPath(); ctx.arc(b.x,b.y,b.r*4.5,0,TAU); ctx.fill();
+  }
+
+  /* particles */
+  for(const p of parts){
+    const k=clamp(p.life/p.max,0,1);
+    ctx.globalAlpha=k;
+    ctx.fillStyle=p.col;
+    ctx.beginPath(); ctx.arc(p.x,p.y,p.size*(0.35+k*0.75),0,TAU); ctx.fill();
+  }
+  ctx.globalAlpha=1;
+
+  /* muzzle sparks */
+  for(const s of sparks){
+    const k=clamp(s.life/s.max,0,1);
+    ctx.strokeStyle=hexA(s.col,k);
+    ctx.lineWidth=2.4*k;
+    ctx.beginPath();
+    ctx.moveTo(s.x,s.y);
+    ctx.lineTo(s.x+Math.cos(s.ang)*s.len*k,s.y+Math.sin(s.ang)*s.len*k);
+    ctx.stroke();
+  }
+
+  ctx.globalCompositeOperation='source-over';
+
+  /* ship */
+  if(ship && ship.alive) wrapDraw(ship.x,ship.y,34,drawShip);
+
+  /* floating score text */
+  ctx.textAlign='center';
+  for(const f of floats){
+    const k=clamp(f.life,0,1);
+    ctx.globalAlpha=k;
+    ctx.font='900 '+f.size+'px "Trebuchet MS",system-ui,sans-serif';
+    ctx.shadowColor=f.col; ctx.shadowBlur=14;
+    ctx.fillStyle=f.col;
+    ctx.fillText(f.txt,f.x,f.y);
+    ctx.shadowBlur=0;
+  }
+  ctx.globalAlpha=1;
+
+  ctx.restore();
+
+  drawHUD();
+
+  /* full-screen flash */
+  if(G.flash>0.004){
+    ctx.globalCompositeOperation='lighter';
+    ctx.fillStyle=hexA(G.flashCol,Math.min(0.6,G.flash*0.55));
+    ctx.fillRect(0,0,W,H);
+    ctx.globalCompositeOperation='source-over';
+  }
+  /* reverse-mode tint */
+  if(G.reverseT>0){
+    const k=Math.min(1,G.reverseT/1.5)*0.10*(0.7+0.3*Math.sin(G.t*8));
+    ctx.fillStyle='rgba(255,190,60,'+k+')';
+    ctx.fillRect(0,0,W,H);
+    // edge bars
+    ctx.fillStyle='rgba(255,209,102,'+(0.16+0.08*Math.sin(G.t*10))+')';
+    ctx.fillRect(0,0,W,4); ctx.fillRect(0,H-4,W,4);
+  }
+  /* slow-mo tint */
+  if(G.slowT>0.02){
+    ctx.fillStyle='rgba(127,240,208,'+(G.slowT*0.055)+')';
+    ctx.fillRect(0,0,W,H);
+  }
+  /* scanlines */
+  ctx.globalAlpha=0.045;
+  ctx.fillStyle='#000';
+  for(let y=0;y<H;y+=3) ctx.fillRect(0,y,W,1);
+  ctx.globalAlpha=1;
+}
+
+/* ---------------- HUD ---------------- */
+function drawHUD(){
+  if(G.state!=='play') return;
+  const pad=22;
+  ctx.save();
+  ctx.textAlign='left';
+  ctx.textBaseline='top';
+
+  // score
+  ctx.font='900 34px "Trebuchet MS",system-ui,sans-serif';
+  ctx.shadowColor='rgba(120,210,255,.85)'; ctx.shadowBlur=18;
+  ctx.fillStyle='#dff2ff';
+  ctx.fillText(String(Math.round(G.disp)).padStart(6,'0'), pad, pad-4);
+  ctx.shadowBlur=0;
+
+  ctx.font='700 11px "Trebuchet MS",system-ui,sans-serif';
+  ctx.fillStyle='#6f88ad';
+  ctx.fillText('SCORE   BEST '+G.best, pad+2, pad+32);
+
+  // lives (ship icons)
+  for(let i=0;i<Math.min(G.lives,8);i++){
+    ctx.save();
+    ctx.translate(pad+10+i*24, pad+62);
+    ctx.rotate(-Math.PI/2);
+    ctx.beginPath();
+    ctx.moveTo(9,0); ctx.lineTo(-5,-6); ctx.lineTo(-2.6,0); ctx.lineTo(-5,6);
+    ctx.closePath();
+    ctx.fillStyle='#9fe9ff';
+    ctx.shadowColor='#63c8ff'; ctx.shadowBlur=10;
+    ctx.fill();
+    ctx.restore();
+  }
+  ctx.shadowBlur=0;
+
+  // wave + orbs right
+  ctx.textAlign='right';
+  ctx.font='900 26px "Trebuchet MS",system-ui,sans-serif';
+  ctx.fillStyle='#cfe4ff';
+  ctx.shadowColor='rgba(176,108,255,.7)'; ctx.shadowBlur=14;
+  ctx.fillText('WAVE '+G.wave, W-pad, pad);
+  ctx.shadowBlur=0;
+  ctx.font='700 11px "Trebuchet MS",system-ui,sans-serif';
+  ctx.fillStyle='#6f88ad';
+  ctx.fillText(orbs.length+' ORBS  ·  '+G.popped+' POPPED', W-pad, pad+30);
+
+  // combo meter
+  if(G.combo>1){
+    const k=clamp(G.comboT/1.8,0,1);
+    const cx=W/2, cy=pad+16;
+    ctx.textAlign='center';
+    ctx.font='900 '+(20+Math.min(G.combo,14))+'px "Trebuchet MS",system-ui,sans-serif';
+    const hue=(140+G.combo*14)%360;
+    ctx.fillStyle='hsl('+hue+',100%,72%)';
+    ctx.shadowColor='hsl('+hue+',100%,60%)'; ctx.shadowBlur=18;
+    ctx.fillText('x'+G.combo+' COMBO', cx, cy-8);
+    ctx.shadowBlur=0;
+    // bar
+    const bw=150;
+    ctx.fillStyle='rgba(255,255,255,.12)';
+    ctx.fillRect(cx-bw/2, cy+22, bw, 4);
+    ctx.fillStyle='hsl('+hue+',100%,66%)';
+    ctx.fillRect(cx-bw/2, cy+22, bw*k, 4);
+  }
+
+  // blink cooldown ring (bottom-left)
+  if(ship){
+    const bx=pad+18, by=H-pad-18;
+    const rdy = ship.blinkCd<=0;
+    ctx.beginPath(); ctx.arc(bx,by,14,0,TAU);
+    ctx.strokeStyle='rgba(255,255,255,.16)'; ctx.lineWidth=3; ctx.stroke();
+    const p = rdy?1:1-(ship.blinkCd/3.4);
+    ctx.beginPath(); ctx.arc(bx,by,14,-Math.PI/2,-Math.PI/2+TAU*p);
+    ctx.strokeStyle= rdy? '#8be9ff' : '#4a6a86';
+    ctx.lineWidth=3;
+    if(rdy){ ctx.shadowColor='#8be9ff'; ctx.shadowBlur=12; }
+    ctx.stroke(); ctx.shadowBlur=0;
+    ctx.textAlign='center'; ctx.textBaseline='middle';
+    ctx.font='900 10px "Trebuchet MS",system-ui,sans-serif';
+    ctx.fillStyle= rdy? '#cff4ff':'#5c7a92';
+    ctx.fillText('⇧',bx,by+1);
+    ctx.textBaseline='top';
+    ctx.font='700 9px "Trebuchet MS",system-ui,sans-serif';
+    ctx.fillStyle='#5b7characters'.slice(0,7)==='#5b7cha'?'#5b7c96':'#5b7c96';
+    ctx.fillText('BLINK',bx,by+20);
+  }
+
+  // active-effect chips (bottom-right)
+  const chips=[];
+  if(G.reverseT>0) chips.push({t:'REVERSED '+G.reverseT.toFixed(1),c:'#ffd166'});
+  if(bubbles.length) chips.push({t:'TIME BUBBLE',c:'#7ff0d0'});
+  if(holes.length) chips.push({t:'BLACK HOLE',c:'#ff5b7f'});
+  if(wells.length) chips.push({t:'GRAVITY WELL',c:'#c58bff'});
+  if(swarm.length) chips.push({t:'SWARM '+swarm.length,c:'#8dff9f'});
+  ctx.textAlign='right'; ctx.textBaseline='middle';
+  ctx.font='800 11px "Trebuchet MS",system-ui,sans-serif';
+  for(let i=0;i<chips.length;i++){
+    const y=H-pad-10-i*20;
+    ctx.fillStyle=hexA(chips[i].c,0.14);
+    const w=ctx.measureText(chips[i].t).width+20;
+    ctx.beginPath();
+    if(ctx.roundRect) { ctx.roundRect(W-pad-w, y-8, w, 16, 8); ctx.fill(); }
+    else ctx.fillRect(W-pad-w,y-8,w,16);
+    ctx.fillStyle=chips[i].c;
+    ctx.fillText(chips[i].t, W-pad-10, y);
+  }
+  ctx.restore();
+}
+
+/* ---------------- loop ---------------- */
+let last=performance.now();
+function frame(now){
+  let dt=(now-last)/1000; last=now;
+  if(dt>0.05) dt=0.05;
+  if(G.state==='play' && !G.paused) update(dt);
+  else {
+    // idle ambience on menus
+    G.t+=dt;
+    for(const st of stars) st.tw+=dt*st.ts;
+    for(const n of neb){
+      n.x+=n.vx*dt*10; n.y+=n.vy*dt*10;
+      if(n.x<-n.r) n.x=W+n.r; if(n.x>W+n.r) n.x=-n.r;
+      if(n.y<-n.r) n.y=H+n.r; if(n.y>H+n.r) n.y=-n.r;
+    }
+    for(let i=parts.length-1;i>=0;i--){
+      const p=parts[i]; p.life-=dt;
+      if(p.life<=0){ parts.splice(i,1); continue; }
+      p.x+=p.vx*dt; p.y+=p.vy*dt;
+      p.vx*=Math.pow(p.drag,dt*60); p.vy*=Math.pow(p.drag,dt*60);
+    }
+    G.flash*=Math.pow(0.0022,dt);
+    G.shake*=Math.pow(0.0009,dt);
+    G.shakeX=rand(G.shake,-G.shake); G.shakeY=rand(G.shake,-G.shake);
+  }
+  draw();
+  requestAnimationFrame(frame);
+}
+requestAnimationFrame(frame);
+
+/* menu ambience: idle orbs drifting behind the start screen */
+(function ambient(){
+  for(let i=0;i<7;i++){
+    const o=spawnOrb();
+    o.vx*=0.5; o.vy*=0.5;
+  }
+})();
+</script>
+</body>
+</html>
\ No newline at end of file
diff --git a/games/wild-orbs/index.html b/games/wild-orbs/index.html
new file mode 100644
index 0000000..2faf026
--- /dev/null
+++ b/games/wild-orbs/index.html
@@ -0,0 +1,1753 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>ORBFALL — Wild Orb Protocol</title>
+<style>
+  *{margin:0;padding:0;box-sizing:border-box;}
+  html,body{width:100%;height:100%;overflow:hidden;background:#03030a;
+    font-family:"Trebuchet MS","Segoe UI",system-ui,sans-serif;-webkit-font-smoothing:antialiased;}
+  #wrap{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;
+    background:radial-gradient(ellipse at 50% 40%,#0b0b22 0%,#04040e 55%,#000 100%);}
+  canvas{display:block;touch-action:none;}
+  #ui{position:absolute;inset:0;pointer-events:none;overflow:hidden;}
+  .screen{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;
+    text-align:center;color:#e8f6ff;pointer-events:auto;overflow-y:auto;padding:24px 16px;
+    background:radial-gradient(ellipse at 50% 45%,rgba(22,10,62,.74) 0%,rgba(3,3,12,.95) 70%);
+    backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);
+    animation:fadeIn .35s ease both;}
+  @keyframes fadeIn{from{opacity:0}to{opacity:1}}
+  .hidden{display:none!important;}
+  h1{font-size:clamp(40px,9.4vw,104px);letter-spacing:.15em;font-weight:900;line-height:1;
+     background:linear-gradient(180deg,#ffffff 0%,#cfeaff 34%,#8be9ff 52%,#b06cff 100%);
+     -webkit-background-clip:text;background-clip:text;color:transparent;
+     filter:drop-shadow(0 0 26px rgba(120,180,255,.42));margin-bottom:.08em;}
+  h1 .sub{display:block;font-size:.185em;letter-spacing:.6em;color:#8fd6ff;
+     -webkit-text-fill-color:#8fd6ff;margin-top:.55em;opacity:.9;filter:none;}
+  .tag{font-size:clamp(12px,1.7vw,16px);color:#9fb4d8;letter-spacing:.2em;text-transform:uppercase;margin-bottom:20px;}
+  .keys{display:flex;gap:8px;flex-wrap:wrap;justify-content:center;margin-bottom:18px;max-width:min(780px,94vw);}
+  .key{border:1px solid rgba(140,190,255,.26);background:rgba(20,32,64,.48);padding:7px 13px;border-radius:8px;
+    font-size:12.5px;color:#cfe4ff;letter-spacing:.07em;}
+  .key b{color:#7ff0d0;}
+  .orblist{display:grid;grid-template-columns:repeat(auto-fit,minmax(168px,1fr));gap:7px;max-width:min(940px,95vw);margin-bottom:22px;}
+  .orbcard{display:flex;align-items:center;gap:9px;background:rgba(12,18,40,.5);
+    border:1px solid rgba(120,170,255,.14);border-radius:10px;padding:7px 10px;font-size:11px;
+    color:#a9bfe0;text-align:left;letter-spacing:.02em;transition:border-color .2s,transform .2s,background .2s;}
+  .orbcard:hover{border-color:rgba(150,200,255,.4);transform:translateY(-2px);background:rgba(18,26,54,.65);}
+  .dot{width:16px;height:16px;border-radius:50%;flex:0 0 auto;background:currentColor;
+    box-shadow:0 0 12px currentColor,inset 0 0 6px rgba(255,255,255,.55);}
+  .btn{pointer-events:auto;cursor:pointer;border:none;border-radius:999px;padding:15px 46px;font-size:18px;
+    font-weight:800;letter-spacing:.2em;color:#04121a;font-family:inherit;text-transform:uppercase;
+    background:linear-gradient(135deg,#7ff0d0,#63c8ff 55%,#b06cff);
+    box-shadow:0 0 34px rgba(99,200,255,.5),0 8px 26px rgba(0,0,0,.5);
+    transition:transform .12s ease,box-shadow .12s ease;}
+  .btn:hover{transform:translateY(-2px) scale(1.03);box-shadow:0 0 54px rgba(140,220,255,.8),0 10px 30px rgba(0,0,0,.55);}
+  .btn:active{transform:translateY(1px) scale(.98);}
+  .small{margin-top:16px;font-size:11.5px;color:#6f84a8;letter-spacing:.13em;}
+  #gameover h2{font-size:clamp(34px,7vw,74px);letter-spacing:.12em;color:#ff6f8f;font-weight:900;
+    text-shadow:0 0 34px rgba(255,80,120,.6);margin-bottom:.12em;}
+  .statrow{display:flex;gap:30px;flex-wrap:wrap;justify-content:center;margin:16px 0 22px;}
+  .stat{min-width:104px;}
+  .stat .v{font-size:31px;font-weight:800;color:#8be9ff;text-shadow:0 0 18px rgba(100,200,255,.55);}
+  .stat .l{font-size:10.5px;letter-spacing:.22em;color:#7f93b6;text-transform:uppercase;margin-top:3px;}
+  #newbest{color:#ffe066;font-weight:900;letter-spacing:.25em;font-size:13px;margin-bottom:6px;
+    text-shadow:0 0 20px rgba(255,220,80,.7);animation:pulseBest 1.1s ease-in-out infinite;}
+  @keyframes pulseBest{0%,100%{opacity:.55}50%{opacity:1}}
+  #toast{position:absolute;left:50%;top:13%;transform:translateX(-50%);font-size:clamp(15px,3vw,29px);
+    font-weight:900;letter-spacing:.16em;text-transform:uppercase;color:#fff;opacity:0;
+    text-shadow:0 0 24px currentColor;white-space:nowrap;}
+  #wavebanner{position:absolute;left:50%;top:42%;transform:translate(-50%,-50%);font-size:clamp(28px,7.6vw,82px);
+    font-weight:900;letter-spacing:.3em;color:#fff;opacity:0;text-shadow:0 0 44px #63c8ff;white-space:nowrap;}
+  #pausetag{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);font-size:clamp(26px,6vw,60px);
+    font-weight:900;letter-spacing:.4em;color:#cfe6ff;text-shadow:0 0 40px #63c8ff;display:none;}
+</style>
+</head>
+<body>
+<div id="wrap">
+  <canvas id="cv"></canvas>
+  <div id="ui">
+    <div id="toast"></div>
+    <div id="wavebanner"></div>
+    <div id="pausetag">PAUSED</div>
+
+    <div id="start" class="screen">
+      <h1>ORBFALL<span class="sub">wild orb protocol</span></h1>
+      <div class="tag">Ten orbs. Ten kinds of chaos. Shoot carefully.</div>
+      <div class="keys">
+        <div class="key"><b>← →</b> / <b>A D</b> rotate</div>
+        <div class="key"><b>↑</b> / <b>W</b> thrust</div>
+        <div class="key"><b>SPACE</b> fire</div>
+        <div class="key"><b>SHIFT</b> blink</div>
+        <div class="key"><b>P</b> pause</div>
+        <div class="key"><b>M</b> mute</div>
+      </div>
+      <div class="orblist" id="orblist"></div>
+      <button class="btn" id="startBtn">Launch</button>
+      <div class="small">Screen wraps · inertia is real · chain your pops for combo</div>
+    </div>
+
+    <div id="gameover" class="screen hidden">
+      <h2>SIGNAL LOST</h2>
+      <div class="tag" id="goSub">the orbs got you</div>
+      <div id="newbest" style="display:none">◆ NEW RECORD ◆</div>
+      <div class="statrow">
+        <div class="stat"><div class="v" id="goScore">0</div><div class="l">Score</div></div>
+        <div class="stat"><div class="v" id="goWave">1</div><div class="l">Wave</div></div>
+        <div class="stat"><div class="v" id="goPopped">0</div><div class="l">Orbs Popped</div></div>
+        <div class="stat"><div class="v" id="goCombo">0</div><div class="l">Best Combo</div></div>
+        <div class="stat"><div class="v" id="goBest">0</div><div class="l">Best</div></div>
+      </div>
+      <button class="btn" id="againBtn">Again</button>
+      <div class="small">Press ENTER to relaunch</div>
+    </div>
+  </div>
+</div>
+
+<script>
+"use strict";
+/* =====================================================================
+   ORBFALL — single-file canvas arcade. No assets, no CDNs.
+   ===================================================================== */
+
+const cv  = document.getElementById('cv');
+const ctx = cv.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;
+  cv.width  = Math.floor(W*DPR);
+  cv.height = Math.floor(H*DPR);
+  cv.style.width  = W+'px';
+  cv.style.height = H+'px';
+  ctx.setTransform(DPR,0,0,DPR,0,0);
+  buildStars();
+  buildNebula();
+}
+window.addEventListener('resize', resize);
+
+/* ---------------- math ---------------- */
+const TAU=Math.PI*2;
+const rand=(a=1,b=0)=> b+Math.random()*(a-b);
+const randi=(a,b)=> Math.floor(rand(a,b));
+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;
+const easeOut=t=> 1-Math.pow(1-t,3);
+
+function wrapDelta(a,b,size){ let d=b-a; if(d>size/2) d-=size; if(d<-size/2) d+=size; return d; }
+function wrapPos(o,pad){
+  const p = pad||40;
+  if(o.x < -p) o.x += W+p*2; else if(o.x > W+p) o.x -= W+p*2;
+  if(o.y < -p) o.y += H+p*2; else if(o.y > H+p) o.y -= H+p*2;
+}
+function torDist(ax,ay,bx,by){
+  const dx=wrapDelta(ax,bx,W), dy=wrapDelta(ay,by,H);
+  return Math.hypot(dx,dy);
+}
+
+/* ---------------- audio ---------------- */
+const Sound = (()=>{
+  let A=null, master=null, comp=null, muted=false, started=false;
+  function ensure(){
+    if(A) return A;
+    try{
+      A = new (window.AudioContext||window.webkitAudioContext)();
+      comp = A.createDynamicsCompressor();
+      comp.threshold.value=-14; comp.knee.value=22; comp.ratio.value=8;
+      comp.attack.value=0.004; comp.release.value=0.22;
+      master = A.createGain(); master.gain.value = 0.46;
+      master.connect(comp); comp.connect(A.destination);
+    }catch(e){ A=null; }
+    return A;
+  }
+  function res(){ if(A && A.state==='suspended') A.resume(); }
+  function tone(o){
+    const a=ensure(); if(!a||muted) return; res();
+    const t0=a.currentTime+(o.delay||0), dur=o.dur||0.2;
+    const osc=a.createOscillator(), g=a.createGain();
+    osc.type=o.type||'sine';
+    osc.frequency.setValueAtTime(o.f0||440,t0);
+    if(o.f1!=null){
+      if(o.exp) osc.frequency.exponentialRampToValueAtTime(Math.max(1,o.f1), t0+dur);
+      else osc.frequency.linearRampToValueAtTime(o.f1, t0+dur);
+    }
+    const vol=(o.vol==null?0.18:o.vol);
+    g.gain.setValueAtTime(0.0001,t0);
+    g.gain.exponentialRampToValueAtTime(Math.max(0.0002,vol), t0+(o.atk||0.008));
+    g.gain.exponentialRampToValueAtTime(0.0001, t0+dur);
+    let node=osc;
+    if(o.filter){
+      const f=a.createBiquadFilter(); f.type=o.filter; f.frequency.value=o.fc||900; f.Q.value=o.q||1;
+      node.connect(f); f.connect(g);
+    } else node.connect(g);
+    g.connect(master);
+    osc.start(t0); osc.stop(t0+dur+0.06);
+  }
+  function noise(o){
+    const a=ensure(); if(!a||muted) return; res();
+    const dur=o.dur||0.3, len=Math.max(1,Math.floor(a.sampleRate*dur));
+    const buf=a.createBuffer(1,len,a.sampleRate), d=buf.getChannelData(0);
+    const dec=o.decay||2;
+    for(let i=0;i<len;i++) d[i]=(Math.random()*2-1)*Math.pow(1-i/len,dec);
+    const s=a.createBufferSource(); s.buffer=buf;
+    const f=a.createBiquadFilter(); f.type=o.filter||'lowpass';
+    const t0=a.currentTime+(o.delay||0);
+    f.frequency.setValueAtTime(o.fc||1200,t0);
+    if(o.fc1!=null) f.frequency.exponentialRampToValueAtTime(Math.max(40,o.fc1), t0+dur);
+    const g=a.createGain();
+    g.gain.setValueAtTime(o.vol==null?0.22:o.vol,t0);
+    g.gain.exponentialRampToValueAtTime(0.0001,t0+dur);
+    s.connect(f); f.connect(g); g.connect(master);
+    s.start(t0); s.stop(t0+dur+0.03);
+  }
+  return {
+    ensure, unlock(){ ensure(); res(); started=true; },
+    toggle(){ muted=!muted; if(master) master.gain.value = muted?0:0.46; return muted; },
+    shoot(p){ tone({type:'square',f0:820+p*120,f1:170,dur:0.075,vol:0.055,exp:true,filter:'lowpass',fc:2600}); },
+    thrust(){ noise({dur:0.06,fc:460,vol:0.028,decay:1}); },
+    pop(hue,c){ tone({type:'triangle',f0:280+hue*1.3+c*24,f1:88,dur:0.2,vol:0.11,exp:true});
+                noise({dur:0.2,fc:2600,fc1:220,vol:0.085}); },
+    boom(){ noise({dur:0.72,fc:1500,fc1:55,vol:0.3,decay:1.6}); tone({type:'sine',f0:130,f1:28,dur:0.62,vol:0.2,exp:true}); },
+    hurt(){ tone({type:'sawtooth',f0:210,f1:46,dur:0.5,vol:0.19,exp:true}); noise({dur:0.42,fc:820,fc1:70,vol:0.19}); },
+    blackhole(){ tone({type:'sine',f0:38,f1:230,dur:1.25,vol:0.16,exp:true});
+                 noise({dur:1.25,fc:110,fc1:1900,vol:0.09,decay:0.6}); },
+    swarm(){ tone({type:'sawtooth',f0:500,f1:1500,dur:0.3,vol:0.08,exp:true,filter:'bandpass',fc:1200,q:3}); },
+    phase(){ tone({type:'sine',f0:1500,f1:270,dur:0.26,vol:0.1,exp:true});
+             tone({type:'sine',f0:680,f1:2100,dur:0.26,vol:0.06,exp:true,delay:0.05}); },
+    reverse(){ tone({type:'square',f0:155,f1:540,dur:0.44,vol:0.1,exp:true});
+               tone({type:'square',f0:540,f1:155,dur:0.44,vol:0.08,exp:true,delay:0.12}); },
+    shard(){ for(let i=0;i<5;i++) tone({type:'square',f0:880+i*230,f1:390,dur:0.09,vol:0.04,exp:true,delay:i*0.028}); },
+    slow(){ tone({type:'sine',f0:640,f1:110,dur:0.95,vol:0.12,exp:true}); },
+    chain(i){ tone({type:'triangle',f0:380+i*95,f1:150,dur:0.15,vol:0.085,exp:true}); },
+    mirror(){ tone({type:'triangle',f0:780,f1:1620,dur:0.19,vol:0.085,exp:true});
+              tone({type:'triangle',f0:1620,f1:780,dur:0.19,vol:0.065,exp:true,delay:0.04}); },
+    rubber(){ tone({type:'sine',f0:210,f1:720,dur:0.2,vol:0.11,exp:true}); },
+    bounce(){ tone({type:'sine',f0:520,f1:260,dur:0.08,vol:0.05,exp:true}); },
+    gravity(){ tone({type:'sine',f0:940,f1:66,dur:0.8,vol:0.13,exp:true}); },
+    wave(){ [523,659,784,1046].forEach((f,i)=>tone({type:'triangle',f0:f,dur:0.3,vol:0.1,delay:i*0.085})); },
+    extra(){ [660,880,1320,1760].forEach((f,i)=>tone({type:'square',f0:f,dur:0.15,vol:0.09,delay:i*0.065})); },
+    over(){ [440,330,247,165].forEach((f,i)=>tone({type:'sawtooth',f0:f,f1:f*0.5,dur:0.5,vol:0.13,exp:true,delay:i*0.16})); },
+    blink(){ tone({type:'sine',f0:1900,f1:400,dur:0.17,vol:0.075,exp:true}); },
+    combo(n){ tone({type:'triangle',f0:520+Math.min(n,12)*70,dur:0.1,vol:0.06}); }
+  };
+})();
+
+/* ---------------- orb types ---------------- */
+const ORB_TYPES = [
+  {key:'swarm',   name:'Swarmling',  hue:135, c1:'#8dff9f', c2:'#0d4d28', desc:'Splits into a homing swarm'},
+  {key:'gravity', name:'Sinkhole',   hue:280, c1:'#c58bff', c2:'#320c60', desc:'Bursts into a gravity well'},
+  {key:'phase',   name:'Phaser',     hue:190, c1:'#7ff0ff', c2:'#083f52', desc:'Blinks out and teleports'},
+  {key:'void',    name:'Voidcore',   hue:352, c1:'#ff5b7f', c2:'#20030c', desc:'Spawns a hungry black hole'},
+  {key:'reverse', name:'Invertex',   hue:42,  c1:'#ffd166', c2:'#5c3800', desc:'Reverses your controls'},
+  {key:'shard',   name:'Glassbone',  hue:212, c1:'#bcd8ff', c2:'#1c2942', desc:'Shatters into ricochet shards'},
+  {key:'slow',    name:'Chronobub',  hue:168, c1:'#7ff0d0', c2:'#09453c', desc:'Drops a time-slow bubble'},
+  {key:'chain',   name:'Chromabomb', hue:322, c1:'#ff8bd8', c2:'#4f0937', desc:'Chain-reaction colour bomb'},
+  {key:'mirror',  name:'Twinshell',  hue:62,  c1:'#f6ff8b', c2:'#4c5000', desc:'Mirror-clones across the void'},
+  {key:'rubber',  name:'Bounceball', hue:22,  c1:'#ffa46b', c2:'#511c00', desc:'Rubber-bands off hard walls'}
+];
+const TYPE_BY_KEY = {};
+ORB_TYPES.forEach(t=> TYPE_BY_KEY[t.key]=t);
+
+(function buildLegend(){
+  const el=document.getElementById('orblist');
+  ORB_TYPES.forEach(t=>{
+    const d=document.createElement('div');
+    d.className='orbcard';
+    d.innerHTML='<span class="dot" style="color:'+t.c1+'"></span>'+
+      '<span><b style="color:'+t.c1+'">'+t.name+'</b><br>'+t.desc+'</span>';
+    el.appendChild(d);
+  });
+})();
+
+/* ---------------- input ---------------- */
+const keys = Object.create(null);
+window.addEventListener('keydown', e=>{
+  const k=e.key.toLowerCase();
+  if([' ','arrowup','arrowdown','arrowleft','arrowright'].indexOf(k)>=0) e.preventDefault();
+  if(keys[k]) return;
+  keys[k]=true;
+  Sound.unlock();
+  if(k==='p' && G.state==='play'){ G.paused=!G.paused; document.getElementById('pausetag').style.display=G.paused?'block':'none'; }
+  if(k==='m'){ toast(Sound.toggle()?'MUTED':'SOUND ON','#9fb4d8'); }
+  if(k==='enter'){ if(G.state==='start'||G.state==='over') startGame(); }
+  if(k==='shift' && G.state==='play' && !G.paused) blink();
+}, {passive:false});
+window.addEventListener('keyup', e=>{ keys[e.key.toLowerCase()]=false; });
+window.addEventListener('blur', ()=>{ for(const k in keys) keys[k]=false; });
+
+/* ---------------- game state ---------------- */
+const G = {
+  state:'start', paused:false,
+  score:0, disp:0, best:+(localStorage.getItem('orbfall_best')||0),
+  lives:3, wave:0, popped:0, nextExtra:5000,
+  combo:0, comboT:0, bestCombo:0,
+  shake:0, shakeX:0, shakeY:0, rot:0,
+  slowT:0, reverseT:0,
+  flash:0, flashCol:'#fff',
+  chroma:0,
+  t:0, waveClearT:0, respawnT:0
+};
+
+let ship=null;
+let bullets=[], orbs=[], parts=[], shards=[], swarm=[], wells=[], holes=[],
+    bubbles=[], ripples=[], floats=[], sparks=[], debris=[];
+
+let stars=[], neb=[];
+function buildStars(){
+  stars=[];
+  const n=Math.floor((W*H)/5200);
+  for(let i=0;i<n;i++){
+    const layer=randi(0,3);
+    stars.push({x:rand(W),y:rand(H),r:rand(0.4+layer*0.55,0.25),
+      a:rand(0.95,0.18),tw:rand(TAU),ts:rand(2.4,0.5),l:layer,
+      h:pick([200,205,215,45,280])});
+  }
+}
+function buildNebula(){
+  neb=[];
+  for(let i=0;i<5;i++){
+    neb.push({x:rand(W),y:rand(H),r:rand(Math.max(W,H)*0.55,Math.max(W,H)*0.22),
+      h:pick([258,196,318,168]),a:rand(0.09,0.035),vx:rand(0.06,-0.06),vy:rand(0.05,-0.05)});
+  }
+}
+resize();
+
+/* ---------------- HUD helpers ---------------- */
+const toastEl=document.getElementById('toast');
+let toastT=0;
+function toast(txt,col){
+  toastEl.textContent=txt;
+  toastEl.style.color=col||'#fff';
+  toastT=1.6;
+}
+const bannerEl=document.getElementById('wavebanner');
+let bannerT=0;
+function banner(txt){ bannerEl.textContent=txt; bannerT=1.9; }
+
+function addFloat(x,y,txt,col,size){
+  floats.push({x,y,txt,col:col||'#fff',size:size||16,life:1,vy:-38,vx:rand(16,-16)});
+}
+
+/* ---------------- particles ---------------- */
+function P(x,y,vx,vy,life,size,col,glow,drag,kind){
+  parts.push({x,y,vx,vy,life,max:life,size,col,glow:glow!==false,drag:drag||0.985,kind:kind||0,rot:rand(TAU),vr:rand(6,-6)});
+}
+function burst(x,y,n,col,spd,size,life){
+  for(let i=0;i<n;i++){
+    const a=rand(TAU), s=rand(spd,spd*0.15);
+    P(x,y,Math.cos(a)*s,Math.sin(a)*s,rand(life||0.8,(life||0.8)*0.4),rand(size||3,1),col);
+  }
+}
+function ring(x,y,col,r0,r1,life,w){
+  ripples.push({x,y,col,r:r0,r1:r1,life:life||0.6,max:life||0.6,w:w||3});
+}
+function sparkLine(x,y,ang,len,col,life){
+  sparks.push({x,y,ang,len,col,life:life||0.25,max:life||0.25});
+}
+function shakeIt(v,rotAmt){ G.shake=Math.min(38,G.shake+v); if(rotAmt) G.rot+=rotAmt; }
+function flash(col,amt){ G.flash=Math.max(G.flash,amt||0.5); G.flashCol=col||'#fff'; }
+
+/* ---------------- ship ---------------- */
+function makeShip(){
+  return {x:W/2,y:H/2,vx:0,vy:0,a:-Math.PI/2,r:13,
+    cd:0, inv:2.6, alive:true, thrust:0, blinkCd:0, trail:[], eng:0, recoil:0};
+}
+function blink(){
+  if(!ship||!ship.alive||ship.blinkCd>0) return;
+  const d=132;
+  const ox=ship.x, oy=ship.y;
+  ship.x+=Math.cos(ship.a)*d; ship.y+=Math.sin(ship.a)*d;
+  wrapPos(ship,20);
+  ship.blinkCd=3.4; ship.inv=Math.max(ship.inv,0.65);
+  Sound.blink();
+  ring(ox,oy,'#8be9ff',6,44,0.5,3);
+  ring(ship.x,ship.y,'#b06cff',40,6,0.45,3);
+  for(let i=0;i<16;i++){
+    const a=rand(TAU);
+    P(ox,oy,Math.cos(a)*rand(120,20),Math.sin(a)*rand(120,20),0.5,rand(3,1),'#8be9ff');
+    P(ship.x,ship.y,Math.cos(a)*rand(60,10),Math.sin(a)*rand(60,10),0.4,rand(2.5,1),'#c9a4ff');
+  }
+  addFloat(ship.x,ship.y-24,'BLINK','#8be9ff',13);
+}
+
+/* ---------------- orbs ---------------- */
+function spawnOrb(type,x,y,size,vx,vy,gen){
+  const t = type || pick(ORB_TYPES).key;
+  const sz = size || rand(46,34);
+  const spd = rand(52,16)*(1+G.wave*0.045);
+  const ang = rand(TAU);
+  const o = {
+    type:t, x:(x==null?0:x), y:(y==null?0:y),
+    vx:(vx==null? Math.cos(ang)*spd : vx),
+    vy:(vy==null? Math.sin(ang)*spd : vy),
+    r:sz, gen:gen||0, hp:sz>34?2:1,
+    a:rand(TAU), spin:rand(1.4,-1.4),
+    pulse:rand(TAU), wob:rand(TAU),
+    hit:0, born:0, ghost:0, seed:rand(1000),
+    verts:[]
+  };
+  const n = 11 + ((sz/9)|0);
+  for(let i=0;i<n;i++) o.verts.push(rand(1.12,0.86));
+  if(x==null){
+    // spawn away from ship
+    let tries=0;
+    do{
+      o.x=rand(W); o.y=rand(H); tries++;
+    } while(ship && torDist(o.x,o.y,ship.x,ship.y) < 210 && tries<60);
+  }
+  orbs.push(o);
+  return o;
+}
+
+function startWave(){
+  G.wave++;
+  const n = Math.min(4+Math.floor(G.wave*1.35), 17);
+  for(let i=0;i<n;i++) spawnOrb();
+  banner('WAVE '+G.wave);
+  Sound.wave();
+  ring(W/2,H/2,'#63c8ff',20,Math.max(W,H)*0.6,1.1,4);
+}
+
+/* ---------------- scoring ---------------- */
+function addScore(v,x,y,label){
+  const mult = 1 + Math.min(G.combo,15)*0.12;
+  const gain = Math.round(v*mult);
+  G.score += gain;
+  if(x!=null) addFloat(x,y,'+'+gain,'#ffe9a8',14+Math.min(8,G.combo));
+  if(label) addFloat(x,y-20,label,'#ffffff',12);
+  while(G.score >= G.nextExtra){
+    G.nextExtra += 5000 + G.lives*1500;
+    G.lives++;
+    Sound.extra();
+    toast('EXTRA LIFE','#7ff0d0');
+    flash('#7ff0d0',0.35);
+  }
+}
+function bumpCombo(){
+  G.combo++; G.comboT=1.8;
+  if(G.combo>G.bestCombo) G.bestCombo=G.combo;
+  if(G.combo>2){ Sound.combo(G.combo); }
+  if(G.combo===5) toast('COMBO x5','#ffd166');
+  if(G.combo===10) toast('COMBO x10 !','#ff8bd8');
+  if(G.combo===18) toast('UNREAL x18','#7ff0ff');
+}
+
+/* ---------------- WILD EFFECTS ---------------- */
+function popOrb(o, idx, srcX, srcY, chainDepth){
+  const T = TYPE_BY_KEY[o.type];
+  orbs.splice(idx,1);
+  G.popped++;
+  bumpCombo();
+  Sound.pop(T.hue, Math.min(G.combo,10));
+  burst(o.x,o.y,Math.floor(o.r*0.85),T.c1,rand(230,110),3.4,0.85);
+  burst(o.x,o.y,Math.floor(o.r*0.45),'#ffffff',rand(150,60),2.1,0.5);
+  ring(o.x,o.y,T.c1,o.r*0.5,o.r*2.5,0.5,3);
+  for(let i=0;i<5;i++){
+    debris.push({x:o.x,y:o.y,vx:rand(120,-120),vy:rand(120,-120),
+      r:rand(o.r*0.26,o.r*0.10),a:rand(TAU),vr:rand(5,-5),life:1.1,max:1.1,col:T.c2,edge:T.c1});
+  }
+  shakeIt(3.5+o.r*0.06);
+  addScore(Math.round(80 + (60-o.r)*3), o.x, o.y-o.r*0.6);
+
+  const gen=o.gen;
+  switch(o.type){
+
+    case 'swarm': {
+      Sound.swarm();
+      const n = o.r>36?6:4;
+      for(let i=0;i<n;i++){
+        const a=(i/n)*TAU+rand(0.5);
+        swarm.push({x:o.x+Math.cos(a)*o.r*0.5, y:o.y+Math.sin(a)*o.r*0.5,
+          vx:Math.cos(a)*160, vy:Math.sin(a)*160, r:6.5, life:9.5, max:9.5,
+          hue:T.hue, wig:rand(TAU), trail:[]});
+      }
+      ring(o.x,o.y,'#8dff9f',10,o.r*3,0.6,2);
+      addFloat(o.x,o.y-o.r,'SWARM!','#8dff9f',15);
+      break;
+    }
+
+    case 'gravity': {
+      Sound.gravity();
+      wells.push({x:o.x,y:o.y,r:150+o.r*2.2,str:1500,life:5.2,max:5.2,a:0});
+      ring(o.x,o.y,'#c58bff',o.r,200,0.9,4);
+      ring(o.x,o.y,'#8be9ff',o.r*0.4,150,1.2,2);
+      addFloat(o.x,o.y-o.r,'GRAVITY WELL','#c58bff',15);
+      flash('#7a3ccf',0.18);
+      break;
+    }
+
+    case 'phase': {
+      Sound.phase();
+      if(gen<2){
+        const nx=rand(W), ny=rand(H);
+        const c = spawnOrb('phase', nx, ny, Math.max(20,o.r*0.86), rand(90,-90), rand(90,-90), gen+1);
+        c.ghost=0.6; c.born=0.35;
+        ring(o.x,o.y,'#7ff0ff',o.r,4,0.5,3);
+        ring(nx,ny,'#7ff0ff',4,o.r*2.2,0.6,3);
+        for(let i=0;i<20;i++){
+          const a=rand(TAU);
+          P(nx,ny,Math.cos(a)*rand(180,40),Math.sin(a)*rand(180,40),0.55,rand(3,1),'#7ff0ff');
+        }
+        // ghost trail between the two
+        for(let i=0;i<10;i++){
+          const t=i/10;
+          P(lerp(o.x,nx,t),lerp(o.y,ny,t),0,0,0.4,rand(4,2),'#7ff0ff',true,0.9);
+        }
+        addFloat(o.x,o.y-o.r,'PHASED','#7ff0ff',15);
+      }
+      break;
+    }
+
+    case 'void': {
+      Sound.blackhole();
+      holes.push({x:o.x,y:o.y,r:22,rTarget:44,life:7.5,max:7.5,a:0,pull:2600,eaten:0});
+      flash('#ff5b7f',0.32);
+      shakeIt(16,0.02);
+      ring(o.x,o.y,'#ff5b7f',6,260,1.0,5);
+      ring(o.x,o.y,'#ffffff',4,120,0.6,2);
+      addFloat(o.x,o.y-o.r,'BLACK HOLE','#ff5b7f',17);
+      break;
+    }
+
+    case 'reverse': {
+      Sound.reverse();
+      G.reverseT = Math.max(G.reverseT, 6.5);
+      toast('CONTROLS REVERSED','#ffd166');
+      flash('#ffd166',0.25);
+      ring(o.x,o.y,'#ffd166',o.r,240,0.8,4);
+      for(let i=0;i<26;i++){
+        const a=rand(TAU);
+        P(o.x,o.y,Math.cos(a)*rand(200,50),Math.sin(a)*rand(200,50),0.9,rand(4,1.5),'#ffd166');
+      }
+      break;
+    }
+
+    case 'shard': {
+      Sound.shard();
+      const n=9;
+      for(let i=0;i<n;i++){
+        const a=(i/n)*TAU+rand(0.4);
+        const sp=rand(430,270);
+        shards.push({x:o.x,y:o.y,vx:Math.cos(a)*sp,vy:Math.sin(a)*sp,
+          life:4.2,max:4.2,bounces:0,a:rand(TAU),vr:rand(14,-14),len:rand(17,10),trail:[]});
+      }
+      ring(o.x,o.y,'#bcd8ff',o.r*0.4,o.r*3,0.45,2);
+      addFloat(o.x,o.y-o.r,'SHATTER','#bcd8ff',15);
+      break;
+    }
+
+    case 'slow': {
+      Sound.slow();
+      bubbles.push({x:o.x,y:o.y,r:16,rTarget:210+o.r*1.6,life:6.5,max:6.5,a:0});
+      ring(o.x,o.y,'#7ff0d0',10,220,1.0,3);
+      addFloat(o.x,o.y-o.r,'TIME BUBBLE','#7ff0d0',15);
+      break;
+    }
+
+    case 'chain': {
+      const depth=chainDepth||0;
+      Sound.chain(depth);
+      const R = 230;
+      ring(o.x,o.y,'#ff8bd8',10,R,0.55,4);
+      flash('#ff8bd8',0.14);
+      shakeIt(5);
+      let hitAny=0;
+      for(let j=orbs.length-1;j>=0;j--){
+        const q=orbs[j];
+        if(torDist(o.x,o.y,q.x,q.y) < R + q.r){
+          hitAny++;
+          const qq=q, jj=j;
+          // slight cascade delay via scheduled pop
+          scheduled.push({t:0.09*(hitAny), fn:()=>{
+            const k=orbs.indexOf(qq);
+            if(k>=0) popOrb(qq,k,o.x,o.y,depth+1);
+          }});
+        }
+      }
+      if(hitAny) addFloat(o.x,o.y-o.r,'CHAIN x'+hitAny,'#ff8bd8',16);
+      else addFloat(o.x,o.y-o.r,'BOOM','#ff8bd8',15);
+      // colour shrapnel
+      for(let i=0;i<34;i++){
+        const a=rand(TAU);
+        P(o.x,o.y,Math.cos(a)*rand(340,90),Math.sin(a)*rand(340,90),rand(0.9,0.4),rand(4,1.5),
+          'hsl('+randi(280,360)+',100%,72%)');
+      }
+      break;
+    }
+
+    case 'mirror': {
+      Sound.mirror();
+      if(gen<2 && o.r>19){
+        const nr=o.r*0.72;
+        const mk=(mx,my)=>{
+          const c=spawnOrb('mirror',mx,my,nr,rand(140,-140),rand(140,-140),gen+1);
+          c.born=0.4;
+          ring(mx,my,'#f6ff8b',4,nr*3,0.55,3);
+        };
+        mk((o.x+W*0.5)%W, o.y);
+        mk(o.x, (o.y+H*0.5)%H);
+        mk((o.x+W*0.5)%W, (o.y+H*0.5)%H);
+        addFloat(o.x,o.y-o.r,'MIRROR x3','#f6ff8b',15);
+      }
+      break;
+    }
+
+    case 'rubber': {
+      Sound.rubber();
+      const n=3;
+      for(let i=0;i<n;i++){
+        const a=rand(TAU), sp=rand(300,190);
+        const c=spawnOrb('rubber',o.x,o.y,Math.max(15,o.r*0.5),Math.cos(a)*sp,Math.sin(a)*sp,gen+1);
+        c.bouncy=true; c.hp=1; c.born=0.25;
+      }
+      ring(o.x,o.y,'#ffa46b',o.r*0.5,o.r*3.2,0.5,3);
+      addFloat(o.x,o.y-o.r,'RUBBER!','#ffa46b',15);
+      break;
+    }
+  }
+}
+
+/* scheduled callbacks (chain cascade) */
+let scheduled=[];
+
+/* ---------------- damage ---------------- */
+function killShip(){
+  if(!ship.alive || ship.inv>0) return;
+  ship.alive=false;
+  G.lives--;
+  Sound.boom(); Sound.hurt();
+  flash('#ff6f8f',0.6);
+  shakeIt(34,0.045);
+  burst(ship.x,ship.y,74,'#ffd9e2',rand(380,120),4,1.3);
+  burst(ship.x,ship.y,40,'#8be9ff',rand(280,80),3,1.0);
+  ring(ship.x,ship.y,'#ff6f8f',8,240,0.9,5);
+  for(let i=0;i<8;i++){
+    debris.push({x:ship.x,y:ship.y,vx:rand(200,-200),vy:rand(200,-200),
+      r:rand(9,4),a:rand(TAU),vr:rand(8,-8),life:1.6,max:1.6,col:'#233',edge:'#9fe9ff'});
+  }
+  G.combo=0;
+  if(G.lives<=0){ gameOver(); }
+  else { G.respawnT=1.5; toast(G.lives+' SHIP'+(G.lives>1?'S':'')+' LEFT','#ff9fb4'); }
+}
+function respawn(){
+  ship=makeShip();
+  ship.inv=2.8;
+  ring(ship.x,ship.y,'#7ff0d0',80,10,0.7,3);
+}
+
+/* ---------------- game flow ---------------- */
+function startGame(){
+  G.state='play'; G.paused=false;
+  G.score=0; G.disp=0; G.lives=3; G.wave=0; G.popped=0; G.nextExtra=5000;
+  G.combo=0; G.comboT=0; G.bestCombo=0;
+  G.slowT=0; G.reverseT=0; G.shake=0; G.rot=0; G.flash=0; G.chroma=0;
+  G.waveClearT=0; G.respawnT=0;
+  bullets=[];orbs=[];parts=[];shards=[];swarm=[];wells=[];holes=[];
+  bubbles=[];ripples=[];floats=[];sparks=[];debris=[];scheduled=[];
+  ship=makeShip();
+  document.getElementById('start').classList.add('hidden');
+  document.getElementById('gameover').classList.add('hidden');
+  document.getElementById('pausetag').style.display='none';
+  Sound.unlock();
+  startWave();
+}
+function gameOver(){
+  G.state='over';
+  Sound.over();
+  const isBest = G.score>G.best;
+  if(isBest){ G.best=G.score; localStorage.setItem('orbfall_best',String(G.best)); }
+  document.getElementById('goScore').textContent=G.score;
+  document.getElementById('goWave').textContent=G.wave;
+  document.getElementById('goPopped').textContent=G.popped;
+  document.getElementById('goCombo').textContent=G.bestCombo;
+  document.getElementById('goBest').textContent=G.best;
+  document.getElementById('newbest').style.display=isBest?'block':'none';
+  document.getElementById('goSub').textContent = isBest? 'a new personal record' : pick([
+    'the orbs got you','chaos wins this round','signal terminated','try not to shoot the voidcores']);
+  setTimeout(()=>document.getElementById('gameover').classList.remove('hidden'),620);
+}
+document.getElementById('startBtn').addEventListener('click',startGame);
+document.getElementById('againBtn').addEventListener('click',startGame);
+
+/* ---------------- update ---------------- */
+function update(dt){
+  G.t+=dt;
+
+  // scheduled cascades
+  for(let i=scheduled.length-1;i>=0;i--){
+    scheduled[i].t-=dt;
+    if(scheduled[i].t<=0){ const f=scheduled[i].fn; scheduled.splice(i,1); f(); }
+  }
+
+  // time scale from bubbles
+  let ts=1;
+  if(ship){
+    for(let i=0;i<bubbles.length;i++){
+      const b=bubbles[i];
+      if(torDist(ship.x,ship.y,b.x,b.y) < b.r) { ts=0.42; break; }
+    }
+  }
+  G.slowT = lerp(G.slowT, ts<1?1:0, 1-Math.pow(0.001,dt));
+  const wdt = dt*ts;   // world dt (affected by slow bubbles)
+
+  if(G.comboT>0){ G.comboT-=dt; if(G.comboT<=0) G.combo=0; }
+  if(G.reverseT>0){ G.reverseT-=dt; if(G.reverseT<=0) toast('CONTROLS RESTORED','#7ff0d0'); }
+  G.chroma = lerp(G.chroma, (G.reverseT>0?1:0)*0.6 + G.flash*0.8, 0.12);
+
+  /* ---- ship ---- */
+  if(ship && ship.alive){
+    const rev = G.reverseT>0 ? -1 : 1;
+    const left  = keys['arrowleft']||keys['a'];
+    const right = keys['arrowright']||keys['d'];
+    const up    = keys['arrowup']||keys['w'];
+    let turn=0;
+    if(left)  turn-=1;
+    if(right) turn+=1;
+    ship.a += turn*rev*3.5*dt;
+
+    ship.thrust = lerp(ship.thrust, up?1:0, 1-Math.pow(0.0008,dt));
+    if(up){
+      const acc = 335*(G.reverseT>0?1:1);
+      ship.vx += Math.cos(ship.a)*acc*dt;
+      ship.vy += Math.sin(ship.a)*acc*dt;
+      ship.eng+=dt;
+      if(ship.eng>0.045){
+        ship.eng=0;
+        Sound.thrust();
+        const bx=ship.x-Math.cos(ship.a)*15, by=ship.y-Math.sin(ship.a)*15;
+        for(let i=0;i<2;i++){
+          const sp=rand(190,90), a=ship.a+Math.PI+rand(0.45,-0.45);
+          P(bx,by,Math.cos(a)*sp+ship.vx*0.35,Math.sin(a)*sp+ship.vy*0.35,
+            rand(0.45,0.2),rand(3.6,1.6),
+            pick(['#8be9ff','#7ff0d0','#ffffff','#b06cff']),true,0.93);
+        }
+      }
+    }
+    const drag=Math.pow(0.994,dt*60);
+    ship.vx*=drag; ship.vy*=drag;
+    const sp=Math.hypot(ship.vx,ship.vy), MAX=560;
+    if(sp>MAX){ ship.vx=ship.vx/sp*MAX; ship.vy=ship.vy/sp*MAX; }
+    ship.x+=ship.vx*dt; ship.y+=ship.vy*dt;
+    wrapPos(ship,20);
+
+    ship.trail.push({x:ship.x,y:ship.y,a:ship.a});
+    if(ship.trail.length>14) ship.trail.shift();
+
+    if(ship.cd>0) ship.cd-=dt;
+    if(ship.inv>0) ship.inv-=dt;
+    if(ship.blinkCd>0) ship.blinkCd-=dt;
+    if(ship.recoil>0) ship.recoil-=dt*6;
+
+    if(keys[' '] && ship.cd<=0){
+      ship.cd=0.135;
+      ship.recoil=1;
+      const mx=ship.x+Math.cos(ship.a)*17, my=ship.y+Math.sin(ship.a)*17;
+      bullets.push({x:mx,y:my,
+        vx:Math.cos(ship.a)*620+ship.vx*0.45,
+        vy:Math.sin(ship.a)*620+ship.vy*0.45,
+        life:1.25,r:3.1,trail:[],hue:190+Math.min(G.combo,10)*8});
+      ship.vx-=Math.cos(ship.a)*22; ship.vy-=Math.sin(ship.a)*22;
+      Sound.shoot(Math.min(G.combo,8)/8);
+      sparkLine(mx,my,ship.a,18,'#cffcff',0.12);
+      for(let i=0;i<3;i++){
+        const a=ship.a+rand(0.5,-0.5);
+        P(mx,my,Math.cos(a)*rand(200,60),Math.sin(a)*rand(200,60),0.2,rand(2.4,1),'#cffcff');
+      }
+    }
+  } else if(G.respawnT>0){
+    G.respawnT-=dt;
+    if(G.respawnT<=0 && G.lives>0) respawn();
+  }
+
+  /* ---- gravity sources act on ship + bullets + orbs ---- */
+  function applyPull(o,src,strength,radius,dtx){
+    const dx=wrapDelta(o.x,src.x,W), dy=wrapDelta(o.y,src.y,H);
+    const d=Math.hypot(dx,dy);
+    if(d<1 || d>radius) return d;
+    const f=strength/(d*d+800);
+    o.vx+=dx*f*dtx; o.vy+=dy*f*dtx;
+    return d;
+  }
+
+  for(let i=wells.length-1;i>=0;i--){
+    const w=wells[i];
+    w.life-=dt; w.a+=dt*2.2;
+    if(w.life<=0){ wells.splice(i,1); ring(w.x,w.y,'#c58bff',w.r,4,0.4,2); continue; }
+    const k=Math.min(1,w.life/0.7);
+    if(ship&&ship.alive) applyPull(ship,w,w.str*k,w.r,dt);
+    for(const b of bullets) applyPull(b,w,w.str*1.7*k,w.r,dt);
+    for(const o of orbs) applyPull(o,w,w.str*0.55*k,w.r,wdt);
+    for(const s of shards) applyPull(s,w,w.str*0.9*k,w.r,wdt);
+    if(Math.random()<0.6){
+      const a=rand(TAU), rr=w.r*rand(1,0.55);
+      P(w.x+Math.cos(a)*rr,w.y+Math.sin(a)*rr,-Math.cos(a)*90,-Math.sin(a)*90,0.6,rand(2.6,1),'#c58bff');
+    }
+  }
+
+  for(let i=holes.length-1;i>=0;i--){
+    const h=holes[i];
+    h.life-=dt; h.a+=dt*3.2;
+    h.r=lerp(h.r,h.rTarget*(h.life<1?h.life:1),0.08);
+    if(h.life<=0){
+      holes.splice(i,1);
+      ring(h.x,h.y,'#ff5b7f',h.r*3,4,0.6,4);
+      flash('#ff5b7f',0.2); shakeIt(9);
+      burst(h.x,h.y,40,'#ff9fb4',rand(320,120),3,0.9);
+      continue;
+    }
+    const R=430;
+    if(ship&&ship.alive){
+      const d=applyPull(ship,h,h.pull,R,dt);
+      if(d<h.r*0.65){ killShip(); }
+    }
+    for(let j=bullets.length-1;j>=0;j--){
+      const b=bullets[j];
+      const d=applyPull(b,h,h.pull*1.6,R,dt);
+      if(d<h.r*0.6){ bullets.splice(j,1); h.eaten++; burst(h.x,h.y,4,'#ff9fb4',60,2,0.35); }
+    }
+    for(let j=orbs.length-1;j>=0;j--){
+      const o=orbs[j];
+      const d=applyPull(o,h,h.pull*0.8,R,wdt);
+      if(d<h.r*0.55){
+        orbs.splice(j,1); G.popped++;
+        addScore(50,o.x,o.y,'CONSUMED');
+        burst(o.x,o.y,16,TYPE_BY_KEY[o.type].c1,120,2.4,0.5);
+        h.rTarget=Math.min(80,h.rTarget+2.5);
+      }
+    }
+    for(let j=swarm.length-1;j>=0;j--){
+      const s=swarm[j];
+      const d=applyPull(s,h,h.pull,R,wdt);
+      if(d<h.r*0.6){ swarm.splice(j,1); burst(h.x,h.y,6,'#8dff9f',80,2,0.4); }
+    }
+    // accretion glitter
+    for(let k=0;k<2;k++){
+      const a=rand(TAU), rr=h.r*rand(3.4,1.5);
+      P(h.x+Math.cos(a)*rr,h.y+Math.sin(a)*rr,
+        Math.cos(a+1.5)*90-Math.cos(a)*140, Math.sin(a+1.5)*90-Math.sin(a)*140,
+        0.55,rand(2.4,1),pick(['#ff5b7f','#ffd0dd','#b06cff']));
+    }
+  }
+
+  /* ---- bubbles ---- */
+  for(let i=bubbles.length-1;i>=0;i--){
+    const b=bubbles[i];
+    b.life-=dt; b.a+=dt;
+    b.r=lerp(b.r,b.rTarget*(b.life<1?b.life:1),0.07);
+    if(b.life<=0){ bubbles.splice(i,1); ring(b.x,b.y,'#7ff0d0',b.r,6,0.5,3); }
+  }
+
+  /* ---- bullets ---- */
+  for(let i=bullets.length-1;i>=0;i--){
+    const b=bullets[i];
+    b.life-=dt;
+    b.x+=b.vx*dt; b.y+=b.vy*dt;
+    wrapPos(b,10);
+    b.trail.push({x:b.x,y:b.y});
+    if(b.trail.length>9) b.trail.shift();
+    if(b.life<=0){ bullets.splice(i,1); continue; }
+
+    let hit=false;
+    for(let j=orbs.length-1;j>=0;j--){
+      const o=orbs[j];
+      if(o.born>0) continue;
+      if(torDist(b.x,b.y,o.x,o.y) < o.r*0.92){
+        bullets.splice(i,1); hit=true;
+        o.hp--; o.hit=1;
+        burst(b.x,b.y,7,'#ffffff',rand(160,60),2,0.34);
+        if(o.hp<=0) popOrb(o,j,b.x,b.y,0);
+        else {
+          Sound.bounce();
+          shakeIt(2);
+          const T=TYPE_BY_KEY[o.type];
+          burst(b.x,b.y,9,T.c1,rand(190,70),2.4,0.45);
+          o.vx+=b.vx*0.055; o.vy+=b.vy*0.055;
+        }
+        break;
+      }
+    }
+    if(hit) continue;
+
+    // bullets kill swarmlings
+    for(let j=swarm.length-1;j>=0;j--){
+      const s=swarm[j];
+      if(torDist(b.x,b.y,s.x,s.y) < s.r+3){
+        swarm.splice(j,1); bullets.splice(i,1);
+        addScore(30,s.x,s.y);
+        burst(s.x,s.y,14,'#8dff9f',rand(200,70),2.4,0.5);
+        bumpCombo();
+        Sound.pop(135,3);
+        break;
+      }
+    }
+  }
+
+  /* ---- orbs ---- */
+  for(let i=orbs.length-1;i>=0;i--){
+    const o=orbs[i];
+    if(o.born>0) o.born-=dt;
+    o.a+=o.spin*wdt;
+    o.pulse+=wdt*2.4;
+    o.wob+=wdt*1.6;
+    if(o.hit>0) o.hit-=dt*3.2;
+
+    if(o.bouncy){
+      o.x+=o.vx*wdt; o.y+=o.vy*wdt;
+      let bounced=false;
+      if(o.x<o.r){ o.x=o.r; o.vx=Math.abs(o.vx)*1.02; bounced=true; }
+      if(o.x>W-o.r){ o.x=W-o.r; o.vx=-Math.abs(o.vx)*1.02; bounced=true; }
+      if(o.y<o.r){ o.y=o.r; o.vy=Math.abs(o.vy)*1.02; bounced=true; }
+      if(o.y>H-o.r){ o.y=H-o.r; o.vy=-Math.abs(o.vy)*1.02; bounced=true; }
+      if(bounced){
+        Sound.rubber(); shakeIt(2.5);
+        ring(o.x,o.y,'#ffa46b',o.r*0.5,o.r*2.4,0.35,3);
+        burst(o.x,o.y,10,'#ffa46b',rand(220,80),2.4,0.4);
+      }
+      const s=Math.hypot(o.vx,o.vy);
+      if(s>420){ o.vx=o.vx/s*420; o.vy=o.vy/s*420; }
+    } else {
+      o.x+=o.vx*wdt; o.y+=o.vy*wdt;
+      wrapPos(o,o.r+8);
+    }
+
+    if(o.type==='phase'){
+      o.ghost = 0.5+0.5*Math.sin(G.t*2.2+o.seed);
+      if(Math.random()<0.004 && o.gen<2){
+        // spontaneous micro-blink
+        const ox=o.x, oy=o.y;
+        o.x=rand(W); o.y=rand(H);
+        ring(ox,oy,'#7ff0ff',o.r,4,0.35,2);
+        ring(o.x,o.y,'#7ff0ff',4,o.r*2,0.4,2);
+      }
+    }
+
+    // ship collision
+    if(ship && ship.alive && ship.inv<=0 && o.born<=0){
+      if(torDist(ship.x,ship.y,o.x,o.y) < o.r*0.82+ship.r*0.7) killShip();
+    }
+  }
+
+  /* ---- swarmlings ---- */
+  for(let i=swarm.length-1;i>=0;i--){
+    const s=swarm[i];
+    s.life-=wdt; s.wig+=wdt*9;
+    if(s.life<=0){ swarm.splice(i,1); burst(s.x,s.y,8,'#8dff9f',80,2,0.4); continue; }
+    if(ship&&ship.alive){
+      const dx=wrapDelta(s.x,ship.x,W), dy=wrapDelta(s.y,ship.y,H);
+      const d=Math.hypot(dx,dy)||1;
+      const acc=340;
+      s.vx+=(dx/d)*acc*wdt; s.vy+=(dy/d)*acc*wdt;
+      s.vx+=Math.cos(s.wig)*36*wdt; s.vy+=Math.sin(s.wig*1.3)*36*wdt;
+    }
+    const sp=Math.hypot(s.vx,s.vy), M=250;
+    if(sp>M){ s.vx=s.vx/sp*M; s.vy=s.vy/sp*M; }
+    s.vx*=Math.pow(0.99,wdt*60); s.vy*=Math.pow(0.99,wdt*60);
+    s.x+=s.vx*wdt; s.y+=s.vy*wdt;
+    wrapPos(s,12);
+    s.trail.push({x:s.x,y:s.y});
+    if(s.trail.length>7) s.trail.shift();
+    if(Math.random()<0.25) P(s.x,s.y,rand(24,-24),rand(24,-24),0.35,rand(2.2,0.8),'#8dff9f');
+    if(ship&&ship.alive&&ship.inv<=0 && torDist(s.x,s.y,ship.x,ship.y)<s.r+ship.r*0.7) killShip();
+  }
+
+  /* ---- shards ---- */
+  for(let i=shards.length-1;i>=0;i--){
+    const s=shards[i];
+    s.life-=wdt; s.a+=s.vr*wdt;
+    if(s.life<=0){ shards.splice(i,1); continue; }
+    s.x+=s.vx*wdt; s.y+=s.vy*wdt;
+    let b=false;
+    if(s.x<0){ s.x=0; s.vx=Math.abs(s.vx); b=true; }
+    if(s.x>W){ s.x=W; s.vx=-Math.abs(s.vx); b=true; }
+    if(s.y<0){ s.y=0; s.vy=Math.abs(s.vy); b=true; }
+    if(s.y>H){ s.y=H; s.vy=-Math.abs(s.vy); b=true; }
+    if(b){
+      s.bounces++;
+      Sound.bounce();
+      burst(s.x,s.y,7,'#bcd8ff',rand(190,70),2.2,0.35);
+      ring(s.x,s.y,'#bcd8ff',2,26,0.3,2);
+      if(s.bounces>7){ shards.splice(i,1); continue; }
+    }
+    s.trail.push({x:s.x,y:s.y});
+    if(s.trail.length>8) s.trail.shift();
+    // shards damage orbs
+    for(let j=orbs.length-1;j>=0;j--){
+      const o=orbs[j];
+      if(o.born>0) continue;
+      if(torDist(s.x,s.y,o.x,o.y)<o.r*0.85){
+        shards.splice(i,1);
+        o.hp--; o.hit=1;
+        if(o.hp<=0) popOrb(o,j,s.x,s.y,0);
+        else burst(s.x,s.y,8,'#bcd8ff',150,2,0.4);
+        break;
+      }
+    }
+    if(ship&&ship.alive&&ship.inv<=0 && torDist(s.x,s.y,ship.x,ship.y)<ship.r*0.8) killShip();
+  }
+
+  /* ---- particles / fx ---- */
+  for(let i=parts.length-1;i>=0;i--){
+    const p=parts[i];
+    p.life-=dt;
+    if(p.life<=0){ parts.splice(i,1); continue; }
+    p.x+=p.vx*dt; p.y+=p.vy*dt;
+    p.vx*=Math.pow(p.drag,dt*60); p.vy*=Math.pow(p.drag,dt*60);
+    p.rot+=p.vr*dt;
+  }
+  for(let i=debris.length-1;i>=0;i--){
+    const d=debris[i];
+    d.life-=dt;
+    if(d.life<=0){ debris.splice(i,1); continue; }
+    d.x+=d.vx*dt; d.y+=d.vy*dt; d.a+=d.vr*dt;
+    d.vx*=Math.pow(0.975,dt*60); d.vy*=Math.pow(0.975,dt*60);
+  }
+  for(let i=ripples.length-1;i>=0;i--){
+    const r=ripples[i]; r.life-=dt;
+    if(r.life<=0){ ripples.splice(i,1); continue; }
+    const t=1-r.life/r.max;
+    r.cur = lerp(r.r, r.r1, easeOut(t));
+  }
+  for(let i=sparks.length-1;i>=0;i--){ sparks[i].life-=dt; if(sparks[i].life<=0) sparks.splice(i,1); }
+  for(let i=floats.length-1;i>=0;i--){
+    const f=floats[i]; f.life-=dt*1.25;
+    if(f.life<=0){ floats.splice(i,1); continue; }
+    f.y+=f.vy*dt; f.x+=f.vx*dt; f.vy*=Math.pow(0.94,dt*60);
+  }
+
+  /* ---- camera / flash ---- */
+  G.shake*=Math.pow(0.0009,dt);
+  if(G.shake<0.05) G.shake=0;
+  const s=G.shake;
+  G.shakeX=rand(s,-s); G.shakeY=rand(s,-s);
+  G.rot*=Math.pow(0.002,dt);
+  G.flash*=Math.pow(0.0022,dt);
+  G.disp += (G.score-G.disp)*Math.min(1,dt*7);
+
+  /* ---- toast / banner ---- */
+  if(toastT>0){ toastT-=dt; toastEl.style.opacity=String(clamp(toastT>1.2?(1.6-toastT)/0.4:toastT/1.2,0,1)); }
+  else toastEl.style.opacity='0';
+  if(bannerT>0){
+    bannerT-=dt;
+    const k=clamp(bannerT>1.4?(1.9-bannerT)/0.5:bannerT/1.4,0,1);
+    bannerEl.style.opacity=String(k);
+    bannerEl.style.transform='translate(-50%,-50%) scale('+(1+(1-k)*0.16)+')';
+  } else bannerEl.style.opacity='0';
+
+  /* ---- wave clear ---- */
+  if(G.state==='play' && orbs.length===0 && G.respawnT<=0){
+    G.waveClearT+=dt;
+    if(G.waveClearT>1.1){
+      G.waveClearT=0;
+      addScore(400+G.wave*120, W/2, H*0.4, 'WAVE CLEAR');
+      startWave();
+    }
+  } else G.waveClearT=0;
+
+  /* ---- stars ---- */
+  for(const st of stars){ st.tw+=dt*st.ts; }
+  for(const n of neb){
+    n.x+=n.vx*dt*10; n.y+=n.vy*dt*10;
+    if(n.x<-n.r) n.x=W+n.r; if(n.x>W+n.r) n.x=-n.r;
+    if(n.y<-n.r) n.y=H+n.r; if(n.y>H+n.r) n.y=-n.r;
+  }
+}
+
+/* ---------------- drawing ---------------- */
+function drawBG(){
+  ctx.fillStyle='#04040c';
+  ctx.fillRect(0,0,W,H);
+
+  // nebula clouds
+  ctx.globalCompositeOperation='lighter';
+  for(const n of neb){
+    const g=ctx.createRadialGradient(n.x,n.y,0,n.x,n.y,n.r);
+    g.addColorStop(0,'hsla('+n.h+',85%,58%,'+n.a+')');
+    g.addColorStop(0.55,'hsla('+n.h+',85%,45%,'+(n.a*0.35)+')');
+    g.addColorStop(1,'hsla('+n.h+',85%,40%,0)');
+    ctx.fillStyle=g;
+    ctx.beginPath(); ctx.arc(n.x,n.y,n.r,0,TAU); ctx.fill();
+  }
+  ctx.globalCompositeOperation='source-over';
+
+  // stars with parallax based on ship velocity
+  let px=0, py=0;
+  if(ship){ px=-ship.vx*0.012; py=-ship.vy*0.012; }
+  for(const st of stars){
+    const tw=0.55+0.45*Math.sin(st.tw);
+    const a=st.a*tw;
+    const sx=st.x+px*(st.l+1), sy=st.y+py*(st.l+1);
+    ctx.fillStyle='hsla('+st.h+',80%,'+(72+st.l*8)+'%,'+a+')';
+    ctx.beginPath();
+    ctx.arc(((sx%W)+W)%W, ((sy%H)+H)%H, st.r, 0, TAU);
+    ctx.fill();
+  }
+  // vignette
+  const vg=ctx.createRadialGradient(W/2,H/2,Math.min(W,H)*0.3,W/2,H/2,Math.max(W,H)*0.78);
+  vg.addColorStop(0,'rgba(0,0,0,0)');
+  vg.addColorStop(1,'rgba(0,0,0,0.72)');
+  ctx.fillStyle=vg; ctx.fillRect(0,0,W,H);
+}
+
+/* draw with toroidal repetition when near edges */
+function wrapDraw(x,y,r,fn){
+  const offs=[[0,0]];
+  if(x<r) offs.push([W,0]); else if(x>W-r) offs.push([-W,0]);
+  if(y<r) offs.push([0,H]); else if(y>H-r) offs.push([0,-H]);
+  if(offs.length===3) offs.push([offs[1][0],offs[2][1]]);
+  for(let i=0;i<offs.length;i++){
+    ctx.save(); ctx.translate(offs[i][0],offs[i][1]); fn(); ctx.restore();
+  }
+}
+
+function drawOrb(o){
+  const T=TYPE_BY_KEY[o.type];
+  const pulse=1+Math.sin(o.pulse)*0.045;
+  const R=o.r*pulse*(o.born>0? (1-o.born/0.5)*0.5+0.5 : 1);
+  const alpha = o.type==='phase' ? 0.45+0.55*o.ghost : 1;
+
+  ctx.save();
+  ctx.globalAlpha=alpha;
+  ctx.translate(o.x,o.y);
+  ctx.rotate(o.a);
+
+  // outer glow
+  ctx.globalCompositeOperation='lighter';
+  const gg=ctx.createRadialGradient(0,0,R*0.35,0,0,R*2.15);
+  gg.addColorStop(0,hexA(T.c1,0.36));
+  gg.addColorStop(0.5,hexA(T.c1,0.12));
+  gg.addColorStop(1,hexA(T.c1,0));
+  ctx.fillStyle=gg;
+  ctx.beginPath(); ctx.arc(0,0,R*2.15,0,TAU); ctx.fill();
+  ctx.globalCompositeOperation='source-over';
+
+  // body blob
+  ctx.beginPath();
+  const n=o.verts.length;
+  for(let i=0;i<=n;i++){
+    const idx=i%n;
+    const a=(i/n)*TAU;
+    const wob=1+Math.sin(o.wob+idx*1.7)*0.035;
+    const rr=R*o.verts[idx]*wob;
+    const x=Math.cos(a)*rr, y=Math.sin(a)*rr;
+    if(i===0) ctx.moveTo(x,y);
+    else{
+      const pa=((i-1)/n)*TAU;
+      const pidx=(i-1)%n;
+      const prr=R*o.verts[pidx]*(1+Math.sin(o.wob+pidx*1.7)*0.035);
+      const cx=(Math.cos(pa)*prr+x)/2, cy=(Math.sin(pa)*prr+y)/2;
+      ctx.quadraticCurveTo(Math.cos((pa+a)/2)*((prr+rr)/2)*1.06, Math.sin((pa+a)/2)*((prr+rr)/2)*1.06, x,y);
+      void cx; void cy;
+    }
+  }
+  ctx.closePath();
+
+  const bg=ctx.createRadialGradient(-R*0.32,-R*0.36,R*0.06,0,0,R*1.15);
+  bg.addColorStop(0,'#ffffff');
+  bg.addColorStop(0.14,T.c1);
+  bg.addColorStop(0.62,T.c2);
+  bg.addColorStop(1,'#04040c');
+  ctx.fillStyle=bg;
+  ctx.fill();
+
+  ctx.lineWidth=1.8;
+  ctx.strokeStyle=hexA(T.c1,0.85);
+  ctx.stroke();
+
+  // hit flash
+  if(o.hit>0){
+    ctx.globalCompositeOperation='lighter';
+    ctx.fillStyle='rgba(255,255,255,'+(o.hit*0.5)+')';
+    ctx.fill();
+    ctx.globalCompositeOperation='source-over';
+  }
+
+  // type-specific interior glyph
+  ctx.globalCompositeOperation='lighter';
+  ctx.lineWidth=1.6;
+  ctx.strokeStyle=hexA(T.c1,0.95);
+  const g=G.t;
+  switch(o.type){
+    case 'swarm':
+      for(let i=0;i<5;i++){
+        const a=g*1.6+i*TAU/5, rr=R*0.5;
+        ctx.beginPath(); ctx.arc(Math.cos(a)*rr,Math.sin(a)*rr,R*0.13,0,TAU);
+        ctx.fillStyle=hexA(T.c1,0.9); ctx.fill();
+      }
+      break;
+    case 'gravity':
+      for(let i=0;i<3;i++){
+        ctx.beginPath();
+        for(let k=0;k<28;k++){
+          const t=k/27, aa=t*TAU*1.5+g*1.5+i*2.1, rr=R*0.82*(1-t*0.85);
+          const x=Math.cos(aa)*rr, y=Math.sin(aa)*rr;
+          k?ctx.lineTo(x,y):ctx.moveTo(x,y);
+        }
+        ctx.stroke();
+      }
+      break;
+    case 'phase':
+      for(let i=0;i<3;i++){
+        ctx.globalAlpha=alpha*(0.5-i*0.13);
+        ctx.beginPath(); ctx.arc(Math.sin(g*3+i)*R*0.2,0,R*(0.7-i*0.17),0,TAU); ctx.stroke();
+      }
+      ctx.globalAlpha=alpha;
+      break;
+    case 'void':
+      ctx.fillStyle='#000';
+      ctx.globalCompositeOperation='source-over';
+      ctx.beginPath(); ctx.arc(0,0,R*0.44,0,TAU); ctx.fill();
+      ctx.globalCompositeOperation='lighter';
+      ctx.beginPath(); ctx.arc(0,0,R*0.46,-g*2,-g*2+2.4); ctx.stroke();
+      ctx.beginPath(); ctx.arc(0,0,R*0.62,-g*1.4+3, -g*1.4+5); ctx.stroke();
+      break;
+    case 'reverse':
+      ctx.beginPath();
+      ctx.arc(0,0,R*0.5,0.6,0.6+4.2); ctx.stroke();
+      ctx.beginPath();
+      ctx.moveTo(Math.cos(0.6+4.2)*R*0.5,Math.sin(0.6+4.2)*R*0.5);
+      ctx.lineTo(Math.cos(0.6+4.2)*R*0.5+R*0.2,Math.sin(0.6+4.2)*R*0.5-R*0.06);
+      ctx.lineTo(Math.cos(0.6+4.2)*R*0.5+R*0.03,Math.sin(0.6+4.2)*R*0.5+R*0.2);
+      ctx.closePath(); ctx.fillStyle=hexA(T.c1,0.9); ctx.fill();
+      break;
+    case 'shard':
+      for(let i=0;i<5;i++){
+        const a=i*TAU/5+g*0.4;
+        ctx.beginPath();
+        ctx.moveTo(0,0);
+        ctx.lineTo(Math.cos(a)*R*0.82,Math.sin(a)*R*0.82);
+        ctx.stroke();
+      }
+      break;
+    case 'slow': {
+      ctx.beginPath(); ctx.arc(0,0,R*0.55,0,TAU); ctx.stroke();
+      const ha=-Math.PI/2+ (g*0.55%TAU);
+      const ma=-Math.PI/2+ (g*3.1%TAU);
+      ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(Math.cos(ha)*R*0.3,Math.sin(ha)*R*0.3); ctx.stroke();
+      ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(Math.cos(ma)*R*0.5,Math.sin(ma)*R*0.5); ctx.stroke();
+      break;
+    }
+    case 'chain':
+      for(let i=0;i<4;i++){
+        ctx.strokeStyle='hsla('+((g*80+i*70)%360)+',100%,72%,0.9)';
+        ctx.beginPath();
+        ctx.arc(0,0,R*(0.24+i*0.16),g*(i%2?1.5:-1.5),g*(i%2?1.5:-1.5)+2.0);
+        ctx.stroke();
+      }
+      break;
+    case 'mirror':
+      ctx.beginPath();
+      ctx.moveTo(0,-R*0.75); ctx.lineTo(0,R*0.75); ctx.stroke();
+      ctx.beginPath();
+      ctx.moveTo(-R*0.75,0); ctx.lineTo(R*0.75,0); ctx.stroke();
+      ctx.globalAlpha=alpha*0.5;
+      ctx.beginPath(); ctx.arc(-R*0.34,0,R*0.24,0,TAU); ctx.stroke();
+      ctx.beginPath(); ctx.arc(R*0.34,0,R*0.24,0,TAU); ctx.stroke();
+      ctx.globalAlpha=alpha;
+      break;
+    case 'rubber':
+      for(let i=0;i<3;i++){
+        ctx.beginPath();
+        ctx.arc(0,0,R*(0.28+i*0.2)*(1+Math.sin(g*4+i)*0.07),0,TAU);
+        ctx.stroke();
+      }
+      break;
+  }
+  ctx.globalCompositeOperation='source-over';
+
+  // specular highlight
+  ctx.globalAlpha=alpha*0.55;
+  const hg=ctx.createRadialGradient(-R*0.38,-R*0.42,0,-R*0.38,-R*0.42,R*0.55);
+  hg.addColorStop(0,'rgba(255,255,255,0.9)');
+  hg.addColorStop(1,'rgba(255,255,255,0)');
+  ctx.fillStyle=hg;
+  ctx.beginPath(); ctx.arc(-R*0.34,-R*0.38,R*0.55,0,TAU); ctx.fill();
+
+  ctx.restore();
+}
+
+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 drawShip(){
+  if(!ship) return;
+  const blink = ship.inv>0 && (Math.floor(G.t*14)%2===0);
+  ctx.save();
+  ctx.translate(ship.x,ship.y);
+
+  // engine plume
+  if(ship.thrust>0.02){
+    ctx.save();
+    ctx.rotate(ship.a);
+    ctx.globalCompositeOperation='lighter';
+    const L=(26+Math.sin(G.t*40)*7)*ship.thrust;
+    const pg=ctx.createLinearGradient(-12,0,-12-L,0);
+    pg.addColorStop(0,'rgba(255,255,255,'+(0.85*ship.thrust)+')');
+    pg.addColorStop(0.28,'rgba(140,235,255,'+(0.6*ship.thrust)+')');
+    pg.addColorStop(1,'rgba(176,108,255,0)');
+    ctx.fillStyle=pg;
+    ctx.beginPath();
+    ctx.moveTo(-11,-6.5); ctx.lineTo(-12-L,0); ctx.lineTo(-11,6.5); ctx.closePath();
+    ctx.fill();
+    ctx.restore();
+  }
+
+  ctx.rotate(ship.a);
+  const rec = ship.recoil>0? -ship.recoil*2.4 : 0;
+  ctx.translate(rec,0);
+
+  ctx.globalAlpha = blink? 0.32 : 1;
+
+  // hull glow
+  ctx.globalCompositeOperation='lighter';
+  const g=ctx.createRadialGradient(0,0,2,0,0,26);
+  g.addColorStop(0,'rgba(140,235,255,0.4)');
+  g.addColorStop(1,'rgba(140,235,255,0)');
+  ctx.fillStyle=g;
+  ctx.beginPath(); ctx.arc(0,0,26,0,TAU); ctx.fill();
+  ctx.globalCompositeOperation='source-over';
+
+  // hull
+  ctx.beginPath();
+  ctx.moveTo(17,0);
+  ctx.lineTo(-9,-11);
+  ctx.lineTo(-5,0);
+  ctx.lineTo(-9,11);
+  ctx.closePath();
+  const hg=ctx.createLinearGradient(-10,0,17,0);
+  hg.addColorStop(0,'#16324a');
+  hg.addColorStop(0.55,'#9fe9ff');
+  hg.addColorStop(1,'#ffffff');
+  ctx.fillStyle=hg;
+  ctx.fill();
+  ctx.lineWidth=1.7;
+  ctx.strokeStyle= G.reverseT>0? '#ffd166' : '#cff6ff';
+  ctx.stroke();
+
+  // cockpit
+  ctx.beginPath(); ctx.ellipse(4,0,4.6,2.9,0,0,TAU);
+  ctx.fillStyle= G.reverseT>0? 'rgba(255,209,102,.95)' : 'rgba(127,240,208,.95)';
+  ctx.fill();
+
+  // shield ring when invulnerable
+  ctx.restore();
+  if(ship.inv>0){
+    ctx.save();
+    ctx.translate(ship.x,ship.y);
+    ctx.globalCompositeOperation='lighter';
+    const k=clamp(ship.inv/2.8,0,1);
+    ctx.strokeStyle='rgba(127,240,208,'+(0.35+0.3*Math.sin(G.t*9))*k+')';
+    ctx.lineWidth=2;
+    ctx.beginPath(); ctx.arc(0,0,20+Math.sin(G.t*5)*2.4,0,TAU); ctx.stroke();
+    ctx.restore();
+  }
+  // blink ready pip
+  if(ship.blinkCd<=0){
+    ctx.save();
+    ctx.globalCompositeOperation='lighter';
+    ctx.fillStyle='rgba(139,233,255,'+(0.4+0.3*Math.sin(G.t*6))+')';
+    ctx.beginPath(); ctx.arc(ship.x,ship.y,3.2,0,TAU); ctx.fill();
+    ctx.restore();
+  }
+}
+
+function draw(){
+  drawBG();
+
+  ctx.save();
+  ctx.translate(W/2+G.shakeX, H/2+G.shakeY);
+  ctx.rotate(G.rot);
+  ctx.translate(-W/2,-H/2);
+
+  ctx.globalCompositeOperation='lighter';
+
+  /* bubbles (time slow) */
+  for(const b of bubbles){
+    const k=clamp(b.life/b.max,0,1);
+    const g=ctx.createRadialGradient(b.x,b.y,b.r*0.1,b.x,b.y,b.r);
+    g.addColorStop(0,'rgba(127,240,208,0.02)');
+    g.addColorStop(0.75,'rgba(127,240,208,'+(0.06*k)+')');
+    g.addColorStop(0.98,'rgba(127,240,208,'+(0.28*k)+')');
+    g.addColorStop(1,'rgba(127,240,208,0)');
+    ctx.fillStyle=g;
+    ctx.beginPath(); ctx.arc(b.x,b.y,b.r,0,TAU); ctx.fill();
+    ctx.strokeStyle='rgba(180,255,235,'+(0.3*k)+')';
+    ctx.lineWidth=1.4;
+    ctx.beginPath(); ctx.arc(b.x,b.y,b.r,0,TAU); ctx.stroke();
+    // clock ticks
+    for(let i=0;i<12;i++){
+      const a=i*TAU/12+b.a*0.4;
+      ctx.strokeStyle='rgba(127,240,208,'+(0.22*k)+')';
+      ctx.beginPath();
+      ctx.moveTo(b.x+Math.cos(a)*b.r*0.9,b.y+Math.sin(a)*b.r*0.9);
+      ctx.lineTo(b.x+Math.cos(a)*b.r,b.y+Math.sin(a)*b.r);
+      ctx.stroke();
+    }
+  }
+
+  /* gravity wells */
+  for(const w of wells){
+    const k=clamp(w.life/w.max,0,1);
+    const g=ctx.createRadialGradient(w.x,w.y,2,w.x,w.y,w.r);
+    g.addColorStop(0,'rgba(197,139,255,'+(0.5*k)+')');
+    g.addColorStop(0.35,'rgba(140,90,220,'+(0.18*k)+')');
+    g.addColorStop(1,'rgba(90,40,180,0)');
+    ctx.fillStyle=g;
+    ctx.beginPath(); ctx.arc(w.x,w.y,w.r,0,TAU); ctx.fill();
+    ctx.lineWidth=1.3;
+    for(let i=0;i<4;i++){
+      ctx.strokeStyle='rgba(197,139,255,'+(0.28*k)+')';
+      ctx.beginPath();
+      for(let s=0;s<40;s++){
+        const t=s/39;
+        const aa=t*TAU*1.6 + w.a*(i%2?1:-1) + i*1.57;
+        const rr=w.r*(0.12+t*0.86);
+        const x=w.x+Math.cos(aa)*rr, y=w.y+Math.sin(aa)*rr;
+        s?ctx.lineTo(x,y):ctx.moveTo(x,y);
+      }
+      ctx.stroke();
+    }
+  }
+
+  /* black holes */
+  for(const h of holes){
+    const k=clamp(h.life/h.max,0,1);
+    // accretion disk
+    ctx.save();
+    ctx.translate(h.x,h.y); ctx.rotate(h.a*0.5);
+    for(let i=0;i<3;i++){
+      ctx.strokeStyle='rgba(255,'+(80+i*50)+','+(130+i*40)+','+(0.5*k)+')';
+      ctx.lineWidth=2.6-i*0.6;
+      ctx.beginPath();
+      ctx.ellipse(0,0,h.r*(2.0+i*0.7),h.r*(0.62+i*0.24),h.a*0.3,0,TAU);
+      ctx.stroke();
+    }
+    ctx.restore();
+    const g=ctx.createRadialGradient(h.x,h.y,h.r*0.5,h.x,h.y,h.r*3.4);
+    g.addColorStop(0,'rgba(255,91,127,'+(0.5*k)+')');
+    g.addColorStop(0.4,'rgba(176,108,255,'+(0.16*k)+')');
+    g.addColorStop(1,'rgba(90,20,90,0)');
+    ctx.fillStyle=g;
+    ctx.beginPath(); ctx.arc(h.x,h.y,h.r*3.4,0,TAU); ctx.fill();
+  }
+  ctx.globalCompositeOperation='source-over';
+  for(const h of holes){
+    // event horizon (true black)
+    ctx.fillStyle='#000';
+    ctx.beginPath(); ctx.arc(h.x,h.y,h.r*0.9,0,TAU); ctx.fill();
+    ctx.strokeStyle='rgba(255,255,255,0.5)';
+    ctx.lineWidth=1.2;
+    ctx.beginPath(); ctx.arc(h.x,h.y,h.r*0.9,0,TAU); ctx.stroke();
+  }
+  ctx.globalCompositeOperation='lighter';
+
+  /* ripples */
+  for(const r of ripples){
+    const k=clamp(r.life/r.max,0,1);
+    ctx.strokeStyle=hexA(r.col,k*0.85);
+    ctx.lineWidth=r.w*k+0.4;
+    ctx.beginPath(); ctx.arc(r.x,r.y,r.cur||r.r,0,TAU); ctx.stroke();
+  }
+
+  /* debris chunks */
+  ctx.globalCompositeOperation='source-over';
+  for(const d of debris){
+    const k=clamp(d.life/d.max,0,1);
+    ctx.save();
+    ctx.globalAlpha=k;
+    ctx.translate(d.x,d.y); ctx.rotate(d.a);
+    ctx.beginPath();
+    ctx.moveTo(d.r,0);
+    ctx.lineTo(-d.r*0.6,d.r*0.8);
+    ctx.lineTo(-d.r*0.3,-d.r*0.7);
+    ctx.closePath();
+    ctx.fillStyle=d.col; ctx.fill();
+    ctx.strokeStyle=d.edge; ctx.lineWidth=1; ctx.stroke();
+    ctx.restore();
+  }
+  ctx.globalAlpha=1;
+
+  /* orbs */
+  for(const o of orbs) wrapDraw(o.x,o.y,o.r*2.4,()=>drawOrb(o));
+
+  /* swarmlings */
+  ctx.globalCompositeOperation='lighter';
+  for(const s of swarm){
+    const k=clamp(s.life/s.max,0,1);
+    for(let i=0;i<s.trail.length;i++){
+      const p=s.trail[i], t=i/s.trail.length;
+      if(Math.abs(p.x-s.x)>W/2||Math.abs(p.y-s.y)>H/2) continue;
+      ctx.fillStyle='rgba(141,255,159,'+(t*0.35*k)+')';
+      ctx.beginPath(); ctx.arc(p.x,p.y,s.r*t*0.8,0,TAU); ctx.fill();
+    }
+    const g=ctx.createRadialGradient(s.x,s.y,0,s.x,s.y,s.r*3);
+    g.addColorStop(0,'rgba(220,255,225,'+(0.95*k)+')');
+    g.addColorStop(0.28,'rgba(141,255,159,'+(0.6*k)+')');
+    g.addColorStop(1,'rgba(141,255,159,0)');
+    ctx.fillStyle=g;
+    ctx.beginPath(); ctx.arc(s.x,s.y,s.r*3,0,TAU); ctx.fill();
+    ctx.fillStyle='#eaffe9';
+    ctx.beginPath(); ctx.arc(s.x,s.y,s.r*0.55,0,TAU); ctx.fill();
+  }
+
+  /* shards */
+  for(const s of shards){
+    const k=clamp(s.life/s.max,0,1);
+    for(let i=1;i<s.trail.length;i++){
+      const a=s.trail[i-1], b=s.trail[i];
+      if(Math.abs(a.x-b.x)>W/2||Math.abs(a.y-b.y)>H/2) continue;
+      ctx.strokeStyle='rgba(188,216,255,'+(i/s.trail.length*0.35*k)+')';
+      ctx.lineWidth=2.2*(i/s.trail.length);
+      ctx.beginPath(); ctx.moveTo(a.x,a.y); ctx.lineTo(b.x,b.y); ctx.stroke();
+    }
+    ctx.save();
+    ctx.translate(s.x,s.y); ctx.rotate(s.a);
+    ctx.fillStyle='rgba(226,240,255,'+(0.95*k)+')';
+    ctx.beginPath();
+    ctx.moveTo(s.len,0); ctx.lineTo(-s.len*0.34,s.len*0.3); ctx.lineTo(-s.len*0.2,-s.len*0.28);
+    ctx.closePath(); ctx.fill();
+    ctx.strokeStyle='rgba(255,255,255,'+(0.8*k)+')';
+    ctx.lineWidth=1; ctx.stroke();
+    ctx.restore();
+  }
+
+  /* bullets */
+  for(const b of bullets){
+    for(let i=1;i<b.trail.length;i++){
+      const p=b.trail[i-1], q=b.trail[i], t=i/b.trail.length;
+      if(Math.abs(p.x-q.x)>W/2||Math.abs(p.y-q.y)>H/2) continue;
+      ctx.strokeStyle='hsla('+b.hue+',100%,'+(60+t*30)+'%,'+(t*0.6)+')';
+      ctx.lineWidth=b.r*2*t;
+      ctx.lineCap='round';
+      ctx.beginPath(); ctx.moveTo(p.x,p.y); ctx.lineTo(q.x,q.y); ctx.stroke();
+    }
+    const g=ctx.createRadialGradient(b.x,b.y,0,b.x,b.y,b.r*4.5);
+    g.addColorStop(0,'#ffffff');
+    g.addColorStop(0.3,'hsla('+b.hue+',100%,72%,0.85)');
+    g.addColorStop(1,'hsla('+b.hue+',100%,60%,0)');
+    ctx.fillStyle=g;
+    ctx.beginPath(); ctx.arc(b.x,b.y,b.r*4.5,0,TAU); ctx.fill();
+  }
+
+  /* particles */
+  for(const p of parts){
+    const k=clamp(p.life/p.max,0,1);
+    ctx.globalAlpha=k;
+    ctx.fillStyle=p.col;
+    ctx.beginPath(); ctx.arc(p.x,p.y,p.size*(0.35+k*0.75),0,TAU); ctx.fill();
+  }
+  ctx.globalAlpha=1;
+
+  /* muzzle sparks */
+  for(const s of sparks){
+    const k=clamp(s.life/s.max,0,1);
+    ctx.strokeStyle=hexA(s.col,k);
+    ctx.lineWidth=2.4*k;
+    ctx.beginPath();
+    ctx.moveTo(s.x,s.y);
+    ctx.lineTo(s.x+Math.cos(s.ang)*s.len*k,s.y+Math.sin(s.ang)*s.len*k);
+    ctx.stroke();
+  }
+
+  ctx.globalCompositeOperation='source-over';
+
+  /* ship */
+  if(ship && ship.alive) wrapDraw(ship.x,ship.y,34,drawShip);
+
+  /* floating score text */
+  ctx.textAlign='center';
+  for(const f of floats){
+    const k=clamp(f.life,0,1);
+    ctx.globalAlpha=k;
+    ctx.font='900 '+f.size+'px "Trebuchet MS",system-ui,sans-serif';
+    ctx.shadowColor=f.col; ctx.shadowBlur=14;
+    ctx.fillStyle=f.col;
+    ctx.fillText(f.txt,f.x,f.y);
+    ctx.shadowBlur=0;
+  }
+  ctx.globalAlpha=1;
+
+  ctx.restore();
+
+  drawHUD();
+
+  /* full-screen flash */
+  if(G.flash>0.004){
+    ctx.globalCompositeOperation='lighter';
+    ctx.fillStyle=hexA(G.flashCol,Math.min(0.6,G.flash*0.55));
+    ctx.fillRect(0,0,W,H);
+    ctx.globalCompositeOperation='source-over';
+  }
+  /* reverse-mode tint */
+  if(G.reverseT>0){
+    const k=Math.min(1,G.reverseT/1.5)*0.10*(0.7+0.3*Math.sin(G.t*8));
+    ctx.fillStyle='rgba(255,190,60,'+k+')';
+    ctx.fillRect(0,0,W,H);
+    // edge bars
+    ctx.fillStyle='rgba(255,209,102,'+(0.16+0.08*Math.sin(G.t*10))+')';
+    ctx.fillRect(0,0,W,4); ctx.fillRect(0,H-4,W,4);
+  }
+  /* slow-mo tint */
+  if(G.slowT>0.02){
+    ctx.fillStyle='rgba(127,240,208,'+(G.slowT*0.055)+')';
+    ctx.fillRect(0,0,W,H);
+  }
+  /* scanlines */
+  ctx.globalAlpha=0.045;
+  ctx.fillStyle='#000';
+  for(let y=0;y<H;y+=3) ctx.fillRect(0,y,W,1);
+  ctx.globalAlpha=1;
+}
+
+/* ---------------- HUD ---------------- */
+function drawHUD(){
+  if(G.state!=='play') return;
+  const pad=22;
+  ctx.save();
+  ctx.textAlign='left';
+  ctx.textBaseline='top';
+
+  // score
+  ctx.font='900 34px "Trebuchet MS",system-ui,sans-serif';
+  ctx.shadowColor='rgba(120,210,255,.85)'; ctx.shadowBlur=18;
+  ctx.fillStyle='#dff2ff';
+  ctx.fillText(String(Math.round(G.disp)).padStart(6,'0'), pad, pad-4);
+  ctx.shadowBlur=0;
+
+  ctx.font='700 11px "Trebuchet MS",system-ui,sans-serif';
+  ctx.fillStyle='#6f88ad';
+  ctx.fillText('SCORE   BEST '+G.best, pad+2, pad+32);
+
+  // lives (ship icons)
+  for(let i=0;i<Math.min(G.lives,8);i++){
+    ctx.save();
+    ctx.translate(pad+10+i*24, pad+62);
+    ctx.rotate(-Math.PI/2);
+    ctx.beginPath();
+    ctx.moveTo(9,0); ctx.lineTo(-5,-6); ctx.lineTo(-2.6,0); ctx.lineTo(-5,6);
+    ctx.closePath();
+    ctx.fillStyle='#9fe9ff';
+    ctx.shadowColor='#63c8ff'; ctx.shadowBlur=10;
+    ctx.fill();
+    ctx.restore();
+  }
+  ctx.shadowBlur=0;
+
+  // wave + orbs right
+  ctx.textAlign='right';
+  ctx.font='900 26px "Trebuchet MS",system-ui,sans-serif';
+  ctx.fillStyle='#cfe4ff';
+  ctx.shadowColor='rgba(176,108,255,.7)'; ctx.shadowBlur=14;
+  ctx.fillText('WAVE '+G.wave, W-pad, pad);
+  ctx.shadowBlur=0;
+  ctx.font='700 11px "Trebuchet MS",system-ui,sans-serif';
+  ctx.fillStyle='#6f88ad';
+  ctx.fillText(orbs.length+' ORBS  ·  '+G.popped+' POPPED', W-pad, pad+30);
+
+  // combo meter
+  if(G.combo>1){
+    const k=clamp(G.comboT/1.8,0,1);
+    const cx=W/2, cy=pad+16;
+    ctx.textAlign='center';
+    ctx.font='900 '+(20+Math.min(G.combo,14))+'px "Trebuchet MS",system-ui,sans-serif';
+    const hue=(140+G.combo*14)%360;
+    ctx.fillStyle='hsl('+hue+',100%,72%)';
+    ctx.shadowColor='hsl('+hue+',100%,60%)'; ctx.shadowBlur=18;
+    ctx.fillText('x'+G.combo+' COMBO', cx, cy-8);
+    ctx.shadowBlur=0;
+    // bar
+    const bw=150;
+    ctx.fillStyle='rgba(255,255,255,.12)';
+    ctx.fillRect(cx-bw/2, cy+22, bw, 4);
+    ctx.fillStyle='hsl('+hue+',100%,66%)';
+    ctx.fillRect(cx-bw/2, cy+22, bw*k, 4);
+  }
+
+  // blink cooldown ring (bottom-left)
+  if(ship){
+    const bx=pad+18, by=H-pad-18;
+    const rdy = ship.blinkCd<=0;
+    ctx.beginPath(); ctx.arc(bx,by,14,0,TAU);
+    ctx.strokeStyle='rgba(255,255,255,.16)'; ctx.lineWidth=3; ctx.stroke();
+    const p = rdy?1:1-(ship.blinkCd/3.4);
+    ctx.beginPath(); ctx.arc(bx,by,14,-Math.PI/2,-Math.PI/2+TAU*p);
+    ctx.strokeStyle= rdy? '#8be9ff' : '#4a6a86';
+    ctx.lineWidth=3;
+    if(rdy){ ctx.shadowColor='#8be9ff'; ctx.shadowBlur=12; }
+    ctx.stroke(); ctx.shadowBlur=0;
+    ctx.textAlign='center'; ctx.textBaseline='middle';
+    ctx.font='900 10px "Trebuchet MS",system-ui,sans-serif';
+    ctx.fillStyle= rdy? '#cff4ff':'#5c7a92';
+    ctx.fillText('⇧',bx,by+1);
+    ctx.textBaseline='top';
+    ctx.font='700 9px "Trebuchet MS",system-ui,sans-serif';
+    ctx.fillStyle='#5b7characters'.slice(0,7)==='#5b7cha'?'#5b7c96':'#5b7c96';
+    ctx.fillText('BLINK',bx,by+20);
+  }
+
+  // active-effect chips (bottom-right)
+  const chips=[];
+  if(G.reverseT>0) chips.push({t:'REVERSED '+G.reverseT.toFixed(1),c:'#ffd166'});
+  if(bubbles.length) chips.push({t:'TIME BUBBLE',c:'#7ff0d0'});
+  if(holes.length) chips.push({t:'BLACK HOLE',c:'#ff5b7f'});
+  if(wells.length) chips.push({t:'GRAVITY WELL',c:'#c58bff'});
+  if(swarm.length) chips.push({t:'SWARM '+swarm.length,c:'#8dff9f'});
+  ctx.textAlign='right'; ctx.textBaseline='middle';
+  ctx.font='800 11px "Trebuchet MS",system-ui,sans-serif';
+  for(let i=0;i<chips.length;i++){
+    const y=H-pad-10-i*20;
+    ctx.fillStyle=hexA(chips[i].c,0.14);
+    const w=ctx.measureText(chips[i].t).width+20;
+    ctx.beginPath();
+    if(ctx.roundRect) { ctx.roundRect(W-pad-w, y-8, w, 16, 8); ctx.fill(); }
+    else ctx.fillRect(W-pad-w,y-8,w,16);
+    ctx.fillStyle=chips[i].c;
+    ctx.fillText(chips[i].t, W-pad-10, y);
+  }
+  ctx.restore();
+}
+
+/* ---------------- loop ---------------- */
+let last=performance.now();
+function frame(now){
+  let dt=(now-last)/1000; last=now;
+  if(dt>0.05) dt=0.05;
+  if(G.state==='play' && !G.paused) update(dt);
+  else {
+    // idle ambience on menus
+    G.t+=dt;
+    for(const st of stars) st.tw+=dt*st.ts;
+    for(const n of neb){
+      n.x+=n.vx*dt*10; n.y+=n.vy*dt*10;
+      if(n.x<-n.r) n.x=W+n.r; if(n.x>W+n.r) n.x=-n.r;
+      if(n.y<-n.r) n.y=H+n.r; if(n.y>H+n.r) n.y=-n.r;
+    }
+    for(let i=parts.length-1;i>=0;i--){
+      const p=parts[i]; p.life-=dt;
+      if(p.life<=0){ parts.splice(i,1); continue; }
+      p.x+=p.vx*dt; p.y+=p.vy*dt;
+      p.vx*=Math.pow(p.drag,dt*60); p.vy*=Math.pow(p.drag,dt*60);
+    }
+    G.flash*=Math.pow(0.0022,dt);
+    G.shake*=Math.pow(0.0009,dt);
+    G.shakeX=rand(G.shake,-G.shake); G.shakeY=rand(G.shake,-G.shake);
+  }
+  draw();
+  requestAnimationFrame(frame);
+}
+requestAnimationFrame(frame);
+
+/* menu ambience: idle orbs drifting behind the start screen */
+(function ambient(){
+  for(let i=0;i<7;i++){
+    const o=spawnOrb();
+    o.vx*=0.5; o.vy*=0.5;
+  }
+})();
+</script>
+</body>
+</html>
\ No newline at end of file

← 738e98d TK-11051: Baguette Brawl — add Breton striped shirt + French  ·  back to Games Agentabrams  ·  (newest)