preview
LMB (move) • Q W E (stance, costs mana) • match colours
🔥 slow (GPU melter) 📱 RIP mobile users
source
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
  <title>Prismo</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body { overflow: hidden; background: #111; }
    #game {
      display: block;
      width: 100vw;
      height: calc(100vh - 32px);
      margin-top: 32px;
      cursor: crosshair;
    }
    #xp-bar-wrap {
      position: fixed;
      top: 0; left: 0; right: 0;
      width: 100%;
      height: 32px;
      z-index: 60;
      border: none;
      outline: none;
    }
    #xp-bar-track {
      position: relative;
      width: 100%;
      height: 100%;
      background: #111;
      overflow: hidden;
      border: none;
      outline: none;
    }
    #xp-bar-fill {
      position: absolute;
      left: 0; top: 0; bottom: 0;
      width: 0%;
      background: #6a8a9e;
    }
    #xp-bar-meta {
      position: absolute;
      inset: 0;
      display: flex;
      align-items: center;
      justify-content: center;
      font-family: ui-monospace, monospace;
      font-size: 12px;
      color: #e8e8e8;
      white-space: nowrap;
      pointer-events: none;
      z-index: 1;
    }
    #stance-key-hud {
      position: fixed;
      top: 36px;
      left: 50%;
      transform: translateX(-50%);
      display: flex;
      gap: 8px;
      z-index: 55;
      pointer-events: none;
      font-family: ui-monospace, monospace;
    }
    .key-cap {
      width: 34px;
      height: 34px;
      display: flex;
      align-items: center;
      justify-content: center;
      border-radius: 4px;
      font-size: 15px;
      font-weight: 700;
      letter-spacing: 0.02em;
      transition: transform 0.07s ease, box-shadow 0.12s ease, filter 0.12s ease;
    }
    /* Red — Q (matches RGB.r) */
    .key-cap-q {
      background: linear-gradient(180deg, #f05555 0%, #e54545 40%, #a01818 100%);
      border: 2px solid #ff8888;
      color: #fff8f8;
      text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
      box-shadow:
        0 3px 0 #501010,
        inset 0 1px 0 rgba(255, 200, 200, 0.45);
    }
    .key-cap-q.active {
      box-shadow:
        0 3px 0 #501010,
        inset 0 1px 0 rgba(255, 220, 220, 0.55),
        0 0 0 2px rgba(255, 255, 255, 0.85),
        0 0 18px rgba(255, 120, 120, 0.95);
      filter: brightness(1.12);
    }
    .key-cap-q.press-flash {
      transform: translateY(1px);
      filter: brightness(1.38) saturate(1.15);
      box-shadow:
        0 1px 0 #401010,
        inset 0 1px 0 rgba(255, 240, 240, 0.7),
        0 0 0 2px #ffb8b8,
        0 0 24px rgba(255, 90, 90, 1);
    }
    /* Green — W (matches RGB.g) */
    .key-cap-w {
      background: linear-gradient(180deg, #58f080 0%, #45e565 40%, #108828 100%);
      border: 2px solid #88ffaa;
      color: #f0fff5;
      text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
      box-shadow:
        0 3px 0 #105018,
        inset 0 1px 0 rgba(200, 255, 220, 0.4);
    }
    .key-cap-w.active {
      box-shadow:
        0 3px 0 #105018,
        inset 0 1px 0 rgba(220, 255, 235, 0.55),
        0 0 0 2px rgba(255, 255, 255, 0.85),
        0 0 18px rgba(100, 255, 160, 0.95);
      filter: brightness(1.12);
    }
    .key-cap-w.press-flash {
      transform: translateY(1px);
      filter: brightness(1.38) saturate(1.15);
      box-shadow:
        0 1px 0 #0c4018,
        inset 0 1px 0 rgba(230, 255, 240, 0.7),
        0 0 0 2px #b8ffd0,
        0 0 24px rgba(80, 255, 140, 1);
    }
    /* Blue — E (matches RGB.b) */
    .key-cap-e {
      background: linear-gradient(180deg, #7090ff 0%, #4565ff 40%, #2030b0 100%);
      border: 2px solid #88aaff;
      color: #f4f7ff;
      text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
      box-shadow:
        0 3px 0 #101850,
        inset 0 1px 0 rgba(200, 215, 255, 0.45);
    }
    .key-cap-e.active {
      box-shadow:
        0 3px 0 #101850,
        inset 0 1px 0 rgba(220, 230, 255, 0.55),
        0 0 0 2px rgba(255, 255, 255, 0.85),
        0 0 18px rgba(120, 160, 255, 0.98);
      filter: brightness(1.12);
    }
    .key-cap-e.press-flash {
      transform: translateY(1px);
      filter: brightness(1.38) saturate(1.15);
      box-shadow:
        0 1px 0 #0c1438,
        inset 0 1px 0 rgba(235, 240, 255, 0.7),
        0 0 0 2px #b8c8ff,
        0 0 24px rgba(100, 150, 255, 1);
    }
    #hud {
      position: fixed;
      top: 42px; left: 10px;
      color: #fff;
      font-family: monospace;
      font-size: 14px;
      z-index: 50;
      line-height: 1.5;
      text-shadow: 0 1px 2px #000;
    }
    #game-over {
      position: fixed;
      inset: 0;
      background: rgba(0,0,0,0.85);
      display: none;
      flex-direction: column;
      justify-content: center;
      align-items: center;
      color: #fff;
      font-size: 48px;
      z-index: 150;
    }
    #game-over.show { display: flex; }
    #game-over p { margin: 10px; font-size: 24px; }
    #restart-btn {
      margin-top: 20px;
      padding: 12px 24px;
      font-size: 18px;
      cursor: pointer;
      background: #4a4;
      border: none;
      border-radius: 4px;
      color: #fff;
    }
    #restart-btn:hover { background: #5b5; }
    #levelup-overlay {
      display: none;
      position: fixed;
      inset: 0;
      background: rgba(0,0,0,0.75);
      z-index: 200;
      flex-direction: column;
      justify-content: center;
      align-items: center;
      padding: 24px;
    }
    #levelup-overlay.open { display: flex; }
    #levelup-overlay h2 {
      color: #e8c84a;
      font-family: ui-monospace, monospace;
      font-size: clamp(22px, 4vw, 32px);
      margin-bottom: 24px;
      letter-spacing: 0.02em;
    }
    #levelup-options {
      display: flex;
      flex-wrap: wrap;
      gap: 20px;
      justify-content: center;
      align-items: stretch;
      max-width: min(1100px, 100%);
    }
    .levelup-card {
      width: clamp(200px, 26vw, 260px);
      aspect-ratio: 3 / 4;
      padding: 22px 18px;
      display: flex;
      flex-direction: column;
      justify-content: center;
      align-items: stretch;
      border: 3px solid;
      border-radius: 0;
      font-family: ui-monospace, monospace;
      font-size: 15px;
      line-height: 1.5;
      cursor: pointer;
      text-align: center;
      transition: border-color 0.1s, background-color 0.1s, color 0.1s;
    }
    /* Neutral — white / gray */
    .levelup-card.theme-n {
      background: #1e1e22;
      border-color: #c8c8d0;
      color: #ececf0;
    }
    .levelup-card.theme-n:hover { background: #28282e; border-color: #e8e8ee; }
    .levelup-card.theme-n .up-title { color: #f4f4f8; }
    .levelup-card.theme-n .up-desc { color: #b0b0b8; }
    .levelup-card.theme-n:focus-visible { outline: 2px solid #e8e8ee; outline-offset: 3px; }
    /* Red */
    .levelup-card.theme-r {
      background: #241818;
      border-color: #c04040;
      color: #f0e4e4;
    }
    .levelup-card.theme-r:hover { background: #2e2020; border-color: #e85858; }
    .levelup-card.theme-r .up-title { color: #ff9090; }
    .levelup-card.theme-r .up-desc { color: #c8a8a8; }
    .levelup-card.theme-r:focus-visible { outline: 2px solid #e85858; outline-offset: 3px; }
    /* Green */
    .levelup-card.theme-g {
      background: #142418;
      border-color: #38a858;
      color: #e8f0ea;
    }
    .levelup-card.theme-g:hover { background: #1a3020; border-color: #50d070; }
    .levelup-card.theme-g .up-title { color: #78ffa0; }
    .levelup-card.theme-g .up-desc { color: #a0c8a8; }
    .levelup-card.theme-g:focus-visible { outline: 2px solid #50d070; outline-offset: 3px; }
    /* Blue */
    .levelup-card.theme-b {
      background: #141824;
      border-color: #4068d8;
      color: #e8ecf8;
    }
    .levelup-card.theme-b:hover { background: #1a2030; border-color: #5888ff; }
    .levelup-card.theme-b .up-title { color: #90b8ff; }
    .levelup-card.theme-b .up-desc { color: #a8b8d8; }
    .levelup-card.theme-b:focus-visible { outline: 2px solid #5888ff; outline-offset: 3px; }
  </style>
</head>
<body>
  <div id="xp-bar-wrap">
    <div id="xp-bar-track">
      <div id="xp-bar-fill"></div>
      <span id="xp-bar-meta">Lv 1</span>
    </div>
  </div>
  <div id="stance-key-hud" aria-hidden="true">
    <div class="key-cap key-cap-q" id="key-cap-q" data-code="KeyQ">Q</div>
    <div class="key-cap key-cap-w" id="key-cap-w" data-code="KeyW">W</div>
    <div class="key-cap key-cap-e" id="key-cap-e" data-code="KeyE">E</div>
  </div>
  <canvas id="game" tabindex="0"></canvas>
  <div id="hud"></div>
  <div id="levelup-overlay">
    <h2 id="levelup-title">Level up!</h2>
    <div id="levelup-options"></div>
  </div>
  <div id="game-over">
    <span>Game Over</span>
    <p id="survived-time"></p>
    <button id="restart-btn" type="button">Restart</button>
  </div>

  <script>
    const MELEE_IMPULSE = 120;
    const PBD_ITERATIONS = 4;
    const SPAWN_BUFFER = 100;
    const SPAWN_INTERVAL = 1 / 10;
    const SPAWN_STEP_SECONDS = 30;
    const DESPAWN_MARGIN = 300;
    const CELL_SIZE = 30;

    const MANA_MAX = 100;
    const MANA_REGEN = 3.2;
    const STANCE_COST = 14;

    const XP_BASE = 50;
    const XP_PER_LEVEL = 18;
    function xpToNext(level) {
      return XP_BASE + (level - 1)*(level - 1) * XP_PER_LEVEL;
    }

    const GEM_MAGNET = 220;
    const GEM_PICKUP_BASE = 28;
    const GEM_PULL_SPEED = 380;

    /** Verdant surge — keep in sync with `effectiveMoveSpeed` / `tryStance` green branch. */
    const GREEN_SURGE_SPEED_BONUS_PER_STACK = 0.55;
    const GREEN_SURGE_SECONDS_PER_STACK = 2;

    const UPGRADE_DEFS = [
      {
        id: 'moveSpeed',
        affinity: null,
        title: 'Swift',
        desc: '+5% move speed (per stack).',
        apply() { player.upgrades.moveSpeed++; }
      },
      {
        id: 'greenHeal',
        affinity: 'g',
        title: 'Verdant shift',
        desc: '+5 HP healed when you switch to green stance (per stack).',
        apply() { player.upgrades.greenHeal++; }
      },
      {
        id: 'redNova',
        affinity: 'r',
        title: 'Crimson burst',
        desc: '+10 damage per stack in an area around you when you switch to red (radius grows with stacks).',
        apply() { player.upgrades.redNova++; }
      },
      {
        id: 'blueMana',
        affinity: 'b',
        title: 'Azure flow',
        desc: '+20% mana regen while in blue stance (per stack).',
        apply() { player.upgrades.blueMana++; }
      },
      {
        id: 'maxHp',
        affinity: null,
        title: 'Vitality',
        desc: '+10 max HP (per stack).',
        apply() {
          player.upgrades.maxHp++;
          player.maxHp += 10;
          player.hp += 10;
        }
      },
      {
        id: 'redKillHp',
        affinity: 'r',
        title: 'Sanguine toll',
        desc: '+2 HP when you kill an enemy while in red stance (per stack).',
        apply() { player.upgrades.redKillHp++; }
      },
      {
        id: 'blueKillMana',
        affinity: 'b',
        title: 'Soul siphon',
        desc: '+5 mana when you kill an enemy while in blue stance (per stack).',
        apply() { player.upgrades.blueKillMana++; }
      },
      {
        id: 'greenSurge',
        affinity: 'g',
        title: 'Verdant surge',
        desc: `On switching to green: +${Math.round(GREEN_SURGE_SPEED_BONUS_PER_STACK * 100)}% move speed for ${GREEN_SURGE_SECONDS_PER_STACK}s per stack (while active). Ends if you leave green.`,
        apply() { player.upgrades.greenSurge++; }
      }
    ];

    const DEFAULT_STATS = {
      hp: 100, moveSpeed: 180, size: 16, contactDamage: 0
    };

    const RGB = {
      r: { key: 'r', label: 'R', fill: '#e54545', stroke: '#ff8888' },
      g: { key: 'g', label: 'G', fill: '#45e565', stroke: '#88ffaa' },
      b: { key: 'b', label: 'B', fill: '#4565ff', stroke: '#88aaff' }
    };

    function randomRgb() {
      const k = Math.floor(Math.random() * 3);
      return k === 0 ? 'r' : k === 1 ? 'g' : 'b';
    }

    const ENEMY_DEFS = [
      { id: 'grub', name: 'Grub', points: 1, baseStats: { hp: 10, moveSpeed: 55, size: 24, contactDamage: 5 }, darkness: 0.6, weight: 1 },
      { id: 'slime', name: 'Slime', points: 2, baseStats: { hp: 26, moveSpeed: 30, size: 26, contactDamage: 10 }, darkness: 0.6, weight: 1 },
      { id: 'imp', name: 'Imp', points: 3, baseStats: { hp: 8, moveSpeed: 115, size: 15, contactDamage: 5 }, darkness: 0.5, weight: 1 },
      { id: 'wraith', name: 'Wraith', points: 6, baseStats: { hp: 55, moveSpeed: 100, size: 28, contactDamage: 25 }, darkness: 0.4, weight: 1 },
      { id: 'troll', name: 'Troll', points: 7, baseStats: { hp: 140, moveSpeed: 50, size: 38, contactDamage: 30 }, darkness: 0.8, weight: 1 },
      { id: 'specter', name: 'Specter', points: 8, baseStats: { hp: 92, moveSpeed: 100, size: 26, contactDamage: 30 }, darkness: 0.45, weight: 1 },
      { id: 'beast', name: 'Beast', points: 9, baseStats: { hp: 100, moveSpeed: 95, size: 30, contactDamage: 35 }, darkness: 0.75, weight: 1 },
      { id: 'demon', name: 'Demon', points: 10, baseStats: { hp: 255, moveSpeed: 75, size: 32, contactDamage: 35 }, darkness: 0.8, weight: 1 },
      { id: 'knight', name: 'Dark Knight', points: 17, baseStats: { hp: 420, moveSpeed: 70, size: 36, contactDamage: 60 }, darkness: 0.9, weight: 1 },
      { id: 'imp_summoner', name: 'Imp Summoner', points: 7, baseStats: { hp: 28, moveSpeed: 60, size: 24, contactDamage: 8 }, darkness: 0.6, weight: 1, summoner: { summonDefId: 'imp', count: 3, interval: 5 } },
      { id: 'imp_summoner_summoner', name: 'Imp Summoner Summoner', points: 13, baseStats: { hp: 80, moveSpeed: 35, size: 22, contactDamage: 15 }, darkness: 0.7, weight: 1, summoner: { summonDefId: 'imp_summoner', count: 3, interval: 15 } },
      { id: 'demon_summoner', name: 'Demon Summoner', points: 16, baseStats: { hp: 200, moveSpeed: 50, size: 28, contactDamage: 25 }, darkness: 0.88, weight: 1, summoner: { summonDefId: 'demon', count: 3, interval: 6 } },
      { id: 'demon_summoner_summoner', name: 'Demon Summoner Summoner', points: 20, baseStats: { hp: 450, moveSpeed: 30, size: 20, contactDamage: 35 }, darkness: 0.92, weight: 1, summoner: { summonDefId: 'demon_summoner', count: 3, interval: 18 } },
      { id: 'shooter', name: 'Shooter', points: 5, ranged: true, baseStats: { hp: 10, moveSpeed: 50, size: 14, contactDamage: 0 }, darkness: 0.2, weight: 1, rangedStats: { projSpeed: 175, projDamage: 8, fireRate: 0.277, numProjectiles: 1, projSize: 10 } },
      { id: 'scatter', name: 'Scatter', points: 10, ranged: true, baseStats: { hp: 32, moveSpeed: 55, size: 16, contactDamage: 0 }, darkness: 0.4, weight: 1, rangedStats: { projSpeed: 175, projDamage: 6, fireRate: 0.2, numProjectiles: 3, projSize: 10, spread: 0.25 } },
      { id: 'sniper', name: 'Sniper', points: 13, ranged: true, baseStats: { hp: 60, moveSpeed: 70, size: 14, contactDamage: 0 }, darkness: 0.6, weight: 1, rangedStats: { projSpeed: 600, projDamage: 15, fireRate: 0.3, numProjectiles: 1, projSize: 10 } },
      { id: 'warlock', name: 'Warlock', points: 15, ranged: true, baseStats: { hp: 85, moveSpeed: 60, size: 24, contactDamage: 0 }, darkness: 0.8, weight: 1, rangedStats: { projSpeed: 300, projDamage: 8, fireRate: 0.2, numProjectiles: 5, projSize: 15, spread: 0.35 } }
    ];
    const MAX_ENEMY_RADIUS = ENEMY_DEFS.reduce((m, d) => Math.max(m, d.baseStats?.size ?? 10), 10);

    let canvas, ctx;
    let gameTime = 0;
    let gameOver = false;
    let mouseX = 0, mouseY = 0;
    let mouseWorldX = 0, mouseWorldY = 0;
    let mouseLmbDown = false;
    let lastTime = 0;
    let spawnAccum = 0;
    let stanceFlash = 0;
    let levelUpOpen = false;
    let pendingLevelUps = 0;
    /** @type {{ t: number, maxT: number, radius: number } | null} */
    let crimsonBurstFx = null;
    /** Green stance surge timer (seconds); cleared on leaving green. */
    let greenSurgeTimer = 0;

    const player = {
      x: 0, y: 0, vx: 0, vy: 0,
      dirX: 1, dirY: 0,
      hp: 100, maxHp: 100,
      stats: { ...DEFAULT_STATS },
      rgb: 'r',
      mana: MANA_MAX,
      hitInvuln: 0,
      level: 1,
      xp: 0,
      upgrades: {
        moveSpeed: 0,
        greenHeal: 0,
        redNova: 0,
        blueMana: 0,
        maxHp: 0,
        redKillHp: 0,
        blueKillMana: 0,
        greenSurge: 0
      }
    };

    const enemies = [];
    const projectiles = [];
    const xpGems = [];

    function effectiveMoveSpeed() {
      const base = player.stats.moveSpeed ?? 150;
      const pct = player.upgrades.moveSpeed * 0.05;
      let mult = 1 + pct;
      if (greenSurgeTimer > 0 && player.rgb === 'g' && player.upgrades.greenSurge > 0) {
        mult *= 1 + GREEN_SURGE_SPEED_BONUS_PER_STACK * player.upgrades.greenSurge;
      }
      return base * mult;
    }

    function effectiveManaRegen() {
      let r = MANA_REGEN;
      if (player.rgb === 'b') r *= 1 + player.upgrades.blueMana * 0.2;
      return r;
    }

    function gemPickupRadius() {
      return GEM_PICKUP_BASE;
    }

    function gainXp(amount) {
      player.xp += amount;
      while (player.xp >= xpToNext(player.level)) {
        player.xp -= xpToNext(player.level);
        pendingLevelUps++;
      }
      if (pendingLevelUps > 0 && !levelUpOpen) openLevelUp();
    }

    function pickThreeUpgrades() {
      const pool = [...UPGRADE_DEFS];
      const out = [];
      while (out.length < 3 && pool.length > 0) {
        const i = Math.floor(Math.random() * pool.length);
        out.push(pool.splice(i, 1)[0]);
      }
      return out;
    }

    function openLevelUp() {
      if (pendingLevelUps <= 0) return;
      levelUpOpen = true;
      const overlay = document.getElementById('levelup-overlay');
      const opts = document.getElementById('levelup-options');
      const title = document.getElementById('levelup-title');
      const nextLv = player.level + 1;
      title.textContent = `Level ${nextLv}! Choose an upgrade`;
      opts.innerHTML = '';
      const choices = pickThreeUpgrades();
      for (const u of choices) {
        const card = document.createElement('button');
        card.type = 'button';
        const theme = u.affinity == null ? 'n' : u.affinity;
        card.className = `levelup-card theme-${theme}`;
        card.innerHTML = `<div class="up-title">${u.title}</div><div class="up-desc">${u.desc}</div>`;
        card.addEventListener('click', () => {
          u.apply();
          player.level++;
          pendingLevelUps--;
          overlay.classList.remove('open');
          levelUpOpen = false;
          if (pendingLevelUps > 0) openLevelUp();
        });
        opts.appendChild(card);
      }
      overlay.classList.add('open');
    }

    function applyRedNova() {
      const stacks = player.upgrades.redNova;
      if (stacks <= 0) return;
      // stacks*10 alone is too small to hit nearby foes; base matches readable AOE + VFX ring
      const radius = 52 + stacks * 10;
      const damage = stacks * 10;
      crimsonBurstFx = { t: 0.42, maxT: 0.42, radius };
      for (const e of enemies) {
        if (e.hp <= 0) continue;
        const er = e.stats.size ?? 10;
        if (dist(player.x, player.y, e.x, e.y) <= radius + er) {
          e.hp -= damage;
        }
      }
    }

    function spawnXpGem(x, y, value) {
      xpGems.push({ x, y, value, age: 0 });
    }

    function drawXpGemWorld(gx, gy, time) {
      const a = 12;
      const b = 7;
      const omega = 3.2;
      const colors = ['#e54545', '#45e565', '#4565ff'];
      for (let i = 0; i < 3; i++) {
        const alpha = i * (2 * Math.PI / 3);
        const theta = omega * time + i * (2 * Math.PI / 3);
        const lx = a * Math.cos(theta);
        const ly = b * Math.sin(theta);
        const px = gx + lx * Math.cos(alpha) - ly * Math.sin(alpha);
        const py = gy + lx * Math.sin(alpha) + ly * Math.cos(alpha);
        ctx.fillStyle = colors[i];
        ctx.beginPath();
        ctx.arc(px, py, 6, 0, Math.PI * 2);
        ctx.fill();
        ctx.strokeStyle = 'rgba(0,0,0,0.35)';
        ctx.lineWidth = 1;
        ctx.stroke();
      }
      ctx.fillStyle = 'rgba(255,255,255,0.95)';
      ctx.beginPath();
      ctx.arc(gx, gy, 3.5, 0, Math.PI * 2);
      ctx.fill();
      ctx.strokeStyle = 'rgba(0,0,0,0.5)';
      ctx.lineWidth = 1;
      ctx.stroke();
    }

    const XP_GEM_ARM_SECONDS = 0.14;

    function updateXpGems(dt) {
      const pr = player.stats.size ?? 16;
      const magnet = GEM_MAGNET;
      const pickup = pr + gemPickupRadius();
      const despawnDist = Math.max(canvas.width, canvas.height) / 2 + DESPAWN_MARGIN;
      for (let i = xpGems.length - 1; i >= 0; i--) {
        const g = xpGems[i];
        if (g.age == null) g.age = 0;
        g.age += dt;
        if (dist(player.x, player.y, g.x, g.y) > despawnDist) {
          xpGems.splice(i, 1);
          continue;
        }
        const d = dist(player.x, player.y, g.x, g.y);
        if (g.age >= XP_GEM_ARM_SECONDS && d <= pickup) {
          gainXp(g.value);
          xpGems.splice(i, 1);
          continue;
        }
        if (g.age >= XP_GEM_ARM_SECONDS && d < magnet && d > 1) {
          const sp = GEM_PULL_SPEED * dt;
          const step = Math.min(sp, d - 0.5);
          g.x += ((player.x - g.x) / d) * step;
          g.y += ((player.y - g.y) / d) * step;
        }
      }
    }

    function dist(ax, ay, bx, by) {
      return Math.hypot(bx - ax, by - ay);
    }
    function normalize(x, y) {
      const d = Math.hypot(x, y);
      return d > 0 ? [x / d, y / d] : [1, 0];
    }
    function getMass(entity) {
      const s = entity.stats?.size ?? 10;
      return s * s;
    }
    function weightedPick(arr, weightKey = 'weight') {
      const total = arr.reduce((s, x) => s + (x[weightKey] ?? 1), 0);
      let r = Math.random() * total;
      for (const x of arr) {
        r -= (x[weightKey] ?? 1);
        if (r <= 0) return x;
      }
      return arr[arr.length - 1];
    }
    function getSpawnBudget() {
      return 1 + Math.floor(gameTime / SPAWN_STEP_SECONDS);
    }
    function getTopUnlockedEnemies(budget, count = 1) {
      const unlocked = ENEMY_DEFS.filter(e => e.points <= budget);
      const topTiers = [...new Set(unlocked.map(e => e.points))].sort((a, b) => b - a).slice(0, count);
      return unlocked.filter(e => topTiers.includes(e.points));
    }
    function runSpawner(dt) {
      spawnAccum += dt;
      if (spawnAccum < SPAWN_INTERVAL) return;
      spawnAccum -= SPAWN_INTERVAL;
      const candidates = getTopUnlockedEnemies(getSpawnBudget());
      if (candidates.length === 0) return;
      const def = weightedPick(candidates, 'weight');
      const d = Math.max(canvas.width, canvas.height) / 2 + SPAWN_BUFFER;
      const angle = Math.random() * Math.PI * 2;
      const x = player.x + Math.cos(angle) * d;
      const y = player.y + Math.sin(angle) * d;
      const enemy = {
        x, y, vx: 0, vy: 0,
        defId: def.id,
        def,
        rgb: randomRgb(),
        hp: def.baseStats.hp ?? 20,
        maxHp: def.baseStats.hp ?? 20,
        stats: { ...def.baseStats },
        darkness: def.darkness,
        hitIds: new Set()
      };
      if (def.ranged) enemy.fireCooldown = 0;
      if (def.summoner) enemy.summonCooldown = def.summoner.interval;
      enemies.push(enemy);
    }

    const projectilePool = [];
    function getProjectile() {
      if (projectilePool.length) return projectilePool.pop();
      return { x: 0, y: 0, vx: 0, vy: 0, damage: 0, size: 4, rgb: 'r' };
    }
    function spawnEnemyProjectile(enemy) {
      const def = enemy.def;
      if (!def?.rangedStats) return;
      const rs = def.rangedStats;
      const dx = player.x - enemy.x;
      const dy = player.y - enemy.y;
      const d = Math.hypot(dx, dy);
      if (d <= 0) return;
      const [baseDx, baseDy] = normalize(dx, dy);
      const count = Math.max(1, rs.numProjectiles ?? 1);
      const spread = rs.spread ?? 0;
      for (let i = 0; i < count; i++) {
        let ndx = baseDx, ndy = baseDy;
        if (count > 1 && spread > 0) {
          const angle = Math.atan2(baseDy, baseDx) + (i - (count - 1) / 2) * spread;
          ndx = Math.cos(angle);
          ndy = Math.sin(angle);
        }
        const p = getProjectile();
        p.x = enemy.x + ndx * (enemy.stats.size + 5);
        p.y = enemy.y + ndy * (enemy.stats.size + 5);
        const speed = rs.projSpeed ?? 350;
        p.vx = ndx * speed;
        p.vy = ndy * speed;
        p.damage = rs.projDamage ?? 8;
        p.size = rs.projSize ?? 5;
        p.rgb = enemy.rgb;
        projectiles.push(p);
      }
    }

    function circleOverlap(ax, ay, ar, bx, by, br) {
      return dist(ax, ay, bx, by) < ar + br;
    }
    function resolveCircleCollision(a, b, ar, br) {
      const dx = a.x - b.x;
      const dy = a.y - b.y;
      let d = Math.hypot(dx, dy);
      const overlap = ar + br - d;
      if (overlap <= 0) return;
      let nx = 0;
      let ny = 0;
      if (d <= 1e-6) {
        // If centers are identical, choose any axis so they can still separate.
        const angle = Math.random() * Math.PI * 2;
        nx = Math.cos(angle);
        ny = Math.sin(angle);
        d = 0;
      } else {
        nx = dx / d;
        ny = dy / d;
      }
      const ma = getMass(a);
      const mb = getMass(b);
      const total = ma + mb;
      a.x += nx * overlap * (mb / total);
      a.y += ny * overlap * (mb / total);
      b.x -= nx * overlap * (ma / total);
      b.y -= ny * overlap * (ma / total);
    }

    function wrongColorTouchDamage(e) {
      const c = e.stats.contactDamage ?? 0;
      return c > 0 ? c : 10;
    }

    function updatePhysicsPBD(dt) {
      const pr = player.stats.size ?? 16;
      const pxPrev = player.x, pyPrev = player.y;
      const ePrev = enemies.map(e => ({ x: e.x, y: e.y }));

      const RANGED_RANGE_MIN = 150, RANGED_RANGE_MAX = 250;
      for (const e of enemies) {
        if (e.hp <= 0) continue;
        const def = e.def;
        const dx = player.x - e.x, dy = player.y - e.y;
        const d = Math.hypot(dx, dy);
        if (d > 0) {
          const [nx, ny] = normalize(dx, dy);
          let vx = nx * (e.stats.moveSpeed ?? 80);
          let vy = ny * (e.stats.moveSpeed ?? 80);
          if (def?.ranged) {
            if (d < RANGED_RANGE_MIN) {
              vx = -nx * (e.stats.moveSpeed ?? 80);
              vy = -ny * (e.stats.moveSpeed ?? 80);
            } else if (d > RANGED_RANGE_MAX) {
              vx = nx * (e.stats.moveSpeed ?? 80);
              vy = ny * (e.stats.moveSpeed ?? 80);
            } else {
              vx = vy = 0;
            }
          }
          e.vx = vx;
          e.vy = vy;
        }
      }
      player.x += player.vx * dt;
      player.y += player.vy * dt;
      for (let i = 0; i < enemies.length; i++) {
        if (enemies[i].hp > 0) {
          enemies[i].x += enemies[i].vx * dt;
          enemies[i].y += enemies[i].vy * dt;
        }
      }

      const meleeImpulses = [];
      const meleeDone = new Set();
      const cellSize = CELL_SIZE;
      // Pairs can overlap even when their centers are several cells apart.
      const neighborCellRange = Math.ceil((MAX_ENEMY_RADIUS * 2) / cellSize);
      const getCellCoords = (x, y) => ({
        cx: Math.floor(x / cellSize),
        cy: Math.floor(y / cellSize)
      });
      const getCellKey = (cx, cy) => `${cx},${cy}`;
      let grid = {};
      for (let iter = 0; iter < PBD_ITERATIONS; iter++) {
        grid = {};
        for (let i = 0; i < enemies.length; i++) {
          const e = enemies[i];
          if (e.hp <= 0) continue;
          const { cx, cy } = getCellCoords(e.x, e.y);
          const c = getCellKey(cx, cy);
          if (!grid[c]) grid[c] = [];
          grid[c].push({ e, i });
        }
        for (const e of enemies) {
          if (e.hp <= 0) continue;
          const er = e.stats.size ?? 10;
          if (circleOverlap(player.x, player.y, pr, e.x, e.y, er)) {
            resolveCircleCollision(player, e, pr, er);
            if (!meleeDone.has(e)) {
              if (e.rgb === player.rgb) {
                e.hp = 0;
                meleeDone.add(e);
                const d = dist(player.x, player.y, e.x, e.y);
                if (d > 0) meleeImpulses.push({ e, nx: (player.x - e.x) / d, ny: (player.y - e.y) / d });
              } else if (player.hitInvuln <= 0) {
                player.hp -= wrongColorTouchDamage(e);
                player.hitInvuln = 0.4;
                meleeDone.add(e);
                const d = dist(player.x, player.y, e.x, e.y);
                if (d > 0) meleeImpulses.push({ e, nx: (player.x - e.x) / d, ny: (player.y - e.y) / d });
              }
            }
          }
        }
        for (let i = 0; i < enemies.length; i++) {
          const a = enemies[i];
          if (a.hp <= 0) continue;
          const ar = a.stats.size ?? 10;
          const { cx, cy } = getCellCoords(a.x, a.y);
          const seen = new Set();
          for (let oy = -neighborCellRange; oy <= neighborCellRange; oy++) {
            for (let ox = -neighborCellRange; ox <= neighborCellRange; ox++) {
              const cc = getCellKey(cx + ox, cy + oy);
              if (!grid[cc]) continue;
              for (const { e: b, i: bIdx } of grid[cc]) {
                if (b === a || seen.has(b)) continue;
                if (bIdx <= i) continue;
                if (b.hp <= 0) continue;
                seen.add(b);
                const br = b.stats.size ?? 10;
                if (circleOverlap(a.x, a.y, ar, b.x, b.y, br)) {
                  resolveCircleCollision(a, b, ar, br);
                }
              }
            }
          }
        }
      }

      player.vx = (player.x - pxPrev) / dt;
      player.vy = (player.y - pyPrev) / dt;
      for (let i = 0; i < enemies.length; i++) {
        const e = enemies[i];
        const p = ePrev[i];
        e.vx = (e.x - p.x) / dt;
        e.vy = (e.y - p.y) / dt;
      }
      for (const { e, nx, ny } of meleeImpulses) {
        if (e.hp <= 0) continue;
        const mp = getMass(player);
        const me = getMass(e);
        player.vx += (nx * MELEE_IMPULSE) / mp;
        player.vy += (ny * MELEE_IMPULSE) / mp;
        e.vx -= (nx * MELEE_IMPULSE) / me;
        e.vy -= (ny * MELEE_IMPULSE) / me;
      }
      if (player.hitInvuln > 0) player.hitInvuln -= dt;
    }

    function spawnSummonedEnemy(summoner, summonDefId) {
      const def = ENEMY_DEFS.find(d => d.id === summonDefId);
      if (!def) return;
      const offset = 25 + (summoner.stats.size ?? 10);
      const angle = Math.random() * Math.PI * 2;
      const x = summoner.x + Math.cos(angle) * offset;
      const y = summoner.y + Math.sin(angle) * offset;
      const e = {
        x, y, vx: 0, vy: 0,
        defId: def.id,
        def,
        rgb: randomRgb(),
        hp: def.baseStats.hp ?? 20,
        maxHp: def.baseStats.hp ?? 20,
        stats: { ...def.baseStats },
        darkness: def.darkness,
        hitIds: new Set()
      };
      if (def.ranged) e.fireCooldown = 0;
      if (def.summoner) e.summonCooldown = def.summoner.interval;
      enemies.push(e);
    }
    function updateSummoners(dt) {
      for (const e of enemies) {
        if (e.hp <= 0) continue;
        const def = e.def;
        const sum = def?.summoner;
        if (!sum) continue;
        if (e.summonCooldown == null) e.summonCooldown = sum.interval;
        e.summonCooldown -= dt;
        if (e.summonCooldown <= 0) {
          e.summonCooldown = sum.interval;
          for (let i = 0; i < (sum.count ?? 3); i++) spawnSummonedEnemy(e, sum.summonDefId);
        }
      }
    }
    const RANGED_FIRE_RANGE = 500;
    function updateRangedEnemies(dt) {
      for (const e of enemies) {
        if (e.hp <= 0) continue;
        const def = e.def;
        if (!def?.ranged || !def.rangedStats) continue;
        if (e.fireCooldown != null) e.fireCooldown -= dt;
        if ((e.fireCooldown ?? 0) > 0) continue;
        const d = dist(player.x, player.y, e.x, e.y);
        if (d > RANGED_FIRE_RANGE) continue;
        spawnEnemyProjectile(e);
        e.fireCooldown = 1 / (def.rangedStats.fireRate ?? 1);
      }
    }

    function updateCollisions(dt) {
      const despawnDist = Math.max(canvas.width, canvas.height) / 2 + DESPAWN_MARGIN;
      for (let i = enemies.length - 1; i >= 0; i--) {
        if (dist(player.x, player.y, enemies[i].x, enemies[i].y) > despawnDist) enemies.splice(i, 1);
      }

      updatePhysicsPBD(dt);

      const pr = player.stats.size ?? 16;
      for (let i = projectiles.length - 1; i >= 0; i--) {
        const p = projectiles[i];
        p.x += p.vx * dt;
        p.y += p.vy * dt;
        const margin = 300;
        if (Math.abs(p.x - player.x) > canvas.width + margin || Math.abs(p.y - player.y) > canvas.height + margin) {
          projectiles.splice(i, 1);
          projectilePool.push(p);
          continue;
        }
        if (circleOverlap(p.x, p.y, p.size, player.x, player.y, pr)) {
          if (p.rgb === player.rgb) {
            projectiles.splice(i, 1);
            projectilePool.push(p);
          } else if (player.hitInvuln <= 0) {
            player.hp -= p.damage;
            player.hitInvuln = 0.8;
            projectiles.splice(i, 1);
            projectilePool.push(p);
          }
          continue;
        }
      }

      for (let i = enemies.length - 1; i >= 0; i--) {
        const e = enemies[i];
        if (e.hp <= 0) {
          const def = e.def;
          const v = Math.max(3, Math.floor((def?.points ?? 1) * 2.5));
          if (player.upgrades.redKillHp > 0 && player.rgb === 'r') {
            player.hp = Math.min(player.maxHp, player.hp + player.upgrades.redKillHp * 2);
          }
          if (player.upgrades.blueKillMana > 0 && player.rgb === 'b') {
            player.mana = Math.min(MANA_MAX, player.mana + player.upgrades.blueKillMana * 5);
          }
          spawnXpGem(e.x, e.y, v);
          enemies.splice(i, 1);
        }
      }

      if (stanceFlash > 0) stanceFlash -= dt;
    }

    function seeded(x, y, s) {
      const n = Math.sin(x * 12.9898 + y * 78.233 + (s || 0)) * 43758.5453;
      return n - Math.floor(n);
    }
    function warp(gx, gy, scale, seed) {
      return (seeded(gx * scale, gy * scale, seed) - 0.5) * 2;
    }
    function drawBackgroundDecor(camX, camY, margin) {
      const GRID = 280;
      const JITTER = GRID * 0.1;
      const warpAmt = GRID * 0.4;
      const minGx = Math.floor((camX - margin) / GRID);
      const maxGx = Math.ceil((camX + canvas.width + margin) / GRID);
      const minGy = Math.floor((camY - margin) / GRID);
      const maxGy = Math.ceil((camY + canvas.height + margin) / GRID);
      for (let gx = minGx; gx <= maxGx; gx++) {
        for (let gy = minGy; gy <= maxGy; gy++) {
          const wx = gx * GRID + GRID / 2
            + warp(gx, gy, 0.2, 1) * warpAmt
            + (seeded(gx, gy, 3) - 0.5) * 2 * JITTER;
          const wy = gy * GRID + GRID / 2
            + warp(gx, gy, 0.2, 2) * warpAmt
            + (seeded(gx, gy, 4) - 0.5) * 2 * JITTER;
          const r = seeded(gx, gy, 5);
          if (r < 0.75) {
            ctx.fillStyle = '#2d5016';
            for (let b = 0; b < 3; b++) {
              const splay = (b - 1) * 0.35;
              const h = 14;
              const lean = Math.sin(splay) * h * 0.4;
              const baseX = wx + (b - 1) * 2;
              const tipX = baseX + lean;
              const tipY = wy - h;
              const baseW = 1.2;
              ctx.beginPath();
              ctx.moveTo(baseX - baseW, wy);
              ctx.lineTo(tipX, tipY);
              ctx.lineTo(baseX + baseW, wy);
              ctx.closePath();
              ctx.fill();
            }
          } else {
            const w = 20;
            const h = 6;
            ctx.fillStyle = '#4a4a4a';
            ctx.beginPath();
            ctx.moveTo(wx - w / 2, wy);
            ctx.lineTo(wx - w * 0.2, wy - h * 0.65);
            ctx.lineTo(wx + w * 0.15, wy - h);
            ctx.lineTo(wx + w / 2, wy);
            ctx.closePath();
            ctx.fill();
          }
        }
      }
    }
    function drawGround() {
      ctx.fillStyle = '#3d6b2f';
      ctx.fillRect(-10000, -10000, 20000, 20000);
    }

    function rgbEnemyFill(rgb, darkness) {
      const base = RGB[rgb].fill;
      const r = parseInt(base.slice(1, 3), 16);
      const g = parseInt(base.slice(3, 5), 16);
      const b = parseInt(base.slice(5, 7), 16);
      const dim = 0.35 + (1 - darkness) * 0.45;
      return `rgb(${Math.round(r * dim)},${Math.round(g * dim)},${Math.round(b * dim)})`;
    }

    function render() {
      ctx.fillStyle = '#3d6b2f';
      ctx.fillRect(0, 0, canvas.width, canvas.height);
      ctx.save();
      ctx.translate(canvas.width / 2 - player.x, canvas.height / 2 - player.y);
      drawGround();
      const camX = player.x - canvas.width / 2;
      const camY = player.y - canvas.height / 2;
      const margin = 100;
      drawBackgroundDecor(camX, camY, margin);

      for (const e of enemies) {
        if (e.hp <= 0) continue;
        if (e.x < camX - margin || e.x > camX + canvas.width + margin || e.y < camY - margin || e.y > camY + canvas.height + margin) continue;
        const r = e.stats.size ?? 10;
        if (e.hp < e.maxHp) {
          const barW = r * 2.5;
          const barH = 4;
          const barY = e.y - r - barH - 4;
          ctx.fillStyle = '#333';
          ctx.fillRect(e.x - barW / 2, barY, barW, barH);
          ctx.fillStyle = e.hp > e.maxHp * 0.3 ? '#4a4' : '#a44';
          ctx.fillRect(e.x - barW / 2, barY, barW * (e.hp / e.maxHp), barH);
          ctx.strokeStyle = '#555';
          ctx.strokeRect(e.x - barW / 2, barY, barW, barH);
        }
        ctx.beginPath();
        ctx.arc(e.x, e.y, r, 0, Math.PI * 2);
        ctx.fillStyle = rgbEnemyFill(e.rgb, e.darkness);
        ctx.fill();
        ctx.strokeStyle = RGB[e.rgb].stroke;
        ctx.lineWidth = 3;
        ctx.stroke();
        ctx.lineWidth = 1;
      }

      for (const p of projectiles) {
        ctx.fillStyle = RGB[p.rgb].fill;
        ctx.beginPath();
        ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
        ctx.fill();
        ctx.strokeStyle = '#000';
        ctx.lineWidth = 2;
        ctx.stroke();
      }

      const pc = RGB[player.rgb];
      const hurt = player.hitInvuln > 0;
      ctx.fillStyle = hurt ? '#ffffff' : pc.fill;
      if (stanceFlash > 0) {
        ctx.globalAlpha = 0.5 + 0.5 * Math.sin(stanceFlash * 40);
      }
      ctx.beginPath();
      ctx.arc(player.x, player.y, player.stats.size, 0, Math.PI * 2);
      ctx.fill();
      ctx.globalAlpha = 1;
      ctx.strokeStyle = hurt ? '#f0f0f0' : pc.stroke;
      ctx.lineWidth = 3;
      ctx.stroke();
      ctx.lineWidth = 1;

      const pr = player.stats.size ?? 16;
      const barW = pr * 5;
      const barH = 6;
      const pad = 4;
      const gap = 2;
      const manaY = player.y - pr - barH - pad;
      const hpY = manaY - barH - gap;
      ctx.fillStyle = '#333';
      ctx.fillRect(player.x - barW / 2, hpY, barW, barH);
      ctx.fillStyle = player.hp > player.maxHp * 0.3 ? '#4a4' : '#a44';
      ctx.fillRect(player.x - barW / 2, hpY, barW * (player.hp / player.maxHp), barH);
      ctx.strokeStyle = '#555';
      ctx.strokeRect(player.x - barW / 2, hpY, barW, barH);
      const manaLow = player.mana < STANCE_COST;
      ctx.fillStyle = manaLow ? '#2a1818' : '#333';
      ctx.fillRect(player.x - barW / 2, manaY, barW, barH);
      ctx.fillStyle = manaLow ? '#d04040' : '#48a8ff';
      ctx.fillRect(player.x - barW / 2, manaY, barW * (player.mana / MANA_MAX), barH);
      ctx.strokeStyle = manaLow ? '#882828' : '#555';
      ctx.strokeRect(player.x - barW / 2, manaY, barW, barH);

      if (crimsonBurstFx) {
        const fx = crimsonBurstFx;
        const life = fx.t / fx.maxT;
        const r = fx.radius;
        ctx.save();
        ctx.globalCompositeOperation = 'lighter';
        ctx.fillStyle = `rgba(255, 80, 60, ${0.14 * life})`;
        ctx.beginPath();
        ctx.arc(player.x, player.y, r, 0, Math.PI * 2);
        ctx.fill();
        ctx.strokeStyle = `rgba(255, 140, 110, ${0.35 + 0.45 * life})`;
        ctx.lineWidth = 3;
        ctx.beginPath();
        ctx.arc(player.x, player.y, r, 0, Math.PI * 2);
        ctx.stroke();
        ctx.restore();
      }

      for (const g of xpGems) {
        if (g.x < camX - margin || g.x > camX + canvas.width + margin || g.y < camY - margin || g.y > camY + canvas.height + margin) continue;
        drawXpGemWorld(g.x, g.y, gameTime);
      }

      ctx.restore();

      const xpNeed = xpToNext(player.level);
      const xpPct = xpNeed > 0 ? Math.min(100, (player.xp / xpNeed) * 100) : 100;
      document.getElementById('xp-bar-fill').style.width = xpPct + '%';
      document.getElementById('xp-bar-meta').textContent =
        `Lv ${player.level}` + (pendingLevelUps > 0 ? '  ·  +' + pendingLevelUps : '');

      document.getElementById('hud').textContent = `Time ${Math.floor(gameTime)}s`;

      const kq = document.getElementById('key-cap-q');
      const kw = document.getElementById('key-cap-w');
      const ke = document.getElementById('key-cap-e');
      if (kq && kw && ke) {
        kq.classList.remove('active');
        kw.classList.remove('active');
        ke.classList.remove('active');
        if (player.rgb === 'r') kq.classList.add('active');
        else if (player.rgb === 'g') kw.classList.add('active');
        else if (player.rgb === 'b') ke.classList.add('active');
      }

      if (gameOver) {
        document.getElementById('game-over').classList.add('show');
        document.getElementById('survived-time').textContent = `Survived ${Math.floor(gameTime)} seconds`;
      }
    }

    function updateInput() {
      const speed = effectiveMoveSpeed();
      if (mouseLmbDown) {
        const tox = mouseWorldX - player.x;
        const toy = mouseWorldY - player.y;
        const d = Math.hypot(tox, toy);
        if (d > 4) {
          player.dirX = tox / d;
          player.dirY = toy / d;
          player.vx = player.dirX * speed;
          player.vy = player.dirY * speed;
        } else {
          player.vx = 0;
          player.vy = 0;
        }
      } else {
        player.vx = 0;
        player.vy = 0;
      }
    }

    function flashKeyCap(code) {
      const id = code === 'KeyQ' ? 'key-cap-q' : code === 'KeyW' ? 'key-cap-w' : code === 'KeyE' ? 'key-cap-e' : null;
      if (!id) return;
      const el = document.getElementById(id);
      if (!el) return;
      el.classList.add('press-flash');
      clearTimeout(el._pressFlashT);
      el._pressFlashT = setTimeout(() => {
        el.classList.remove('press-flash');
      }, 110);
    }

    function tryStance(targetRgb) {
      if (levelUpOpen) return;
      if (targetRgb === player.rgb) return;
      if (player.mana < STANCE_COST) {
        stanceFlash = 0.15;
        return;
      }
      player.mana -= STANCE_COST;
      player.rgb = targetRgb;
      stanceFlash = 0.12;
      if (targetRgb === 'g') {
        if (player.upgrades.greenSurge > 0) {
          greenSurgeTimer = GREEN_SURGE_SECONDS_PER_STACK * player.upgrades.greenSurge;
        }
        if (player.upgrades.greenHeal > 0) {
          player.hp = Math.min(player.maxHp, player.hp + player.upgrades.greenHeal * 5);
        }
      }
      if (targetRgb === 'r' && player.upgrades.redNova > 0) {
        applyRedNova();
      }
    }

    function update(dt) {
      if (gameOver) return;
      if (crimsonBurstFx) {
        crimsonBurstFx.t -= dt;
        if (crimsonBurstFx.t <= 0) crimsonBurstFx = null;
      }
      if (levelUpOpen) {
        render();
        requestAnimationFrame(gameLoop);
        return;
      }
      gameTime += dt;
      if (greenSurgeTimer > 0) {
        if (player.rgb !== 'g') greenSurgeTimer = 0;
        else greenSurgeTimer -= dt;
      }
      updateInput();
      player.mana = Math.min(MANA_MAX, player.mana + effectiveManaRegen() * dt);
      player.hp = Math.min(player.maxHp, player.hp + 0.5 * dt);
      runSpawner(dt);
      updateSummoners(dt);
      updateRangedEnemies(dt);
      updateCollisions(dt);
      updateXpGems(dt);
      if (player.hp <= 0) gameOver = true;
      render();
      requestAnimationFrame(gameLoop);
    }
    function gameLoop(now) {
      const dt = Math.min(0.05, (now - lastTime) / 1000);
      lastTime = now;
      update(dt);
    }

    function init() {
      canvas = document.getElementById('game');
      ctx = canvas.getContext('2d');
      canvas.width = window.innerWidth;
      canvas.height = window.innerHeight;
      window.addEventListener('resize', () => {
        canvas.width = window.innerWidth;
        canvas.height = window.innerHeight;
      });
      document.addEventListener('keydown', e => {
        if (levelUpOpen) return;
        if (e.code === 'KeyQ') { e.preventDefault(); flashKeyCap('KeyQ'); tryStance('r'); }
        else if (e.code === 'KeyW') { e.preventDefault(); flashKeyCap('KeyW'); tryStance('g'); }
        else if (e.code === 'KeyE') { e.preventDefault(); flashKeyCap('KeyE'); tryStance('b'); }
      });
      window.addEventListener('blur', () => { mouseLmbDown = false; });
      canvas.addEventListener('mousemove', e => {
        const rect = canvas.getBoundingClientRect();
        mouseX = e.clientX - rect.left;
        mouseY = e.clientY - rect.top;
        mouseWorldX = player.x - canvas.width / 2 + mouseX;
        mouseWorldY = player.y - canvas.height / 2 + mouseY;
      });
      canvas.addEventListener('mousedown', e => { if (e.button === 0) mouseLmbDown = true; });
      canvas.addEventListener('contextmenu', e => e.preventDefault());
      window.addEventListener('mouseup', e => { if (e.button === 0) mouseLmbDown = false; });
      document.getElementById('restart-btn').addEventListener('click', () => {
        gameOver = false;
        gameTime = 0;
        document.getElementById('game-over').classList.remove('show');
        document.getElementById('levelup-overlay').classList.remove('open');
        levelUpOpen = false;
        pendingLevelUps = 0;
        player.x = 0; player.y = 0; player.vx = 0; player.vy = 0;
        player.hp = 100; player.maxHp = 100;
        player.rgb = 'r';
        player.mana = MANA_MAX;
        player.hitInvuln = 0;
        stanceFlash = 0;
        crimsonBurstFx = null;
        greenSurgeTimer = 0;
        player.level = 1;
        player.xp = 0;
        player.upgrades = {
          moveSpeed: 0, greenHeal: 0, redNova: 0, blueMana: 0, maxHp: 0,
          redKillHp: 0, blueKillMana: 0, greenSurge: 0
        };
        enemies.length = 0;
        projectiles.length = 0;
        xpGems.length = 0;
        spawnAccum = 0;
        lastTime = performance.now();
        requestAnimationFrame(gameLoop);
      });
      canvas.focus();
      lastTime = performance.now();
      requestAnimationFrame(gameLoop);
    }
    init();
  </script>
</body>
</html>

remixes

no remixes yet... email to remix@gameslop.net