← RPG results

RPG

Grok 4.5 · full

Initial view of RPG

Reviews & generation details

Reviews

No human review yet. Unreviewed does not mean unsuccessful.

Generation context

Imported historical artifact. Its exact original instructions and lineage are not known.

Generation prompt · exact text unavailable

See the current prompt for reference. It may differ from the instructions used to generate this artifact.

Inspect original source 101,976 bytes · SHA-256 084ca5543a9e
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Ember Path — Action RPG</title>
<style>
  @import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&family=Share+Tech+Mono&display=swap');

  :root {
    --bg: #0b0e14;
    --panel: #141a24;
    --panel-border: #2a3548;
    --accent: #e8a838;
    --accent2: #4ecdc4;
    --danger: #e74c3c;
    --text: #e8eef7;
    --muted: #7a8899;
    --slot: #1c2433;
    --hp: #e74c3c;
    --hp-half: #c0392b;
    --hp-empty: #3a2222;
    --gold: #f1c40f;
  }

  * { box-sizing: border-box; margin: 0; padding: 0; }

  html, body {
    width: 100%; height: 100%;
    background: var(--bg);
    color: var(--text);
    font-family: 'Share Tech Mono', monospace;
    overflow: hidden;
    user-select: none;
  }

  #app {
    display: grid;
    grid-template-rows: auto 1fr auto;
    height: 100%;
    max-width: 1200px;
    margin: 0 auto;
  }

  header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 8px 14px;
    background: linear-gradient(180deg, #121824, #0d121a);
    border-bottom: 2px solid var(--panel-border);
    gap: 12px;
    flex-wrap: wrap;
  }

  .brand {
    font-family: 'Press Start 2P', monospace;
    font-size: 12px;
    color: var(--accent);
    letter-spacing: 1px;
    text-shadow: 0 0 12px rgba(232,168,56,0.45);
  }

  .hud-stats {
    display: flex;
    align-items: center;
    gap: 18px;
    flex-wrap: wrap;
  }

  .stat {
    display: flex;
    align-items: center;
    gap: 8px;
    font-size: 13px;
  }

  .stat label { color: var(--muted); font-size: 11px; letter-spacing: 1px; }

  #hearts {
    display: flex;
    gap: 4px;
    align-items: center;
  }

  .heart {
    width: 18px; height: 16px;
    position: relative;
    display: inline-block;
  }
  .heart::before, .heart::after {
    content: '';
    position: absolute;
    width: 10px; height: 16px;
    background: var(--hp-empty);
    border-radius: 10px 10px 0 0;
    top: 0;
  }
  .heart::before { left: 0; transform: rotate(-45deg); transform-origin: bottom right; }
  .heart::after  { right: 0; transform: rotate(45deg);  transform-origin: bottom left; }
  .heart.full::before, .heart.full::after { background: var(--hp); box-shadow: 0 0 6px rgba(231,76,60,0.6); }
  .heart.half::before { background: var(--hp); }
  .heart.half::after { background: var(--hp-empty); }

  #gold-display { color: var(--gold); font-weight: bold; }
  #weapon-display { color: var(--accent2); }
  #zone-display { color: var(--accent); }

  #game-wrap {
    position: relative;
    display: flex;
    align-items: center;
    justify-content: center;
    background: #06080c;
    overflow: hidden;
    min-height: 0;
  }

  canvas#game {
    display: block;
    image-rendering: pixelated;
    image-rendering: crisp-edges;
    background: #1a2030;
    box-shadow: 0 0 0 3px #2a3548, 0 10px 40px rgba(0,0,0,0.6);
    max-width: 100%;
    max-height: 100%;
    cursor: crosshair;
  }

  /* Overlays */
  .overlay {
    position: absolute;
    inset: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    background: rgba(6,8,12,0.86);
    z-index: 20;
    backdrop-filter: blur(3px);
  }
  .overlay.hidden { display: none; }

  .panel {
    background: var(--panel);
    border: 2px solid var(--panel-border);
    border-radius: 10px;
    padding: 28px 32px;
    max-width: 560px;
    width: 92%;
    box-shadow: 0 20px 60px rgba(0,0,0,0.55), inset 0 1px 0 rgba(255,255,255,0.04);
  }
  .panel h1 {
    font-family: 'Press Start 2P', monospace;
    font-size: 18px;
    color: var(--accent);
    margin-bottom: 14px;
    line-height: 1.5;
  }
  .panel h2 {
    font-family: 'Press Start 2P', monospace;
    font-size: 13px;
    color: var(--accent2);
    margin-bottom: 12px;
  }
  .panel p { color: var(--muted); line-height: 1.6; margin-bottom: 10px; font-size: 14px; }
  .panel .story { color: var(--text); margin-bottom: 16px; }

  .btn-row { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 18px; }

  button.btn {
    font-family: 'Press Start 2P', monospace;
    font-size: 10px;
    padding: 12px 16px;
    background: linear-gradient(180deg, #2a3a28, #1a2818);
    color: #b8e0a0;
    border: 2px solid #4a6a40;
    border-radius: 6px;
    cursor: pointer;
    transition: transform 0.08s, box-shadow 0.08s, filter 0.08s;
  }
  button.btn:hover { filter: brightness(1.15); box-shadow: 0 0 16px rgba(78,205,100,0.25); }
  button.btn:active { transform: translateY(1px); }
  button.btn.primary {
    background: linear-gradient(180deg, #6a4a18, #4a3210);
    border-color: #c4a040;
    color: #ffe9a0;
  }
  button.btn.danger {
    background: linear-gradient(180deg, #4a2020, #301414);
    border-color: #a04040;
    color: #f0a0a0;
  }
  button.btn:disabled {
    opacity: 0.4;
    cursor: not-allowed;
    filter: none;
    box-shadow: none;
  }

  /* Inventory / Shop panels */
  .inv-grid {
    display: grid;
    grid-template-columns: repeat(5, 1fr);
    gap: 8px;
    margin: 12px 0;
  }
  .inv-slot {
    aspect-ratio: 1;
    background: var(--slot);
    border: 2px solid var(--panel-border);
    border-radius: 6px;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    font-size: 22px;
    cursor: pointer;
    position: relative;
    transition: border-color 0.12s, background 0.12s;
  }
  .inv-slot:hover, .inv-slot.selected {
    border-color: var(--accent);
    background: #243044;
  }
  .inv-slot .qty {
    position: absolute;
    right: 4px; bottom: 2px;
    font-size: 11px;
    color: var(--text);
  }
  .inv-slot.empty { opacity: 0.45; cursor: default; }
  .inv-detail {
    min-height: 64px;
    background: #0f141c;
    border: 1px solid var(--panel-border);
    border-radius: 6px;
    padding: 10px 12px;
    font-size: 13px;
    color: var(--muted);
  }
  .inv-detail strong { color: var(--text); display: block; margin-bottom: 4px; }

  .shop-list { display: flex; flex-direction: column; gap: 8px; max-height: 280px; overflow-y: auto; }
  .shop-item {
    display: grid;
    grid-template-columns: 36px 1fr auto auto;
    gap: 10px;
    align-items: center;
    background: var(--slot);
    border: 1px solid var(--panel-border);
    border-radius: 6px;
    padding: 8px 10px;
  }
  .shop-item .ico { font-size: 22px; text-align: center; }
  .shop-item .nm { color: var(--text); font-size: 13px; }
  .shop-item .ds { color: var(--muted); font-size: 11px; }
  .shop-item .pr { color: var(--gold); font-size: 13px; min-width: 52px; text-align: right; }

  /* Bottom bar */
  footer {
    background: linear-gradient(0deg, #121824, #0d121a);
    border-top: 2px solid var(--panel-border);
    padding: 8px 14px;
    display: grid;
    grid-template-columns: 1fr auto;
    gap: 12px;
    align-items: center;
  }

  .hotbar {
    display: flex;
    gap: 6px;
    align-items: center;
  }
  .hot-slot {
    width: 42px; height: 42px;
    background: var(--slot);
    border: 2px solid var(--panel-border);
    border-radius: 6px;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 20px;
    position: relative;
  }
  .hot-slot .key {
    position: absolute;
    top: -2px; left: 3px;
    font-size: 9px;
    color: var(--muted);
  }
  .hot-slot.active { border-color: var(--accent); box-shadow: 0 0 10px rgba(232,168,56,0.35); }

  .controls-hint {
    font-size: 11px;
    color: var(--muted);
    text-align: right;
    line-height: 1.5;
  }
  .controls-hint kbd {
    background: #1c2433;
    border: 1px solid #3a4558;
    border-radius: 3px;
    padding: 1px 5px;
    color: var(--text);
    font-family: inherit;
    font-size: 10px;
  }

  #toast {
    position: absolute;
    top: 18px;
    left: 50%;
    transform: translateX(-50%);
    background: rgba(20,26,36,0.94);
    border: 1px solid var(--accent);
    color: var(--text);
    padding: 10px 18px;
    border-radius: 8px;
    font-size: 13px;
    z-index: 30;
    opacity: 0;
    pointer-events: none;
    transition: opacity 0.25s, transform 0.25s;
    box-shadow: 0 8px 24px rgba(0,0,0,0.4);
    max-width: 80%;
    text-align: center;
  }
  #toast.show {
    opacity: 1;
    transform: translateX(-50%) translateY(4px);
  }

  #dialogue {
    position: absolute;
    bottom: 24px;
    left: 50%;
    transform: translateX(-50%);
    width: min(560px, 90%);
    background: rgba(14,18,28,0.95);
    border: 2px solid var(--accent2);
    border-radius: 10px;
    padding: 14px 16px;
    z-index: 25;
    display: none;
  }
  #dialogue.show { display: block; }
  #dialogue .who {
    font-family: 'Press Start 2P', monospace;
    font-size: 10px;
    color: var(--accent2);
    margin-bottom: 8px;
  }
  #dialogue .txt { font-size: 14px; line-height: 1.5; color: var(--text); }
  #dialogue .cont {
    margin-top: 8px;
    font-size: 11px;
    color: var(--muted);
    text-align: right;
  }

  #minimap {
    position: absolute;
    top: 12px;
    right: 12px;
    width: 120px;
    height: 90px;
    background: rgba(10,14,22,0.82);
    border: 2px solid var(--panel-border);
    border-radius: 6px;
    z-index: 10;
    image-rendering: pixelated;
  }

  #quest {
    position: absolute;
    top: 12px;
    left: 12px;
    background: rgba(10,14,22,0.82);
    border: 1px solid var(--panel-border);
    border-radius: 6px;
    padding: 8px 10px;
    z-index: 10;
    max-width: 220px;
    font-size: 11px;
  }
  #quest .qt {
    font-family: 'Press Start 2P', monospace;
    font-size: 8px;
    color: var(--accent);
    margin-bottom: 6px;
  }
  #quest .qd { color: var(--muted); line-height: 1.4; }
  #quest .qd b { color: var(--text); }

  .dmg-flash {
    position: absolute;
    inset: 0;
    background: rgba(200,30,30,0.22);
    pointer-events: none;
    opacity: 0;
    z-index: 15;
    transition: opacity 0.1s;
  }
  .dmg-flash.on { opacity: 1; }

  @media (max-width: 700px) {
    .brand { font-size: 9px; }
    .controls-hint { display: none; }
    header { padding: 6px 8px; }
    .stat { font-size: 11px; }
  }
</style>
</head>
<body>
<div id="app">
  <header>
    <div class="brand">EMBER PATH</div>
    <div class="hud-stats">
      <div class="stat"><label>HP</label><div id="hearts"></div></div>
      <div class="stat"><label>GOLD</label><span id="gold-display">0</span></div>
      <div class="stat"><label>WEAPON</label><span id="weapon-display">Oak Blade</span></div>
      <div class="stat"><label>ZONE</label><span id="zone-display">Village</span></div>
    </div>
  </header>

  <div id="game-wrap">
    <canvas id="game" width="960" height="540"></canvas>
    <canvas id="minimap" width="120" height="90"></canvas>
    <div id="quest">
      <div class="qt">QUEST</div>
      <div class="qd" id="quest-text">Find Elder Myra in the village.</div>
    </div>
    <div id="toast"></div>
    <div id="dialogue"><div class="who" id="dlg-who"></div><div class="txt" id="dlg-txt"></div><div class="cont">[E / Click] continue</div></div>
    <div class="dmg-flash" id="dmg-flash"></div>

    <!-- Title -->
    <div class="overlay" id="overlay-title">
      <div class="panel">
        <h1>EMBER PATH</h1>
        <p class="story">The Ashen Seal shattered. Shadows pour from the Hollow Spire. You are the last Pathwalker — blade in hand, embers in your blood.</p>
        <p>Explore the wilds, forge your weapon, unlock forgotten arts, and seal the breach.</p>
        <p style="color:var(--accent2);font-size:12px;">~ 45–60 min · Save at stone circles · Shop in the village</p>
        <div class="btn-row">
          <button class="btn primary" id="btn-new">NEW JOURNEY</button>
          <button class="btn" id="btn-continue" disabled>CONTINUE</button>
        </div>
      </div>
    </div>

    <!-- Pause / Inventory -->
    <div class="overlay hidden" id="overlay-inv">
      <div class="panel" style="max-width:520px">
        <h2>INVENTORY</h2>
        <div class="inv-grid" id="inv-grid"></div>
        <div class="inv-detail" id="inv-detail">Select an item.</div>
        <div class="btn-row">
          <button class="btn primary" id="btn-use-item">USE / EQUIP</button>
          <button class="btn" id="btn-close-inv">CLOSE (I / ESC)</button>
        </div>
      </div>
    </div>

    <!-- Shop -->
    <div class="overlay hidden" id="overlay-shop">
      <div class="panel" style="max-width:540px">
        <h2>MYRA'S EMPORIUM</h2>
        <p style="margin-bottom:8px">Gold: <span id="shop-gold" style="color:var(--gold)">0</span></p>
        <div class="shop-list" id="shop-list"></div>
        <div class="btn-row">
          <button class="btn" id="btn-close-shop">LEAVE SHOP</button>
        </div>
      </div>
    </div>

    <!-- Pause menu -->
    <div class="overlay hidden" id="overlay-pause">
      <div class="panel">
        <h2>PAUSED</h2>
        <p>Progress saves only at glowing Save Stones.</p>
        <div class="btn-row">
          <button class="btn primary" id="btn-resume">RESUME</button>
          <button class="btn" id="btn-save-manual" title="Only works near a save stone">SAVE (need stone)</button>
          <button class="btn danger" id="btn-to-title">TITLE SCREEN</button>
        </div>
      </div>
    </div>

    <!-- Victory / Defeat -->
    <div class="overlay hidden" id="overlay-end">
      <div class="panel">
        <h1 id="end-title">VICTORY</h1>
        <p class="story" id="end-text"></p>
        <div class="btn-row">
          <button class="btn primary" id="btn-end-title">TITLE SCREEN</button>
        </div>
      </div>
    </div>
  </div>

  <footer>
    <div class="hotbar">
      <div class="hot-slot active" id="hot-melee" title="Melee"><span class="key">1</span><span id="hot-melee-ico">🗡</span></div>
      <div class="hot-slot" id="hot-ranged" title="Ranged"><span class="key">2</span><span id="hot-ranged-ico">—</span></div>
      <div class="hot-slot" id="hot-item" title="Quick item (Q)"><span class="key">Q</span><span id="hot-item-ico">+</span></div>
      <div class="hot-slot" id="hot-bomb" title="Bomb (F)"><span class="key">F</span>💣</div>
    </div>
    <div class="controls-hint">
      <kbd>WASD</kbd> move · <kbd>Space/Click</kbd> attack · <kbd>Shift</kbd> dash ·
      <kbd>E</kbd> interact · <kbd>I</kbd> inventory · <kbd>1/2</kbd> weapons · <kbd>Esc</kbd> pause
    </div>
  </footer>
</div>

<script>
(() => {
  'use strict';

  // ============================================================
  // AUDIO (WebAudio synthesized SFX)
  // ============================================================
  const AudioFX = (() => {
    let ctx = null;
    const ensure = () => {
      if (!ctx) ctx = new (window.AudioContext || window.webkitAudioContext)();
      if (ctx.state === 'suspended') ctx.resume();
      return ctx;
    };
    const tone = (freq, dur, type = 'square', gain = 0.06, slide = 0) => {
      try {
        const c = ensure();
        const t0 = c.currentTime;
        const o = c.createOscillator();
        const g = c.createGain();
        o.type = type;
        o.frequency.setValueAtTime(freq, t0);
        if (slide) o.frequency.exponentialRampToValueAtTime(Math.max(40, freq + slide), t0 + dur);
        g.gain.setValueAtTime(gain, t0);
        g.gain.exponentialRampToValueAtTime(0.001, t0 + dur);
        o.connect(g); g.connect(c.destination);
        o.start(t0); o.stop(t0 + dur + 0.02);
      } catch (_) {}
    };
    const noise = (dur, gain = 0.04, bp = 800) => {
      try {
        const c = ensure();
        const t0 = c.currentTime;
        const n = c.createBufferSource();
        const buf = c.createBuffer(1, c.sampleRate * dur, c.sampleRate);
        const d = buf.getChannelData(0);
        for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
        n.buffer = buf;
        const f = c.createBiquadFilter();
        f.type = 'bandpass'; f.frequency.value = bp;
        const g = c.createGain();
        g.gain.setValueAtTime(gain, t0);
        g.gain.exponentialRampToValueAtTime(0.001, t0 + dur);
        n.connect(f); f.connect(g); g.connect(c.destination);
        n.start(t0); n.stop(t0 + dur);
      } catch (_) {}
    };
    return {
      resume: ensure,
      step: () => tone(120 + Math.random() * 40, 0.04, 'triangle', 0.02),
      swing: () => { tone(280, 0.07, 'sawtooth', 0.05, -120); noise(0.06, 0.03, 1200); },
      hit: () => { tone(180, 0.09, 'square', 0.07, -80); noise(0.08, 0.05, 600); },
      hurt: () => { tone(140, 0.15, 'sawtooth', 0.08, -90); tone(90, 0.18, 'square', 0.05); },
      pickup: () => { tone(520, 0.06, 'sine', 0.05); setTimeout(() => tone(780, 0.08, 'sine', 0.05), 50); },
      coin: () => { tone(880, 0.05, 'square', 0.04); setTimeout(() => tone(1175, 0.08, 'square', 0.04), 40); },
      shop: () => { tone(440, 0.08, 'triangle', 0.05); tone(554, 0.1, 'triangle', 0.04); },
      buy: () => { tone(660, 0.07, 'sine', 0.05); setTimeout(() => tone(880, 0.1, 'sine', 0.05), 60); },
      save: () => { tone(392, 0.1, 'sine', 0.05); setTimeout(() => tone(523, 0.12, 'sine', 0.05), 90); setTimeout(() => tone(659, 0.16, 'sine', 0.05), 180); },
      dash: () => noise(0.1, 0.04, 400),
      shoot: () => { tone(640, 0.05, 'square', 0.05, -300); noise(0.05, 0.03, 2000); },
      explosion: () => { noise(0.25, 0.09, 200); tone(80, 0.3, 'sawtooth', 0.08, -40); },
      heal: () => { tone(523, 0.08, 'sine', 0.05); setTimeout(() => tone(659, 0.08, 'sine', 0.05), 70); setTimeout(() => tone(784, 0.12, 'sine', 0.05), 140); },
      levelup: () => { [523,659,784,1046].forEach((f,i) => setTimeout(() => tone(f, 0.12, 'triangle', 0.06), i * 90)); },
      door: () => { tone(200, 0.1, 'triangle', 0.05); noise(0.12, 0.03, 300); },
      puzzle: () => { tone(440, 0.08, 'sine', 0.05); setTimeout(() => tone(660, 0.12, 'sine', 0.05), 100); },
      death: () => { tone(220, 0.4, 'sawtooth', 0.08, -160); noise(0.4, 0.05, 150); },
      boss: () => { tone(55, 0.4, 'square', 0.08); noise(0.3, 0.06, 100); },
      ui: () => tone(500, 0.04, 'sine', 0.03),
    };
  })();

  // ============================================================
  // CONSTANTS & DATA
  // ============================================================
  const TILE = 48;
  const VIEW_W = 960;
  const VIEW_H = 540;
  const COLS = 40;
  const ROWS = 30;

  const T = {
    GRASS: 0, PATH: 1, WATER: 2, WALL: 3, TREE: 4, SAND: 5,
    FLOOR: 6, LAVA: 7, BRIDGE: 8, BUSH: 9, ROCK: 10, FLOWER: 11,
    CARPET: 12, SNOW: 13, ASH: 14, SPIKE: 15,
  };

  const WEAPONS = {
    oak: {
      id: 'oak', name: 'Oak Blade', tier: 0,
      dmg: 1, range: 42, cooldown: 0.38, knock: 180, color: '#c4a574',
      arc: 0.9, desc: 'A sturdy starter blade.',
    },
    ember: {
      id: 'ember', name: 'Ember Edge', tier: 1,
      dmg: 2, range: 48, cooldown: 0.32, knock: 220, color: '#e07030',
      arc: 1.1, burn: true, desc: 'Forged in village fire. Burns foes.',
    },
    storm: {
      id: 'storm', name: 'Storm Cleaver', tier: 2,
      dmg: 3, range: 58, cooldown: 0.28, knock: 280, color: '#70d0ff',
      arc: 1.35, chain: true, desc: 'Lightning leaps between enemies.',
    },
  };

  const RANGED = {
    none: null,
    ashbow: {
      id: 'ashbow', name: 'Ash Bow',
      dmg: 1, speed: 420, cooldown: 0.48, range: 16, color: '#d0a060',
      desc: 'Found in the Ruined Watch. Pierces shade.',
    },
  };

  // Accessories & consumables (5+ distinct)
  const ITEMS = {
    potion: {
      id: 'potion', name: 'Life Vial', type: 'consumable', icon: '🧪',
      desc: 'Restore 2 HP.', price: 15, stack: true,
      use: (p) => { if (p.hp >= p.maxHp) return false; p.hp = Math.min(p.maxHp, p.hp + 2); AudioFX.heal(); spawnHeal(p.x, p.y); return true; },
    },
    elixir: {
      id: 'elixir', name: 'Ember Elixir', type: 'consumable', icon: '🧴',
      desc: 'Fully restore HP.', price: 40, stack: true,
      use: (p) => { if (p.hp >= p.maxHp) return false; p.hp = p.maxHp; AudioFX.heal(); spawnHeal(p.x, p.y); return true; },
    },
    bomb: {
      id: 'bomb', name: 'Powder Bomb', type: 'consumable', icon: '💣',
      desc: 'Place a bomb (F). Damages foes & cracked walls.', price: 20, stack: true,
      use: () => false, // used via F key
    },
    amulet: {
      id: 'amulet', name: 'Guardian Amulet', type: 'accessory', icon: '📿',
      desc: 'Equipped: +1 max HP (heart container half).', price: 50, stack: false,
      onEquip: (p) => { p.maxHp = Math.min(5, p.baseMaxHp + 1 + (p.accessories.includes('ring') ? 1 : 0)); p.hp = Math.min(p.hp + 1, p.maxHp); },
      onUnequip: (p) => { p.maxHp = p.baseMaxHp + (p.accessories.includes('ring') ? 1 : 0); p.hp = Math.min(p.hp, p.maxHp); },
    },
    boots: {
      id: 'boots', name: 'Windstep Boots', type: 'accessory', icon: '👢',
      desc: 'Equipped: faster move & lower dash cooldown.', price: 35, stack: false,
      onEquip: (p) => { p.speedMod = 1.22; p.dashCdMod = 0.7; },
      onUnequip: (p) => { p.speedMod = 1; p.dashCdMod = 1; },
    },
    ring: {
      id: 'ring', name: 'Heartstone Ring', type: 'accessory', icon: '💍',
      desc: 'Equipped: +1 max HP. Found in Crystal Caves.', price: 0, stack: false,
      onEquip: (p) => { p.maxHp = Math.min(5, p.baseMaxHp + 1 + (p.accessories.includes('amulet') ? 1 : 0)); p.hp = Math.min(p.hp + 1, p.maxHp); },
      onUnequip: (p) => { p.maxHp = p.baseMaxHp + (p.accessories.includes('amulet') ? 1 : 0); p.hp = Math.min(p.hp, p.maxHp); },
    },
    charm: {
      id: 'charm', name: 'Lucky Charm', type: 'accessory', icon: '🍀',
      desc: 'Equipped: enemies drop more gold.', price: 30, stack: false,
      onEquip: (p) => { p.goldMod = 1.5; },
      onUnequip: (p) => { p.goldMod = 1; },
    },
    map: {
      id: 'map', name: 'Cartographer\'s Map', type: 'key', icon: '🗺️',
      desc: 'Reveals secrets on the minimap.', price: 25, stack: false,
    },
    key_iron: {
      id: 'key_iron', name: 'Iron Key', type: 'key', icon: '🔑',
      desc: 'Opens iron gates.', price: 0, stack: true,
    },
    key_ember: {
      id: 'key_ember', name: 'Ember Key', type: 'key', icon: '🗝️',
      desc: 'Opens the Spire gate.', price: 0, stack: false,
    },
  };

  const SHOP_STOCK = ['potion', 'elixir', 'bomb', 'boots', 'charm', 'amulet', 'map'];

  // ============================================================
  // STATE
  // ============================================================
  const canvas = document.getElementById('game');
  const ctx = canvas.getContext('2d');
  const mmCanvas = document.getElementById('minimap');
  const mmCtx = mmCanvas.getContext('2d');

  const keys = Object.create(null);
  const mouse = { x: 0, y: 0, down: false, worldX: 0, worldY: 0 };

  let state = 'title'; // title | play | dialogue | inv | shop | pause | end
  let lastT = 0;
  let gameTime = 0;

  const camera = { x: 0, y: 0 };

  // particles, float texts, projectiles, bombs
  const particles = [];
  const floaters = [];
  const projectiles = [];
  const bombs = [];
  const hitsplats = [];

  // World
  let world = null;
  let player = null;
  let flags = null;
  let questId = 'meet_elder';

  const QUESTS = {
    meet_elder: { text: 'Speak with <b>Elder Myra</b> in the village square.' },
    get_blade: { text: 'Claim the <b>Ember Edge</b> from the forge after clearing the grove.' },
    open_wilds: { text: 'Explore the <b>Wilds</b>. Find the ruined watchtower.' },
    find_bow: { text: 'Retrieve the <b>Ash Bow</b> from the Ruined Watch.' },
    crystal: { text: 'Delve the <b>Crystal Caves</b> east of the wilds. Claim the Heartstone.' },
    storm_forge: { text: 'Return to the forge with rare ore for the <b>Storm Cleaver</b>.' },
    spire: { text: 'Use the Ember Key. Enter the <b>Hollow Spire</b> and seal the breach.' },
    done: { text: 'The Seal is restored. Rest, Pathwalker.' },
  };

  // ============================================================
  // UTIL
  // ============================================================
  const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
  const lerp = (a, b, t) => a + (b - a) * t;
  const dist = (ax, ay, bx, by) => Math.hypot(bx - ax, by - ay);
  const rand = (a, b) => a + Math.random() * (b - a);
  const irand = (a, b) => (a + Math.floor(Math.random() * (b - a + 1)));
  const choice = (arr) => arr[Math.floor(Math.random() * arr.length)];
  const angTo = (ax, ay, bx, by) => Math.atan2(by - ay, bx - ax);

  function toast(msg, ms = 2200) {
    const el = document.getElementById('toast');
    el.textContent = msg;
    el.classList.add('show');
    clearTimeout(toast._t);
    toast._t = setTimeout(() => el.classList.remove('show'), ms);
  }

  function updateQuestUI() {
    document.getElementById('quest-text').innerHTML = (QUESTS[questId] || QUESTS.done).text;
  }

  function updateHearts() {
    const el = document.getElementById('hearts');
    el.innerHTML = '';
    // each heart = 2 HP; maxHp up to 5
    const slots = Math.ceil(player.maxHp / 2);
    for (let i = 0; i < slots; i++) {
      const h = document.createElement('div');
      h.className = 'heart';
      const filled = player.hp - i * 2;
      if (filled >= 2) h.classList.add('full');
      else if (filled === 1) h.classList.add('half');
      el.appendChild(h);
    }
  }

  function updateHUD() {
    if (!player) return;
    updateHearts();
    document.getElementById('gold-display').textContent = Math.floor(player.gold);
    document.getElementById('weapon-display').textContent = WEAPONS[player.weapon].name;
    document.getElementById('zone-display').textContent = currentZoneName();
    document.getElementById('hot-melee-ico').textContent = player.weapon === 'storm' ? '⚡' : player.weapon === 'ember' ? '🔥' : '🗡';
    document.getElementById('hot-ranged-ico').textContent = player.ranged ? '🏹' : '—';
    document.getElementById('hot-ranged').style.opacity = player.ranged ? '1' : '0.35';
    document.getElementById('hot-melee').classList.toggle('active', player.mode === 'melee');
    document.getElementById('hot-ranged').classList.toggle('active', player.mode === 'ranged');
    const pots = countItem('potion');
    document.getElementById('hot-item-ico').textContent = pots > 0 ? '🧪' : '+';
  }

  function countItem(id) {
    return player.inventory.filter(i => i === id).length;
  }

  function hasItem(id) {
    return player.inventory.includes(id);
  }

  function giveItem(id, n = 1) {
    for (let i = 0; i < n; i++) player.inventory.push(id);
    AudioFX.pickup();
    toast(`Got ${ITEMS[id].icon} ${ITEMS[id].name}`);
  }

  function takeItem(id, n = 1) {
    for (let k = 0; k < n; k++) {
      const ix = player.inventory.indexOf(id);
      if (ix < 0) return false;
      player.inventory.splice(ix, 1);
    }
    return true;
  }

  function equipAccessory(id) {
    const it = ITEMS[id];
    if (!it || it.type !== 'accessory') return;
    if (player.accessories.includes(id)) return;
    // max 3 accessories
    if (player.accessories.length >= 3) {
      toast('Accessory limit (3). Unequip one first.');
      return;
    }
    if (!hasItem(id)) return;
    player.accessories.push(id);
    if (it.onEquip) it.onEquip(player);
    AudioFX.levelup();
    toast(`Equipped ${it.name}`);
    updateHUD();
  }

  function unequipAccessory(id) {
    const ix = player.accessories.indexOf(id);
    if (ix < 0) return;
    player.accessories.splice(ix, 1);
    const it = ITEMS[id];
    if (it.onUnequip) it.onUnequip(player);
    updateHUD();
  }

  // ============================================================
  // PARTICLES
  // ============================================================
  function spawnParticles(x, y, n, color, speed = 80, life = 0.5, size = 3) {
    for (let i = 0; i < n; i++) {
      const a = Math.random() * Math.PI * 2;
      const s = rand(speed * 0.3, speed);
      particles.push({
        x, y,
        vx: Math.cos(a) * s, vy: Math.sin(a) * s,
        life, max: life,
        color, size: rand(size * 0.5, size),
        gravity: 0,
      });
    }
  }
  function spawnHeal(x, y) {
    for (let i = 0; i < 12; i++) {
      particles.push({
        x: x + rand(-12, 12), y: y + rand(-8, 8),
        vx: rand(-10, 10), vy: rand(-40, -20),
        life: 0.7, max: 0.7, color: '#5d5', size: 3, gravity: -20,
      });
    }
  }
  function spawnFloat(x, y, text, color = '#fff') {
    floaters.push({ x, y, text, color, life: 0.9, vy: -30 });
  }

  // ============================================================
  // WORLD GENERATION
  // ============================================================
  function makeGrid(fill) {
    const g = new Array(ROWS);
    for (let y = 0; y < ROWS; y++) {
      g[y] = new Array(COLS);
      for (let x = 0; x < COLS; x++) g[y][x] = fill;
    }
    return g;
  }

  function inBounds(tx, ty) {
    return tx >= 0 && ty >= 0 && tx < COLS && ty < ROWS;
  }

  function tileAt(px, py) {
    const tx = Math.floor(px / TILE);
    const ty = Math.floor(py / TILE);
    if (!inBounds(tx, ty)) return T.WALL;
    return world.tiles[ty][tx];
  }

  function setTile(tx, ty, v) {
    if (inBounds(tx, ty)) world.tiles[ty][tx] = v;
  }

  function solidTile(t) {
    return t === T.WALL || t === T.WATER || t === T.TREE || t === T.ROCK || t === T.LAVA;
  }

  function blocks(px, py, r = 10) {
    // circle vs tile solids
    const pts = [
      [px - r, py - r], [px + r, py - r], [px - r, py + r], [px + r, py + r],
      [px, py - r], [px, py + r], [px - r, py], [px + r, py],
    ];
    for (const [x, y] of pts) {
      if (solidTile(tileAt(x, y))) return true;
    }
    // entity walls / doors
    for (const e of world.entities) {
      if (e.solid && dist(px, py, e.x, e.y) < r + (e.radius || 16)) {
        if ((e.type === 'door' || e.type === 'gate') && e.open) continue;
        if (e.type === 'cracked' && e.broken) continue;
        return true;
      }
    }
    return false;
  }

  function tryMove(ent, dx, dy, r = 10) {
    const nx = ent.x + dx;
    const ny = ent.y + dy;
    if (!blocks(nx, ent.y, r)) ent.x = nx;
    if (!blocks(ent.x, ny, r)) ent.y = ny;
  }

  function zoneAt(px, py) {
    // regions of the overworld map
    const tx = px / TILE, ty = py / TILE;
    if (tx < 12 && ty < 12) return 'village';
    if (tx < 12 && ty >= 12 && ty < 20) return 'grove';
    if (tx >= 12 && tx < 24 && ty < 14) return 'wilds';
    if (tx >= 12 && tx < 24 && ty >= 14 && ty < 22) return 'watch';
    if (tx >= 24 && ty < 16) return 'caves';
    if (tx >= 24 && ty >= 16) return 'spire';
    if (ty >= 22) return 'shore';
    return 'wilds';
  }

  function currentZoneName() {
    if (!player) return '—';
    const z = zoneAt(player.x, player.y);
    return ({
      village: 'Village',
      grove: 'Whisper Grove',
      wilds: 'Ashen Wilds',
      watch: 'Ruined Watch',
      caves: 'Crystal Caves',
      spire: 'Hollow Spire',
      shore: 'Gray Shore',
    })[z] || z;
  }

  function buildWorld() {
    const tiles = makeGrid(T.GRASS);
    const entities = [];
    const enemies = [];
    const pickups = [];

    // helpers
    const fillRect = (x, y, w, h, t) => {
      for (let j = y; j < y + h; j++)
        for (let i = x; i < x + w; i++)
          if (inBounds(i, j)) tiles[j][i] = t;
    };
    const ring = (x, y, w, h, t) => {
      for (let i = x; i < x + w; i++) {
        if (inBounds(i, y)) tiles[y][i] = t;
        if (inBounds(i, y + h - 1)) tiles[y + h - 1][i] = t;
      }
      for (let j = y; j < y + h; j++) {
        if (inBounds(x, j)) tiles[j][x] = t;
        if (inBounds(x + w - 1, j)) tiles[j][x + w - 1] = t;
      }
    };
    const scatter = (x, y, w, h, t, chance) => {
      for (let j = y; j < y + h; j++)
        for (let i = x; i < x + w; i++)
          if (inBounds(i, j) && Math.random() < chance && tiles[j][i] === T.GRASS)
            tiles[j][i] = t;
    };

    // --- VILLAGE (0,0)-(12,12) ---
    fillRect(0, 0, 12, 12, T.PATH);
    fillRect(1, 1, 10, 10, T.GRASS);
    fillRect(4, 4, 4, 4, T.PATH);
    // buildings (walls)
    fillRect(1, 1, 3, 3, T.WALL); // house
    tiles[3][2] = T.PATH; // door gap
    fillRect(8, 1, 3, 3, T.WALL); // shop
    tiles[3][9] = T.PATH;
    fillRect(1, 7, 3, 3, T.WALL); // forge
    tiles[8][3] = T.PATH;
    // carpet plaza
    fillRect(5, 5, 2, 2, T.CARPET);
    scatter(1, 1, 10, 10, T.FLOWER, 0.08);

    // --- GROVE south of village ---
    fillRect(0, 12, 12, 8, T.GRASS);
    scatter(0, 12, 12, 8, T.TREE, 0.22);
    scatter(0, 12, 12, 8, T.BUSH, 0.08);
    fillRect(5, 12, 2, 8, T.PATH); // road south
    // clear grove center ritual
    fillRect(3, 15, 5, 4, T.GRASS);
    fillRect(4, 16, 3, 2, T.FLOWER);

    // water band
    fillRect(0, 20, 14, 3, T.WATER);
    fillRect(5, 20, 2, 3, T.BRIDGE);
    fillRect(0, 23, 40, 7, T.SAND);
    scatter(0, 23, 40, 7, T.ROCK, 0.05);

    // --- WILDS ---
    fillRect(12, 0, 12, 14, T.GRASS);
    scatter(12, 0, 12, 14, T.TREE, 0.1);
    scatter(12, 0, 12, 14, T.BUSH, 0.06);
    scatter(12, 0, 12, 14, T.ROCK, 0.03);
    fillRect(11, 5, 8, 2, T.PATH); // east road

    // --- WATCH / ruins ---
    fillRect(12, 14, 12, 8, T.ASH);
    ring(14, 15, 8, 6, T.WALL);
    fillRect(15, 16, 6, 4, T.FLOOR);
    tiles[18][14] = T.FLOOR; // entrance
    tiles[18][15] = T.FLOOR;
    // cracked wall inside
    // internal
    fillRect(17, 16, 1, 3, T.WALL);

    // --- CAVES ---
    fillRect(24, 0, 16, 16, T.ROCK);
    fillRect(25, 1, 14, 14, T.FLOOR);
    // crystal lake
    fillRect(30, 6, 5, 4, T.WATER);
    fillRect(24, 7, 2, 2, T.FLOOR); // entrance corridor
    tiles[7][24] = T.FLOOR;
    tiles[8][24] = T.FLOOR;
    // maze walls
    fillRect(27, 2, 1, 5, T.ROCK);
    fillRect(27, 8, 1, 5, T.ROCK);
    fillRect(32, 2, 1, 4, T.ROCK);
    fillRect(34, 10, 4, 1, T.ROCK);
    fillRect(28, 12, 6, 1, T.ROCK);
    // spike traps
    tiles[4][29] = T.SPIKE;
    tiles[5][35] = T.SPIKE;
    tiles[11][31] = T.SPIKE;

    // --- SPIRE ---
    fillRect(24, 16, 16, 14, T.ASH);
    // outer walls
    ring(28, 18, 10, 10, T.WALL);
    fillRect(29, 19, 8, 8, T.FLOOR);
    fillRect(32, 27, 2, 2, T.FLOOR); // door
    // lava moat
    fillRect(28, 27, 4, 1, T.LAVA);
    fillRect(34, 27, 4, 1, T.LAVA);
    fillRect(32, 27, 2, 1, T.BRIDGE);
    // inner sanctum
    fillRect(31, 21, 4, 4, T.CARPET);
    tiles[20][32] = T.FLOOR;
    tiles[20][33] = T.FLOOR;

    // road eccentricity
    fillRect(11, 8, 1, 1, T.PATH);

    // ========== ENTITIES ==========
    // NPCs
    entities.push({
      type: 'npc', id: 'elder', name: 'Elder Myra',
      x: 6.5 * TILE, y: 5.5 * TILE, radius: 14, solid: true,
      color: '#c9a0e8', glyph: 'M',
      lines: [],
    });
    entities.push({
      type: 'npc', id: 'smith', name: 'Smith Borin',
      x: 2.5 * TILE, y: 8.5 * TILE, radius: 14, solid: true,
      color: '#d08050', glyph: 'B',
    });
    entities.push({
      type: 'npc', id: 'shopkeep', name: 'Shopkeep Lila',
      x: 9.5 * TILE, y: 2.5 * TILE, radius: 14, solid: true,
      color: '#50c8a0', glyph: 'L', shop: true,
    });
    entities.push({
      type: 'npc', id: 'scout', name: 'Scout Renn',
      x: 10.5 * TILE, y: 10.5 * TILE, radius: 14, solid: true,
      color: '#80b0e0', glyph: 'R',
    });

    // Save stones
    const saveSpots = [
      [6, 9], [6, 18], [18, 6], [18, 18], [30, 3], [32, 25],
    ];
    for (const [sx, sy] of saveSpots) {
      entities.push({
        type: 'save', x: (sx + 0.5) * TILE, y: (sy + 0.5) * TILE,
        radius: 16, solid: false, pulse: 0,
      });
      if (tiles[sy][sx] === T.WALL || tiles[sy][sx] === T.WATER || tiles[sy][sx] === T.ROCK)
        tiles[sy][sx] = T.PATH;
    }

    // Chests & pickups markers as entities
    entities.push({ type: 'chest', id: 'chest_grove', x: 5.5 * TILE, y: 17.5 * TILE, radius: 14, solid: true, open: false, loot: () => {
      if (!flags.ember_ready) { toast('The chest is sealed by grove spirits...'); return; }
      flags.chest_grove = true;
      if (player.weapon === 'oak') {
        player.weapon = 'ember';
        AudioFX.levelup();
        toast('Ember Edge obtained!');
        spawnParticles(player.x, player.y, 24, '#e07030', 140, 0.7, 4);
        questId = 'open_wilds';
        updateQuestUI();
      } else {
        player.gold += 20;
        toast('+20 gold');
      }
    }});

    entities.push({ type: 'chest', id: 'chest_bow', x: 19.5 * TILE, y: 16.5 * TILE, radius: 14, solid: true, open: false, loot: () => {
      flags.chest_bow = true;
      player.ranged = 'ashbow';
      player.mode = 'ranged';
      AudioFX.levelup();
      toast('Ash Bow recovered! Press 2 to aim & fire.');
      spawnParticles(player.x, player.y, 28, '#d0a060', 150, 0.8, 4);
      questId = 'crystal';
      updateQuestUI();
      // drop potion too
      giveItem('potion', 1);
    }});

    entities.push({ type: 'chest', id: 'chest_ring', x: 36.5 * TILE, y: 13.5 * TILE, radius: 14, solid: true, open: false, loot: () => {
      flags.chest_ring = true;
      giveItem('ring');
      equipAccessory('ring');
      toast('Heartstone Ring — max HP increased!');
      questId = flags.has_ore ? 'storm_forge' : 'crystal';
      if (flags.has_ore) questId = 'storm_forge';
      updateQuestUI();
    }});

    entities.push({ type: 'chest', id: 'chest_ore', x: 26.5 * TILE, y: 3.5 * TILE, radius: 14, solid: true, open: false, loot: () => {
      flags.chest_ore = true;
      flags.has_ore = true;
      toast('Storm Ore acquired! Return it to Smith Borin.');
      giveItem('potion', 1);
      questId = 'storm_forge';
      updateQuestUI();
    }});

    entities.push({ type: 'chest', id: 'chest_spire', x: 33.5 * TILE, y: 22.5 * TILE, radius: 14, solid: true, open: false, loot: () => {
      flags.chest_spire = true;
      giveItem('elixir', 1);
      player.gold += 40;
      toast('Spire cache: Elixir + 40 gold');
    }});

    // Pressure plate puzzle in caves
    entities.push({
      type: 'plate', id: 'plate1', x: 29.5 * TILE, y: 9.5 * TILE, radius: 12, solid: false, on: false,
    });
    entities.push({
      type: 'plate', id: 'plate2', x: 35.5 * TILE, y: 4.5 * TILE, radius: 12, solid: false, on: false,
    });
    entities.push({
      type: 'gate', id: 'crystal_gate', x: 36.5 * TILE, y: 12.5 * TILE, radius: 20, solid: true, open: false,
      color: '#80e0ff',
    });

    // Iron gate to wilds (east of village) — opens after grove cleared
    entities.push({
      type: 'gate', id: 'wilds_gate', x: 11.5 * TILE, y: 5.5 * TILE, radius: 22, solid: true, open: false,
      color: '#8899aa', needs: 'grove_cleared',
    });

    // Spire gate
    entities.push({
      type: 'door', id: 'spire_door', x: 32.5 * TILE, y: 27.5 * TILE, radius: 20, solid: true, open: false,
      needsKey: 'key_ember', color: '#e06040',
    });

    // Cracked walls (bombable)
    entities.push({
      type: 'cracked', id: 'crack1', x: 17.5 * TILE, y: 17.5 * TILE, radius: 18, solid: true, broken: false,
      tx: 17, ty: 17,
    });
    entities.push({
      type: 'cracked', id: 'crack2', x: 31.5 * TILE, y: 11.5 * TILE, radius: 18, solid: true, broken: false,
      tx: 31, ty: 11,
    });

    // Signs
    entities.push({
      type: 'sign', x: 7.5 * TILE, y: 11.5 * TILE, radius: 12, solid: false,
      text: 'Whisper Grove — restless sprites guard an old chest.',
    });
    entities.push({
      type: 'sign', x: 13.5 * TILE, y: 5.5 * TILE, radius: 12, solid: false,
      text: 'Ashen Wilds — watchtowers hold old war-tech. Beware shades.',
    });
    entities.push({
      type: 'sign', x: 25.5 * TILE, y: 8.5 * TILE, radius: 12, solid: false,
      text: 'Crystal Caves — twin plates awaken the azure gate.',
    });

    // Boss pedestal / seal
    entities.push({
      type: 'seal', id: 'final_seal', x: 32.5 * TILE, y: 22.5 * TILE, radius: 22, solid: false,
      active: true,
    });

    // ========== ENEMIES ==========
    const addEnemy = (kind, tx, ty, opts = {}) => {
      const defs = {
        slime: { hp: 3, spd: 40, dmg: 1, radius: 12, color: '#5cb85c', xp: 3, gold: [1, 4], ai: 'chase' },
        sprite: { hp: 2, spd: 70, dmg: 1, radius: 10, color: '#c0e060', xp: 4, gold: [2, 5], ai: 'dart' },
        shade: { hp: 5, spd: 55, dmg: 1, radius: 13, color: '#6060a0', xp: 6, gold: [3, 8], ai: 'chase' },
        crystal: { hp: 6, spd: 45, dmg: 1, radius: 14, color: '#60d8ff', xp: 8, gold: [5, 12], ai: 'shoot' },
        ashguard: { hp: 10, spd: 50, dmg: 1, radius: 15, color: '#c05030', xp: 12, gold: [8, 16], ai: 'chase' },
        boss: { hp: 40, spd: 60, dmg: 1, radius: 22, color: '#a02040', xp: 50, gold: [40, 60], ai: 'boss' },
      };
      const d = defs[kind];
      enemies.push({
        kind, ...d, ...opts,
        x: (tx + 0.5) * TILE, y: (ty + 0.5) * TILE,
        maxHp: opts.hp || d.hp, hp: opts.hp || d.hp,
        vx: 0, vy: 0, hurt: 0, cd: 0, alive: true,
        phase: 0, timer: rand(0, 2), facing: 0,
        homeX: (tx + 0.5) * TILE, homeY: (ty + 0.5) * TILE,
      });
    };

    // Grove
    addEnemy('slime', 3, 14);
    addEnemy('slime', 8, 15);
    addEnemy('sprite', 4, 17);
    addEnemy('sprite', 7, 18);
    addEnemy('slime', 2, 18);

    // Wilds
    addEnemy('slime', 14, 3);
    addEnemy('slime', 20, 4);
    addEnemy('shade', 16, 8);
    addEnemy('shade', 21, 10);
    addEnemy('sprite', 18, 2);
    addEnemy('slime', 15, 11);

    // Watch
    addEnemy('shade', 16, 17);
    addEnemy('shade', 20, 17);
    addEnemy('ashguard', 18, 16);

    // Caves
    addEnemy('crystal', 28, 4);
    addEnemy('crystal', 34, 5);
    addEnemy('slime', 29, 11);
    addEnemy('crystal', 33, 13);
    addEnemy('shade', 26, 12);

    // Spire approach
    addEnemy('ashguard', 26, 20);
    addEnemy('ashguard', 36, 20);
    addEnemy('shade', 30, 24);
    addEnemy('shade', 35, 24);

    // Boss
    addEnemy('boss', 32, 22, { hp: 40 });

    // ========== LOOSE PICKUPS ==========
    const addCoin = (tx, ty, n = 5) => {
      pickups.push({ type: 'gold', x: (tx + 0.5) * TILE, y: (ty + 0.5) * TILE, v: n, r: 8 });
    };
    const addPot = (tx, ty) => {
      pickups.push({ type: 'item', id: 'potion', x: (tx + 0.5) * TILE, y: (ty + 0.5) * TILE, r: 10 });
    };
    addCoin(9, 9, 5);
    addCoin(3, 10, 3);
    addCoin(14, 13, 8);
    addCoin(22, 7, 6);
    addPot(19, 12);
    addCoin(27, 14, 10);
    // hidden bomb pickup shore
    pickups.push({ type: 'item', id: 'bomb', x: 3.5 * TILE, y: 25.5 * TILE, r: 10 });
    pickups.push({ type: 'item', id: 'bomb', x: 22.5 * TILE, y: 19.5 * TILE, r: 10 });

    // Ember key held by… we'll drop from boss or place after mid
    // Place key after caves progress, near spire entrance after ore/ring? drop from ashguard captain
    // Actually give key at seal approach via special enemy drop - place pickup after boss of watch
    pickups.push({
      type: 'item', id: 'key_iron', x: 6.5 * TILE, y: 16.5 * TILE, r: 10,
      // iron key for nothing critical - optional
    });

    world = { tiles, entities, enemies, pickups };
  }

  // ============================================================
  // PLAYER
  // ============================================================
  function createPlayer(px, py) {
    return {
      x: px, y: py,
      vx: 0, vy: 0,
      radius: 12,
      hp: 3, maxHp: 3, baseMaxHp: 3,
      gold: 0,
      weapon: 'oak',
      ranged: null,
      mode: 'melee',
      inventory: ['potion', 'potion'],
      accessories: [],
      speedMod: 1,
      dashCdMod: 1,
      goldMod: 1,
      facing: 0,
      atkCd: 0,
      dashCd: 0,
      dashT: 0,
      invuln: 0,
      anim: 0,
      walking: false,
      attackT: 0,
      attackAng: 0,
      hurtFlash: 0,
      kills: 0,
    };
  }

  // ============================================================
  // SAVE / LOAD
  // ============================================================
  const SAVE_KEY = 'ember_path_save_v1';

  function canSaveHere() {
    return world.entities.some(e => e.type === 'save' && dist(player.x, player.y, e.x, e.y) < 40);
  }

  function saveGame() {
    if (!player) return false;
    const data = {
      x: player.x, y: player.y,
      hp: player.hp, maxHp: player.maxHp, baseMaxHp: player.baseMaxHp,
      gold: player.gold,
      weapon: player.weapon,
      ranged: player.ranged,
      inventory: player.inventory.slice(),
      accessories: player.accessories.slice(),
      speedMod: player.speedMod,
      dashCdMod: player.dashCdMod,
      goldMod: player.goldMod,
      flags: { ...flags },
      questId,
      // entity flags
      openedChests: world.entities.filter(e => e.type === 'chest' && e.open).map(e => e.id),
      brokenWalls: world.entities.filter(e => e.type === 'cracked' && e.broken).map(e => e.id),
      openGates: world.entities.filter(e => (e.type === 'gate' || e.type === 'door') && e.open).map(e => e.id),
      deadEnemies: world.enemies.map((e, i) => e.alive ? null : i).filter(i => i !== null),
      pickupsTaken: world.pickups.map((p, i) => p.taken ? i : null).filter(i => i !== null),
      gameTime,
    };
    try {
      localStorage.setItem(SAVE_KEY, JSON.stringify(data));
    } catch (e) {
      toast('Save failed.');
      return false;
    }
    try { AudioFX.save(); } catch (_) {}
    toast('Game saved.');
    spawnParticles(player.x, player.y, 20, '#80e0ff', 100, 0.8, 3);
    return true;
  }

  function hasSave() {
    try { return !!localStorage.getItem(SAVE_KEY); } catch { return false; }
  }

  function loadGame() {
    try {
      const raw = localStorage.getItem(SAVE_KEY);
      if (!raw) return false;
      const data = JSON.parse(raw);
      buildWorld();
      player = createPlayer(data.x, data.y);
      Object.assign(player, {
        hp: data.hp, maxHp: data.maxHp, baseMaxHp: data.baseMaxHp,
        gold: data.gold, weapon: data.weapon, ranged: data.ranged,
        inventory: data.inventory || [],
        accessories: data.accessories || [],
        speedMod: data.speedMod || 1,
        dashCdMod: data.dashCdMod || 1,
        goldMod: data.goldMod || 1,
      });
      flags = Object.assign(defaultFlags(), data.flags || {});
      questId = data.questId || 'meet_elder';
      gameTime = data.gameTime || 0;

      for (const id of (data.openedChests || [])) {
        const c = world.entities.find(e => e.id === id);
        if (c) c.open = true;
      }
      for (const id of (data.brokenWalls || [])) {
        const c = world.entities.find(e => e.id === id);
        if (c) { c.broken = true; c.solid = false; if (c.tx != null) setTile(c.tx, c.ty, T.FLOOR); }
      }
      for (const id of (data.openGates || [])) {
        const c = world.entities.find(e => e.id === id);
        if (c) { c.open = true; c.solid = false; }
      }
      for (const i of (data.deadEnemies || [])) {
        if (world.enemies[i]) { world.enemies[i].alive = false; world.enemies[i].hp = 0; }
      }
      for (const i of (data.pickupsTaken || [])) {
        if (world.pickups[i]) world.pickups[i].taken = true;
      }

      projectiles.length = 0;
      bombs.length = 0;
      particles.length = 0;
      updateQuestUI();
      updateHUD();
      return true;
    } catch (e) {
      console.error(e);
      return false;
    }
  }

  function defaultFlags() {
    return {
      met_elder: false,
      grove_cleared: false,
      ember_ready: false,
      wilds_open: false,
      chest_grove: false,
      chest_bow: false,
      chest_ring: false,
      chest_ore: false,
      has_ore: false,
      storm_forged: false,
      has_ember_key: false,
      boss_dead: false,
      won: false,
    };
  }

  function newGame() {
    buildWorld();
    player = createPlayer(6.5 * TILE, 8.5 * TILE);
    flags = defaultFlags();
    questId = 'meet_elder';
    gameTime = 0;
    projectiles.length = 0;
    bombs.length = 0;
    particles.length = 0;
    floaters.length = 0;
    updateQuestUI();
    updateHUD();
    state = 'play';
    hideOverlays();
    toast('Find Elder Myra in the square.');
    AudioFX.resume();
  }

  // ============================================================
  // DIALOGUE / NPC LOGIC
  // ============================================================
  let dlgQueue = [];
  let dlgSpeaker = '';

  function startDialogue(speaker, lines) {
    dlgSpeaker = speaker;
    dlgQueue = lines.slice();
    state = 'dialogue';
    showDialogue();
  }

  function showDialogue() {
    const box = document.getElementById('dialogue');
    if (!dlgQueue.length) {
      box.classList.remove('show');
      state = 'play';
      return;
    }
    document.getElementById('dlg-who').textContent = dlgSpeaker;
    document.getElementById('dlg-txt').textContent = dlgQueue.shift();
    box.classList.add('show');
    AudioFX.ui();
  }

  function advanceDialogue() {
    if (dlgQueue.length) showDialogue();
    else {
      document.getElementById('dialogue').classList.remove('show');
      state = 'play';
      onDialogueEnd();
    }
  }

  let dialogueEndHook = null;
  function onDialogueEnd() {
    if (dialogueEndHook) {
      const h = dialogueEndHook;
      dialogueEndHook = null;
      h();
    }
  }

  function interactNPC(npc) {
    if (npc.shop) {
      openShop();
      return;
    }
    if (npc.id === 'elder') {
      if (!flags.met_elder) {
        flags.met_elder = true;
        startDialogue(npc.name, [
          'Pathwalker. The Ashen Seal cracks, and the Spire drinks our light.',
          'South lies Whisper Grove. Calm the spirits. A chest there holds Ember steel.',
          'Return when your blade hungers for the storm. The wilds open when the grove rests.',
        ]);
        dialogueEndHook = () => {
          questId = 'get_blade';
          updateQuestUI();
          flags.ember_ready = true;
          toast('Quest: claim Ember Edge in the Grove.');
        };
      } else if (flags.boss_dead) {
        startDialogue(npc.name, [
          'The Seal hums again. You have walked the Ember Path to its end.',
          'Rest now. The village will sing your name.',
        ]);
      } else if (flags.storm_forged) {
        startDialogue(npc.name, [
          'Storm and bow — you are ready. The Ember Key answers only the Spire gate.',
          'I entrust it to you. End this.',
        ]);
        dialogueEndHook = () => {
          if (!flags.has_ember_key) {
            flags.has_ember_key = true;
            giveItem('key_ember');
            questId = 'spire';
            updateQuestUI();
          }
        };
      } else if (player.ranged) {
        startDialogue(npc.name, [
          'The Ash Bow remembers old wars. Seek Heartstone and Storm Ore in the east caves.',
          'Borin can reforge your edge when the ore is yours.',
        ]);
      } else {
        startDialogue(npc.name, [
          'The grove chest opens only when its guardians fall. South, then east to the tower.',
        ]);
      }
    } else if (npc.id === 'smith') {
      if (flags.has_ore && player.weapon !== 'storm') {
        startDialogue(npc.name, [
          'Storm Ore! Ha! Stand back —',
          'Your blade is reborn: Storm Cleaver. Swing wide. Lightning follows.',
        ]);
        dialogueEndHook = () => {
          player.weapon = 'storm';
          flags.storm_forged = true;
          flags.has_ore = false;
          AudioFX.levelup();
          toast('Storm Cleaver forged!');
          spawnParticles(player.x, player.y, 30, '#70d0ff', 160, 0.9, 4);
          updateHUD();
          questId = 'spire';
          updateQuestUI();
          // encourage talking to elder for key
          toast('Speak to Elder Myra for the Ember Key.');
        };
      } else if (player.weapon === 'oak' && flags.met_elder) {
        startDialogue(npc.name, [
          'I poured the last ember-steel into a grove casket. Earn it.',
          'Bring me rare ore later — I\'ll wake lightning in your steel.',
        ]);
      } else if (player.weapon === 'storm') {
        startDialogue(npc.name, [
          'That cleaver could split the Spire itself. Don\'t prove me wrong.',
        ]);
      } else {
        startDialogue(npc.name, [
          'Need bombs? Lila sells them. Cracked stone fears firepowder.',
        ]);
      }
    } else if (npc.id === 'scout') {
      startDialogue(npc.name, [
        'Shades stalk the wilds past the east gate. It unlocks when the grove is quiet.',
        'I saw crystal light eastward — and something vast moving in the Spire.',
        'Save Stones remember you. Touch them. Always.',
      ]);
    }
  }

  function checkGroveCleared() {
    if (flags.grove_cleared) return;
    const groveEnemies = world.enemies.filter(e => e.alive && zoneAt(e.x, e.y) === 'grove');
    if (groveEnemies.length === 0) {
      flags.grove_cleared = true;
      flags.ember_ready = true;
      flags.wilds_open = true;
      const g = world.entities.find(e => e.id === 'wilds_gate');
      if (g) { g.open = true; g.solid = false; }
      AudioFX.puzzle();
      toast('Grove pacified. East gate opened!');
      spawnParticles(11.5 * TILE, 5.5 * TILE, 20, '#c4a040', 100, 0.7, 3);
      if (questId === 'get_blade') { /* chest still needed */ }
    }
  }

  // ============================================================
  // SHOP & INVENTORY UI
  // ============================================================
  function hideOverlays() {
    ['overlay-title','overlay-inv','overlay-shop','overlay-pause','overlay-end'].forEach(id => {
      document.getElementById(id).classList.add('hidden');
    });
    // title stays until new game
  }

  function openInventory() {
    state = 'inv';
    document.getElementById('overlay-inv').classList.remove('hidden');
    renderInvGrid();
    AudioFX.ui();
  }

  function closeInventory() {
    document.getElementById('overlay-inv').classList.add('hidden');
    state = 'play';
  }

  let invSelected = null;

  function renderInvGrid() {
    const grid = document.getElementById('inv-grid');
    grid.innerHTML = '';
    // unique stack view
    const counts = {};
    for (const id of player.inventory) counts[id] = (counts[id] || 0) + 1;
    const equipped = new Set(player.accessories);
    const ids = Object.keys(counts);
    invSelected = null;
    document.getElementById('inv-detail').innerHTML = 'Select an item.';

    for (let i = 0; i < 15; i++) {
      const slot = document.createElement('div');
      slot.className = 'inv-slot';
      if (i < ids.length) {
        const id = ids[i];
        const it = ITEMS[id];
        slot.textContent = it.icon;
        if (counts[id] > 1) {
          const q = document.createElement('span');
          q.className = 'qty';
          q.textContent = counts[id];
          slot.appendChild(q);
        }
        if (equipped.has(id)) slot.style.borderColor = 'var(--accent2)';
        slot.addEventListener('click', () => {
          document.querySelectorAll('.inv-slot').forEach(s => s.classList.remove('selected'));
          slot.classList.add('selected');
          invSelected = id;
          const eq = equipped.has(id) ? ' [EQUIPPED]' : '';
          document.getElementById('inv-detail').innerHTML =
            `<strong>${it.icon} ${it.name}${eq}</strong>${it.desc}<br><span style="color:var(--muted)">Type: ${it.type}</span>`;
          AudioFX.ui();
        });
      } else {
        slot.classList.add('empty');
      }
      grid.appendChild(slot);
    }
  }

  function useSelectedItem() {
    if (!invSelected) return;
    const it = ITEMS[invSelected];
    if (it.type === 'consumable') {
      if (invSelected === 'bomb') { toast('Press F in-game to place bombs.'); return; }
      if (it.use && it.use(player)) {
        takeItem(invSelected);
        updateHUD();
        renderInvGrid();
      } else toast('Cannot use now.');
    } else if (it.type === 'accessory') {
      if (player.accessories.includes(invSelected)) {
        unequipAccessory(invSelected);
        toast(`Unequipped ${it.name}`);
      } else {
        equipAccessory(invSelected);
      }
      renderInvGrid();
      updateHUD();
    } else {
      toast('Key item — used automatically when needed.');
    }
  }

  function openShop() {
    state = 'shop';
    document.getElementById('overlay-shop').classList.remove('hidden');
    document.getElementById('shop-gold').textContent = Math.floor(player.gold);
    const list = document.getElementById('shop-list');
    list.innerHTML = '';
    AudioFX.shop();
    for (const id of SHOP_STOCK) {
      const it = ITEMS[id];
      // unique accessories only once
      if (it.type === 'accessory' && (hasItem(id) || player.accessories.includes(id))) continue;
      if (id === 'map' && hasItem('map')) continue;
      const row = document.createElement('div');
      row.className = 'shop-item';
      row.innerHTML = `
        <div class="ico">${it.icon}</div>
        <div><div class="nm">${it.name}</div><div class="ds">${it.desc}</div></div>
        <div class="pr">${it.price}g</div>
      `;
      const btn = document.createElement('button');
      btn.className = 'btn';
      btn.textContent = 'BUY';
      btn.disabled = player.gold < it.price;
      btn.addEventListener('click', () => {
        if (player.gold < it.price) return;
        player.gold -= it.price;
        giveItem(id);
        AudioFX.buy();
        updateHUD();
        openShop(); // refresh
      });
      row.appendChild(btn);
      list.appendChild(row);
    }
    if (!list.children.length) {
      list.innerHTML = '<p style="color:var(--muted)">Sold out of unique wares. Potions restock in spirit only — restart for full stock.</p>';
      // restock consumables always
      for (const id of ['potion', 'elixir', 'bomb']) {
        const it = ITEMS[id];
        const row = document.createElement('div');
        row.className = 'shop-item';
        row.innerHTML = `
          <div class="ico">${it.icon}</div>
          <div><div class="nm">${it.name}</div><div class="ds">${it.desc}</div></div>
          <div class="pr">${it.price}g</div>
        `;
        const btn = document.createElement('button');
        btn.className = 'btn';
        btn.textContent = 'BUY';
        btn.disabled = player.gold < it.price;
        btn.addEventListener('click', () => {
          if (player.gold < it.price) return;
          player.gold -= it.price;
          giveItem(id);
          AudioFX.buy();
          updateHUD();
          document.getElementById('shop-gold').textContent = Math.floor(player.gold);
          btn.disabled = player.gold < it.price;
        });
        row.appendChild(btn);
        list.appendChild(row);
      }
    }
  }

  function closeShop() {
    document.getElementById('overlay-shop').classList.add('hidden');
    state = 'play';
  }

  // ============================================================
  // COMBAT
  // ============================================================
  function playerAttack() {
    if (player.atkCd > 0 || player.attackT > 0) return;
    if (player.mode === 'ranged' && player.ranged) {
      fireBow();
      return;
    }
    const w = WEAPONS[player.weapon];
    player.atkCd = w.cooldown;
    player.attackT = 0.18;
    player.attackAng = player.facing;
    AudioFX.swing();

    // hit enemies in arc
    const reach = w.range;
    let hits = 0;
    for (const e of world.enemies) {
      if (!e.alive || e.hurt > 0.05) continue;
      const d = dist(player.x, player.y, e.x, e.y);
      if (d > reach + e.radius) continue;
      const a = angTo(player.x, player.y, e.x, e.y);
      let da = Math.abs(Math.atan2(Math.sin(a - player.facing), Math.cos(a - player.facing)));
      if (da > w.arc) continue;
      damageEnemy(e, w.dmg, player.facing, w.knock);
      hits++;
      if (w.burn) {
        e.burn = 1.5;
        spawnParticles(e.x, e.y, 6, '#e07030', 60, 0.4, 2);
      }
      if (w.chain) {
        // arc to nearby
        for (const o of world.enemies) {
          if (o === e || !o.alive) continue;
          if (dist(e.x, e.y, o.x, o.y) < 70) {
            damageEnemy(o, Math.max(1, w.dmg - 1), angTo(e.x, e.y, o.x, o.y), w.knock * 0.5);
            spawnParticles((e.x + o.x) / 2, (e.y + o.y) / 2, 5, '#70d0ff', 40, 0.3, 2);
          }
        }
      }
    }
    // break pots? crack soft bushes
    // visual slash particles
    spawnParticles(
      player.x + Math.cos(player.facing) * 24,
      player.y + Math.sin(player.facing) * 24,
      8, w.color, 90, 0.25, 3
    );
  }

  function fireBow() {
    const r = RANGED[player.ranged];
    if (!r) return;
    player.atkCd = r.cooldown;
    const a = player.facing;
    projectiles.push({
      x: player.x + Math.cos(a) * 16,
      y: player.y + Math.sin(a) * 16,
      vx: Math.cos(a) * r.speed,
      vy: Math.sin(a) * r.speed,
      dmg: r.dmg, friendly: true, life: 1.2, r: 5, color: r.color,
    });
    AudioFX.shoot();
  }

  function damageEnemy(e, dmg, ang, knock) {
    e.hp -= dmg;
    e.hurt = 0.2;
    e.vx += Math.cos(ang) * knock;
    e.vy += Math.sin(ang) * knock;
    AudioFX.hit();
    spawnParticles(e.x, e.y, 10, e.color, 120, 0.35, 3);
    spawnFloat(e.x, e.y - 10, `-${dmg}`, '#ffe0a0');
    if (e.hp <= 0) killEnemy(e);
  }

  function killEnemy(e) {
    e.alive = false;
    e.hp = 0;
    player.kills++;
    const g = Math.floor(rand(e.gold[0], e.gold[1] + 1) * player.goldMod);
    player.gold += g;
    spawnParticles(e.x, e.y, 20, e.color, 150, 0.6, 4);
    spawnFloat(e.x, e.y, `+${g}g`, '#f1c40f');
    AudioFX.coin();
    // drops
    if (Math.random() < 0.12) {
      world.pickups.push({ type: 'item', id: 'potion', x: e.x, y: e.y, r: 10 });
    }
    if (e.kind === 'boss') {
      flags.boss_dead = true;
      giveItem('elixir', 2);
      player.gold += 50;
      AudioFX.boss();
      toast('The Breach Warden falls!');
      // win sequence
      setTimeout(() => victory(), 1200);
    }
    if (e.kind === 'ashguard' && zoneAt(e.x, e.y) === 'watch' && !flags.has_ember_key_drop) {
      // optional mid drop - actually key from elder
    }
    checkGroveCleared();
    updateHUD();
  }

  function hurtPlayer(dmg, ax, ay) {
    if (player.invuln > 0 || player.dashT > 0) return;
    player.hp -= dmg;
    player.invuln = 0.9;
    player.hurtFlash = 0.3;
    AudioFX.hurt();
    const a = angTo(ax, ay, player.x, player.y);
    player.vx += Math.cos(a) * 200;
    player.vy += Math.sin(a) * 200;
    spawnParticles(player.x, player.y, 12, '#e74c3c', 100, 0.4, 3);
    spawnFloat(player.x, player.y - 12, `-${dmg}`, '#e74c3c');
    const flash = document.getElementById('dmg-flash');
    flash.classList.add('on');
    setTimeout(() => flash.classList.remove('on'), 100);
    updateHUD();
    if (player.hp <= 0) {
      player.hp = 0;
      AudioFX.death();
      gameOver();
    }
  }

  function gameOver() {
    state = 'end';
    document.getElementById('end-title').textContent = 'DEFEATED';
    document.getElementById('end-text').textContent =
      'Your embers fade… Load a Save Stone checkpoint from the title screen, or begin anew.';
    document.getElementById('overlay-end').classList.remove('hidden');
  }

  function victory() {
    flags.won = true;
    questId = 'done';
    updateQuestUI();
    state = 'end';
    document.getElementById('end-title').textContent = 'SEAL RESTORED';
    document.getElementById('end-text').textContent =
      `The Hollow Spire stills. Gold earned: ${Math.floor(player.gold)}. Foes felled: ${player.kills}. Time: ${formatTime(gameTime)}. The Ember Path is complete.`;
    document.getElementById('overlay-end').classList.remove('hidden');
    AudioFX.levelup();
  }

  function formatTime(t) {
    const m = Math.floor(t / 60);
    const s = Math.floor(t % 60);
    return `${m}m ${s}s`;
  }

  // ============================================================
  // BOMBS
  // ============================================================
  function placeBomb() {
    if (!hasItem('bomb')) { toast('No bombs.'); return; }
    if (bombs.length >= 3) return;
    takeItem('bomb');
    bombs.push({
      x: player.x, y: player.y, t: 1.2, r: 56,
    });
    updateHUD();
    AudioFX.ui();
  }

  function explodeBomb(b) {
    AudioFX.explosion();
    spawnParticles(b.x, b.y, 36, '#e08040', 200, 0.6, 5);
    spawnParticles(b.x, b.y, 16, '#555', 120, 0.5, 4);
    for (const e of world.enemies) {
      if (!e.alive) continue;
      if (dist(b.x, b.y, e.x, e.y) < b.r + e.radius) {
        damageEnemy(e, 3, angTo(b.x, b.y, e.x, e.y), 320);
      }
    }
    if (dist(b.x, b.y, player.x, player.y) < b.r) {
      hurtPlayer(1, b.x, b.y);
    }
    for (const ent of world.entities) {
      if (ent.type === 'cracked' && !ent.broken && dist(b.x, b.y, ent.x, ent.y) < b.r) {
        ent.broken = true;
        ent.solid = false;
        if (ent.tx != null) setTile(ent.tx, ent.ty, T.FLOOR);
        AudioFX.puzzle();
        toast('Cracked wall shattered!');
        spawnParticles(ent.x, ent.y, 20, '#888', 100, 0.5, 4);
      }
    }
  }

  // ============================================================
  // INTERACT
  // ============================================================
  function interact() {
    // NPCs
    for (const e of world.entities) {
      if (dist(player.x, player.y, e.x, e.y) > 36) continue;
      if (e.type === 'npc') { interactNPC(e); return; }
      if (e.type === 'sign') {
        startDialogue('Sign', [e.text]);
        return;
      }
      if (e.type === 'save') {
        saveGame();
        return;
      }
      if (e.type === 'chest' && !e.open) {
        e.open = true;
        AudioFX.door();
        e.loot();
        spawnParticles(e.x, e.y, 14, '#f1c40f', 80, 0.5, 3);
        return;
      }
      if (e.type === 'door' && !e.open) {
        if (e.needsKey && hasItem(e.needsKey)) {
          e.open = true;
          e.solid = false;
          takeItem(e.needsKey);
          AudioFX.door();
          toast('Gate unlocked.');
          spawnParticles(e.x, e.y, 16, e.color, 90, 0.5, 3);
        } else {
          toast('Sealed. Needs the Ember Key.');
          AudioFX.ui();
        }
        return;
      }
      if (e.type === 'seal' && e.active && !flags.boss_dead) {
        startDialogue('Ashen Seal', [
          'The breach screams. Defeat the Warden to restore the Seal.',
        ]);
        return;
      }
    }
  }

  // ============================================================
  // UPDATE
  // ============================================================
  function update(dt) {
    if (state !== 'play') return;
    gameTime += dt;

    // facing toward mouse
    player.facing = angTo(player.x, player.y, mouse.worldX, mouse.worldY);

    // timers
    player.atkCd = Math.max(0, player.atkCd - dt);
    player.dashCd = Math.max(0, player.dashCd - dt);
    player.invuln = Math.max(0, player.invuln - dt);
    player.hurtFlash = Math.max(0, player.hurtFlash - dt);
    player.attackT = Math.max(0, player.attackT - dt);
    player.dashT = Math.max(0, player.dashT - dt);

    // movement
    let ix = 0, iy = 0;
    if (keys['KeyW'] || keys['ArrowUp']) iy -= 1;
    if (keys['KeyS'] || keys['ArrowDown']) iy += 1;
    if (keys['KeyA'] || keys['ArrowLeft']) ix -= 1;
    if (keys['KeyD'] || keys['ArrowRight']) ix += 1;
    const len = Math.hypot(ix, iy) || 1;
    ix /= len; iy /= len;

    const baseSpd = 145 * player.speedMod;
    let spd = baseSpd;
    if (player.dashT > 0) spd = baseSpd * 2.6;

    // friction velocity from knockback
    player.vx *= Math.pow(0.05, dt);
    player.vy *= Math.pow(0.05, dt);

    const mx = ix * spd * dt + player.vx * dt;
    const my = iy * spd * dt + player.vy * dt;
    tryMove(player, mx, my, player.radius);

    player.walking = (ix !== 0 || iy !== 0) && player.dashT <= 0;
    if (player.walking) {
      player.anim += dt * 10;
      if (Math.floor(player.anim) % 4 === 0 && player._stepGate !== Math.floor(player.anim)) {
        player._stepGate = Math.floor(player.anim);
        if (Math.random() < 0.4) AudioFX.step();
      }
    }

    // dash
    if ((keys['ShiftLeft'] || keys['ShiftRight']) && player.dashCd <= 0 && (ix || iy)) {
      player.dashT = 0.16;
      player.dashCd = 0.7 * player.dashCdMod;
      player.invuln = Math.max(player.invuln, 0.16);
      AudioFX.dash();
      spawnParticles(player.x, player.y, 8, '#a0c0e0', 60, 0.3, 2);
    }

    // spikes / lava
    const tUnder = tileAt(player.x, player.y);
    if (tUnder === T.SPIKE || tUnder === T.LAVA) {
      hurtPlayer(1, player.x, player.y + 1);
    }

    // attack
    if (mouse.down || keys['Space']) playerAttack();

    // pickups
    for (const p of world.pickups) {
      if (p.taken) continue;
      if (dist(player.x, player.y, p.x, p.y) < player.radius + p.r) {
        p.taken = true;
        if (p.type === 'gold') {
          player.gold += p.v;
          AudioFX.coin();
          spawnFloat(p.x, p.y, `+${p.v}g`, '#f1c40f');
          spawnParticles(p.x, p.y, 8, '#f1c40f', 60, 0.4, 2);
        } else if (p.type === 'item') {
          giveItem(p.id);
          spawnParticles(p.x, p.y, 10, '#fff', 70, 0.4, 2);
        }
        updateHUD();
      }
    }

    // plates puzzle
    const plates = world.entities.filter(e => e.type === 'plate');
    for (const pl of plates) {
      const was = pl.on;
      pl.on = dist(player.x, player.y, pl.x, pl.y) < 20
        || world.enemies.some(e => e.alive && dist(e.x, e.y, pl.x, pl.y) < 18);
      // also bombs? skip
      if (pl.on && !was) AudioFX.puzzle();
    }
    const gate = world.entities.find(e => e.id === 'crystal_gate');
    if (gate && !gate.open) {
      if (plates.every(p => p.on)) {
        gate.open = true;
        gate.solid = false;
        AudioFX.puzzle();
        toast('Azure gate opens!');
        spawnParticles(gate.x, gate.y, 24, '#80e0ff', 120, 0.7, 3);
      }
    }

    // enemies
    for (const e of world.enemies) {
      if (!e.alive) continue;
      e.hurt = Math.max(0, e.hurt - dt);
      e.cd = Math.max(0, e.cd - dt);
      e.timer -= dt;
      if (e.burn > 0) {
        e.burn -= dt;
        if (Math.random() < dt * 4) {
          e.hp -= 0.25;
          spawnParticles(e.x, e.y, 2, '#e07030', 30, 0.3, 2);
          if (e.hp <= 0) killEnemy(e);
        }
      }

      // knock friction
      e.vx *= Math.pow(0.02, dt);
      e.vy *= Math.pow(0.02, dt);

      const dPlayer = dist(e.x, e.y, player.x, player.y);
      const aggro = e.kind === 'boss' ? 280 : 200;

      if (e.ai === 'chase' || e.ai === 'boss') {
        if (dPlayer < aggro) {
          const a = angTo(e.x, e.y, player.x, player.y);
          e.facing = a;
          const spd = e.spd * (e.kind === 'boss' && e.hp < e.maxHp * 0.4 ? 1.35 : 1);
          tryMove(e, Math.cos(a) * spd * dt + e.vx * dt, Math.sin(a) * spd * dt + e.vy * dt, e.radius);
          if (dPlayer < e.radius + player.radius + 4 && e.cd <= 0) {
            hurtPlayer(e.dmg, e.x, e.y);
            e.cd = e.kind === 'boss' ? 0.7 : 0.85;
          }
          // boss specials
          if (e.kind === 'boss' && e.timer <= 0) {
            e.timer = 2.2;
            // ring shot
            const n = e.hp < e.maxHp * 0.5 ? 10 : 6;
            for (let i = 0; i < n; i++) {
              const ang = (i / n) * Math.PI * 2 + gameTime;
              projectiles.push({
                x: e.x, y: e.y,
                vx: Math.cos(ang) * 160, vy: Math.sin(ang) * 160,
                dmg: 1, friendly: false, life: 2.5, r: 6, color: '#e04060',
              });
            }
            AudioFX.shoot();
            spawnParticles(e.x, e.y, 12, '#a02040', 80, 0.4, 3);
          }
        } else {
          // wander home
          const a = angTo(e.x, e.y, e.homeX, e.homeY);
          if (dist(e.x, e.y, e.homeX, e.homeY) > 10)
            tryMove(e, Math.cos(a) * e.spd * 0.4 * dt, Math.sin(a) * e.spd * 0.4 * dt, e.radius);
        }
      } else if (e.ai === 'dart') {
        if (e.timer <= 0) {
          e.timer = rand(0.6, 1.4);
          if (dPlayer < aggro) {
            const a = angTo(e.x, e.y, player.x, player.y) + rand(-0.5, 0.5);
            e.vx = Math.cos(a) * 220;
            e.vy = Math.sin(a) * 220;
          } else {
            const a = Math.random() * Math.PI * 2;
            e.vx = Math.cos(a) * 120;
            e.vy = Math.sin(a) * 120;
          }
        }
        tryMove(e, e.vx * dt, e.vy * dt, e.radius);
        if (dPlayer < e.radius + player.radius + 2 && e.cd <= 0) {
          hurtPlayer(e.dmg, e.x, e.y);
          e.cd = 0.8;
        }
      } else if (e.ai === 'shoot') {
        if (dPlayer < aggro) {
          const a = angTo(e.x, e.y, player.x, player.y);
          e.facing = a;
          // keep distance
          if (dPlayer < 100) tryMove(e, -Math.cos(a) * e.spd * dt, -Math.sin(a) * e.spd * dt, e.radius);
          else if (dPlayer > 160) tryMove(e, Math.cos(a) * e.spd * dt, Math.sin(a) * e.spd * dt, e.radius);
          if (e.cd <= 0) {
            e.cd = 1.4;
            projectiles.push({
              x: e.x, y: e.y,
              vx: Math.cos(a) * 200, vy: Math.sin(a) * 200,
              dmg: 1, friendly: false, life: 2, r: 5, color: '#80e0ff',
            });
            AudioFX.shoot();
          }
        }
        tryMove(e, e.vx * dt, e.vy * dt, e.radius);
      }
    }

    // projectiles
    for (const p of projectiles) {
      p.life -= dt;
      p.x += p.vx * dt;
      p.y += p.vy * dt;
      if (solidTile(tileAt(p.x, p.y))) { p.life = 0; spawnParticles(p.x, p.y, 4, p.color, 40, 0.2, 2); continue; }
      if (p.friendly) {
        for (const e of world.enemies) {
          if (!e.alive) continue;
          if (dist(p.x, p.y, e.x, e.y) < e.radius + p.r) {
            damageEnemy(e, p.dmg, Math.atan2(p.vy, p.vx), 120);
            p.life = 0;
            break;
          }
        }
      } else {
        if (dist(p.x, p.y, player.x, player.y) < player.radius + p.r) {
          hurtPlayer(p.dmg, p.x, p.y);
          p.life = 0;
        }
      }
    }
    for (let i = projectiles.length - 1; i >= 0; i--) if (projectiles[i].life <= 0) projectiles.splice(i, 1);

    // bombs
    for (const b of bombs) {
      b.t -= dt;
      if (b.t <= 0) explodeBomb(b);
    }
    for (let i = bombs.length - 1; i >= 0; i--) if (bombs[i].t <= 0) bombs.splice(i, 1);

    // particles
    for (const p of particles) {
      p.life -= dt;
      p.x += p.vx * dt;
      p.y += p.vy * dt;
      p.vy += (p.gravity || 80) * dt;
      p.vx *= 0.98; p.vy *= 0.98;
    }
    for (let i = particles.length - 1; i >= 0; i--) if (particles[i].life <= 0) particles.splice(i, 1);

    for (const f of floaters) {
      f.life -= dt;
      f.y += f.vy * dt;
    }
    for (let i = floaters.length - 1; i >= 0; i--) if (floaters[i].life <= 0) floaters.splice(i, 1);

    // save pulse
    for (const e of world.entities) {
      if (e.type === 'save') e.pulse += dt * 3;
    }

    // camera
    camera.x = clamp(player.x - VIEW_W / 2, 0, COLS * TILE - VIEW_W);
    camera.y = clamp(player.y - VIEW_H / 2, 0, ROWS * TILE - VIEW_H);

    // soft zone toast
    const z = currentZoneName();
    if (z !== player._lastZone) {
      player._lastZone = z;
      document.getElementById('zone-display').textContent = z;
      if (player._zinit) toast(z);
      player._zinit = true;
    }
  }

  // ============================================================
  // RENDER
  // ============================================================
  const tileColors = {
    [T.GRASS]: ['#3d6b3a', '#355f33'],
    [T.PATH]: ['#8a7a58', '#7a6a4c'],
    [T.WATER]: ['#2a5a8a', '#254e78'],
    [T.WALL]: ['#4a4a58', '#3a3a48'],
    [T.TREE]: ['#2d5a2a', '#244a22'],
    [T.SAND]: ['#c2b280', '#b0a070'],
    [T.FLOOR]: ['#5a5858', '#4a4848'],
    [T.LAVA]: ['#c04010', '#a03010'],
    [T.BRIDGE]: ['#6a5030', '#5a4028'],
    [T.BUSH]: ['#3a7a38', '#326832'],
    [T.ROCK]: ['#606068', '#505058'],
    [T.FLOWER]: ['#3d6b3a', '#355f33'],
    [T.CARPET]: ['#6a3050', '#5a2844'],
    [T.SNOW]: ['#d0d8e0', '#c0c8d0'],
    [T.ASH]: ['#4a4440', '#3e3936'],
    [T.SPIKE]: ['#5a5050', '#4a4040'],
  };

  function draw() {
    ctx.clearRect(0, 0, VIEW_W, VIEW_H);
    if (!world || !player) {
      ctx.fillStyle = '#0b0e14';
      ctx.fillRect(0, 0, VIEW_W, VIEW_H);
      return;
    }

    const camX = camera.x, camY = camera.y;
    const t0x = Math.floor(camX / TILE);
    const t0y = Math.floor(camY / TILE);
    const t1x = Math.ceil((camX + VIEW_W) / TILE);
    const t1y = Math.ceil((camY + VIEW_H) / TILE);

    // tiles
    for (let ty = t0y; ty <= t1y; ty++) {
      for (let tx = t0x; tx <= t1x; tx++) {
        if (!inBounds(tx, ty)) continue;
        const t = world.tiles[ty][tx];
        const c = tileColors[t] || tileColors[T.GRASS];
        const px = tx * TILE - camX;
        const py = ty * TILE - camY;
        const checker = (tx + ty) % 2;
        ctx.fillStyle = c[checker];
        ctx.fillRect(px, py, TILE + 0.5, TILE + 0.5);

        // details
        if (t === T.WATER) {
          ctx.fillStyle = 'rgba(150,200,255,0.12)';
          const wo = Math.sin(gameTime * 2 + tx * 0.7 + ty * 0.5) * 3;
          ctx.fillRect(px + 8, py + 14 + wo, 12, 4);
          ctx.fillRect(px + 26, py + 28 - wo, 10, 3);
        } else if (t === T.LAVA) {
          ctx.fillStyle = `rgba(255,180,40,${0.3 + Math.sin(gameTime * 4 + tx) * 0.15})`;
          ctx.fillRect(px + 4, py + 4, TILE - 8, TILE - 8);
        } else if (t === T.TREE) {
          ctx.fillStyle = '#1a3a18';
          ctx.fillRect(px + 20, py + 28, 8, 14);
          ctx.fillStyle = '#2f7a2c';
          ctx.beginPath();
          ctx.arc(px + 24, py + 20, 16, 0, Math.PI * 2);
          ctx.fill();
          ctx.fillStyle = '#3a9a34';
          ctx.beginPath();
          ctx.arc(px + 18, py + 18, 10, 0, Math.PI * 2);
          ctx.fill();
        } else if (t === T.BUSH) {
          ctx.fillStyle = '#2f8030';
          ctx.beginPath();
          ctx.arc(px + 24, py + 28, 14, 0, Math.PI * 2);
          ctx.fill();
        } else if (t === T.ROCK) {
          ctx.fillStyle = '#707078';
          ctx.beginPath();
          ctx.moveTo(px + 10, py + 36);
          ctx.lineTo(px + 18, py + 12);
          ctx.lineTo(px + 36, py + 16);
          ctx.lineTo(px + 40, py + 36);
          ctx.fill();
        } else if (t === T.FLOWER) {
          ctx.fillStyle = '#e080b0';
          ctx.beginPath();
          ctx.arc(px + 18, py + 22, 3, 0, Math.PI * 2);
          ctx.arc(px + 30, py + 30, 3, 0, Math.PI * 2);
          ctx.fill();
        } else if (t === T.SPIKE) {
          ctx.fillStyle = '#bbb';
          for (let i = 0; i < 3; i++) {
            const sx = px + 12 + i * 12;
            ctx.beginPath();
            ctx.moveTo(sx, py + 36);
            ctx.lineTo(sx + 5, py + 14);
            ctx.lineTo(sx + 10, py + 36);
            ctx.fill();
          }
        } else if (t === T.WALL) {
          ctx.fillStyle = 'rgba(0,0,0,0.25)';
          ctx.fillRect(px, py + TILE - 10, TILE, 10);
          ctx.strokeStyle = 'rgba(255,255,255,0.05)';
          ctx.strokeRect(px + 1, py + 1, TILE - 2, TILE - 2);
        }
      }
    }

    // entities under
    const drawList = [];

    for (const e of world.entities) {
      drawList.push({ y: e.y, draw: () => drawEntity(e, camX, camY) });
    }
    for (const p of world.pickups) {
      if (p.taken) continue;
      drawList.push({ y: p.y, draw: () => drawPickup(p, camX, camY) });
    }
    for (const e of world.enemies) {
      if (!e.alive) continue;
      drawList.push({ y: e.y, draw: () => drawEnemy(e, camX, camY) });
    }
    for (const b of bombs) {
      drawList.push({ y: b.y, draw: () => drawBomb(b, camX, camY) });
    }
    drawList.push({ y: player.y, draw: () => drawPlayer(camX, camY) });

    drawList.sort((a, b) => a.y - b.y);
    for (const d of drawList) d.draw();

    // projectiles
    for (const p of projectiles) {
      const px = p.x - camX, py = p.y - camY;
      ctx.fillStyle = p.color;
      ctx.beginPath();
      ctx.arc(px, py, p.r, 0, Math.PI * 2);
      ctx.fill();
      ctx.fillStyle = 'rgba(255,255,255,0.5)';
      ctx.beginPath();
      ctx.arc(px - 1, py - 1, p.r * 0.4, 0, Math.PI * 2);
      ctx.fill();
    }

    // attack arc
    if (player.attackT > 0) {
      const w = WEAPONS[player.weapon];
      const px = player.x - camX, py = player.y - camY;
      const a0 = player.attackAng - w.arc;
      const a1 = player.attackAng + w.arc;
      ctx.strokeStyle = w.color;
      ctx.globalAlpha = player.attackT / 0.18 * 0.7;
      ctx.lineWidth = 4;
      ctx.beginPath();
      ctx.arc(px, py, w.range * 0.85, a0, a1);
      ctx.stroke();
      ctx.globalAlpha = 1;
    }

    // particles
    for (const p of particles) {
      ctx.globalAlpha = clamp(p.life / p.max, 0, 1);
      ctx.fillStyle = p.color;
      ctx.fillRect(p.x - camX - p.size / 2, p.y - camY - p.size / 2, p.size, p.size);
    }
    ctx.globalAlpha = 1;

    // floaters
    ctx.font = '12px Share Tech Mono, monospace';
    ctx.textAlign = 'center';
    for (const f of floaters) {
      ctx.globalAlpha = clamp(f.life / 0.9, 0, 1);
      ctx.fillStyle = f.color;
      ctx.fillText(f.text, f.x - camX, f.y - camY);
    }
    ctx.globalAlpha = 1;

    // interaction prompt
    let prompt = null;
    for (const e of world.entities) {
      if (dist(player.x, player.y, e.x, e.y) > 36) continue;
      if (e.type === 'npc') prompt = `E — Talk (${e.name})`;
      else if (e.type === 'save') prompt = 'E — Save Stone';
      else if (e.type === 'chest' && !e.open) prompt = 'E — Open Chest';
      else if (e.type === 'door' && !e.open) prompt = 'E — Unlock';
      else if (e.type === 'sign') prompt = 'E — Read';
      else if (e.type === 'seal') prompt = 'E — Examine Seal';
      if (prompt) break;
    }
    if (prompt) {
      ctx.fillStyle = 'rgba(0,0,0,0.55)';
      ctx.font = '13px Share Tech Mono, monospace';
      const tw = ctx.measureText(prompt).width;
      const px = player.x - camX;
      const py = player.y - camY - 40;
      ctx.fillRect(px - tw / 2 - 8, py - 14, tw + 16, 22);
      ctx.strokeStyle = '#4ecdc4';
      ctx.strokeRect(px - tw / 2 - 8, py - 14, tw + 16, 22);
      ctx.fillStyle = '#e8eef7';
      ctx.fillText(prompt, px, py + 2);
    }

    // boss hp bar
    const boss = world.enemies.find(e => e.kind === 'boss' && e.alive);
    if (boss && dist(player.x, player.y, boss.x, boss.y) < 320) {
      const bw = 320, bx = (VIEW_W - bw) / 2, by = 16;
      ctx.fillStyle = 'rgba(0,0,0,0.6)';
      ctx.fillRect(bx - 2, by - 2, bw + 4, 16);
      ctx.fillStyle = '#3a1018';
      ctx.fillRect(bx, by, bw, 12);
      ctx.fillStyle = '#e04060';
      ctx.fillRect(bx, by, bw * (boss.hp / boss.maxHp), 12);
      ctx.fillStyle = '#fff';
      ctx.font = '10px Press Start 2P, monospace';
      ctx.textAlign = 'center';
      ctx.fillText('BREACH WARDEN', VIEW_W / 2, by + 10);
    }

    drawMinimap();
  }

  function drawEntity(e, camX, camY) {
    const px = e.x - camX, py = e.y - camY;
    if (px < -40 || py < -40 || px > VIEW_W + 40 || py > VIEW_H + 40) return;

    if (e.type === 'npc') {
      // body
      ctx.fillStyle = e.color;
      ctx.beginPath();
      ctx.arc(px, py - 4, 12, 0, Math.PI * 2);
      ctx.fill();
      // head
      ctx.fillStyle = '#f0d0b0';
      ctx.beginPath();
      ctx.arc(px, py - 18, 8, 0, Math.PI * 2);
      ctx.fill();
      // label
      ctx.fillStyle = 'rgba(0,0,0,0.45)';
      ctx.font = '10px Share Tech Mono, monospace';
      ctx.textAlign = 'center';
      const nm = e.name.split(' ').pop();
      ctx.fillText(nm, px, py + 18);
      // shop icon
      if (e.shop) {
        ctx.fillStyle = '#f1c40f';
        ctx.fillText('₪', px + 12, py - 22);
      }
    } else if (e.type === 'save') {
      const pulse = 0.5 + Math.sin(e.pulse) * 0.5;
      ctx.strokeStyle = `rgba(100,220,255,${0.4 + pulse * 0.5})`;
      ctx.lineWidth = 2;
      ctx.beginPath();
      ctx.arc(px, py, 14 + pulse * 4, 0, Math.PI * 2);
      ctx.stroke();
      ctx.fillStyle = '#6ad0e8';
      ctx.beginPath();
      // stone diamond
      ctx.moveTo(px, py - 14);
      ctx.lineTo(px + 12, py);
      ctx.lineTo(px, py + 14);
      ctx.lineTo(px - 12, py);
      ctx.closePath();
      ctx.fill();
      ctx.fillStyle = `rgba(200,255,255,${0.5 + pulse * 0.4})`;
      ctx.beginPath();
      ctx.arc(px, py, 4, 0, Math.PI * 2);
      ctx.fill();
    } else if (e.type === 'chest') {
      ctx.fillStyle = e.open ? '#3a2a18' : '#a07830';
      ctx.fillRect(px - 12, py - 8, 24, 18);
      ctx.fillStyle = e.open ? '#2a1a10' : '#c4a040';
      ctx.fillRect(px - 12, py - 14, 24, 10);
      if (!e.open) {
        ctx.fillStyle = '#f1c40f';
        ctx.fillRect(px - 2, py - 6, 4, 6);
      }
    } else if (e.type === 'gate' || e.type === 'door') {
      if (e.open) {
        ctx.globalAlpha = 0.25;
      }
      ctx.fillStyle = e.color || '#888';
      ctx.fillRect(px - 18, py - 20, 36, 40);
      ctx.fillStyle = 'rgba(0,0,0,0.3)';
      ctx.fillRect(px - 6, py - 4, 12, 20);
      ctx.globalAlpha = 1;
      if (!e.open) {
        ctx.strokeStyle = '#fff4';
        ctx.strokeRect(px - 18, py - 20, 36, 40);
      }
    } else if (e.type === 'cracked') {
      if (e.broken) return;
      ctx.fillStyle = '#5a5a68';
      ctx.fillRect(px - 16, py - 16, 32, 32);
      ctx.strokeStyle = '#2a2a30';
      ctx.beginPath();
      ctx.moveTo(px - 8, py - 14);
      ctx.lineTo(px - 2, py);
      ctx.lineTo(px + 6, py - 8);
      ctx.lineTo(px + 10, py + 14);
      ctx.stroke();
      ctx.fillStyle = '#e08040';
      ctx.font = '10px sans-serif';
      ctx.textAlign = 'center';
      ctx.fillText('💣', px, py + 4);
    } else if (e.type === 'plate') {
      ctx.fillStyle = e.on ? '#80e0a0' : '#506070';
      ctx.beginPath();
      ctx.ellipse(px, py, 14, 10, 0, 0, Math.PI * 2);
      ctx.fill();
      ctx.strokeStyle = e.on ? '#c0ffe0' : '#304050';
      ctx.stroke();
    } else if (e.type === 'sign') {
      ctx.fillStyle = '#6a5030';
      ctx.fillRect(px - 2, py - 4, 4, 16);
      ctx.fillStyle = '#c4a060';
      ctx.fillRect(px - 10, py - 20, 20, 16);
    } else if (e.type === 'seal') {
      if (flags.boss_dead) {
        ctx.strokeStyle = '#80e0ff';
        ctx.globalAlpha = 0.7 + Math.sin(gameTime * 3) * 0.3;
      } else {
        ctx.strokeStyle = '#e04060';
        ctx.globalAlpha = 0.5 + Math.sin(gameTime * 5) * 0.3;
      }
      ctx.lineWidth = 3;
      ctx.beginPath();
      ctx.arc(px, py, 20, 0, Math.PI * 2);
      ctx.stroke();
      ctx.beginPath();
      ctx.arc(px, py, 10, 0, Math.PI * 2);
      ctx.stroke();
      ctx.globalAlpha = 1;
    }
  }

  function drawPickup(p, camX, camY) {
    const px = p.x - camX, py = p.y - camY + Math.sin(gameTime * 4 + p.x) * 3;
    if (p.type === 'gold') {
      ctx.fillStyle = '#f1c40f';
      ctx.beginPath();
      ctx.arc(px, py, 6, 0, Math.PI * 2);
      ctx.fill();
      ctx.fillStyle = '#fff8';
      ctx.beginPath();
      ctx.arc(px - 2, py - 2, 2, 0, Math.PI * 2);
      ctx.fill();
    } else {
      const it = ITEMS[p.id];
      ctx.font = '16px sans-serif';
      ctx.textAlign = 'center';
      ctx.fillText(it ? it.icon : '?', px, py + 5);
    }
  }

  function drawEnemy(e, camX, camY) {
    const px = e.x - camX, py = e.y - camY;
    const flash = e.hurt > 0;
    ctx.save();
    ctx.translate(px, py);
    if (flash) ctx.globalAlpha = 0.7;

    if (e.kind === 'slime') {
      ctx.fillStyle = flash ? '#fff' : e.color;
      ctx.beginPath();
      ctx.ellipse(0, 4, e.radius, e.radius * 0.75, 0, 0, Math.PI * 2);
      ctx.fill();
      ctx.fillStyle = '#1a1a1a';
      ctx.beginPath();
      ctx.arc(-4, 0, 2, 0, Math.PI * 2);
      ctx.arc(4, 0, 2, 0, Math.PI * 2);
      ctx.fill();
    } else if (e.kind === 'sprite') {
      ctx.fillStyle = flash ? '#fff' : e.color;
      const flap = Math.sin(gameTime * 12 + e.x) * 5;
      ctx.beginPath();
      ctx.ellipse(-10, flap, 8, 4, -0.4, 0, Math.PI * 2);
      ctx.ellipse(10, -flap, 8, 4, 0.4, 0, Math.PI * 2);
      ctx.fill();
      ctx.beginPath();
      ctx.arc(0, 0, 8, 0, Math.PI * 2);
      ctx.fill();
    } else if (e.kind === 'shade') {
      ctx.fillStyle = flash ? '#ccc' : e.color;
      ctx.beginPath();
      ctx.moveTo(0, -14);
      ctx.bezierCurveTo(14, -10, 12, 10, 0, 16);
      ctx.bezierCurveTo(-12, 10, -14, -10, 0, -14);
      ctx.fill();
      ctx.fillStyle = '#f44';
      ctx.beginPath();
      ctx.arc(-4, -2, 2.5, 0, Math.PI * 2);
      ctx.arc(4, -2, 2.5, 0, Math.PI * 2);
      ctx.fill();
    } else if (e.kind === 'crystal') {
      ctx.fillStyle = flash ? '#fff' : e.color;
      ctx.beginPath();
      ctx.moveTo(0, -16);
      ctx.lineTo(12, 0);
      ctx.lineTo(0, 16);
      ctx.lineTo(-12, 0);
      ctx.closePath();
      ctx.fill();
      ctx.fillStyle = '#fff8';
      ctx.beginPath();
      ctx.moveTo(0, -8);
      ctx.lineTo(5, 0);
      ctx.lineTo(0, 4);
      ctx.closePath();
      ctx.fill();
    } else if (e.kind === 'ashguard') {
      ctx.fillStyle = flash ? '#fff' : e.color;
      ctx.fillRect(-10, -12, 20, 24);
      ctx.fillStyle = '#222';
      ctx.fillRect(-10, -16, 20, 6);
      ctx.fillStyle = '#e08040';
      ctx.fillRect(10, -8, 14, 4);
    } else if (e.kind === 'boss') {
      const sc = 1 + Math.sin(gameTime * 2) * 0.03;
      ctx.scale(sc, sc);
      ctx.fillStyle = flash ? '#fff' : e.color;
      ctx.beginPath();
      ctx.arc(0, 0, e.radius, 0, Math.PI * 2);
      ctx.fill();
      ctx.fillStyle = '#2a0810';
      ctx.beginPath();
      ctx.arc(-8, -4, 5, 0, Math.PI * 2);
      ctx.arc(8, -4, 5, 0, Math.PI * 2);
      ctx.fill();
      ctx.fillStyle = '#f1c40f';
      ctx.beginPath();
      ctx.arc(-8, -4, 2, 0, Math.PI * 2);
      ctx.arc(8, -4, 2, 0, Math.PI * 2);
      ctx.fill();
      // horns
      ctx.fillStyle = '#601028';
      ctx.beginPath();
      ctx.moveTo(-12, -16); ctx.lineTo(-18, -30); ctx.lineTo(-4, -18);
      ctx.moveTo(12, -16); ctx.lineTo(18, -30); ctx.lineTo(4, -18);
      ctx.fill();
    }

    // hp bar
    if (e.hp < e.maxHp && e.kind !== 'boss') {
      ctx.fillStyle = '#000';
      ctx.fillRect(-12, -e.radius - 10, 24, 4);
      ctx.fillStyle = '#e74c3c';
      ctx.fillRect(-12, -e.radius - 10, 24 * (e.hp / e.maxHp), 4);
    }
    if (e.burn > 0) {
      ctx.fillStyle = '#e07030';
      ctx.globalAlpha = 0.6;
      ctx.beginPath();
      ctx.arc(rand(-6, 6), rand(-6, 6), 3, 0, Math.PI * 2);
      ctx.fill();
    }
    ctx.restore();
  }

  function drawBomb(b, camX, camY) {
    const px = b.x - camX, py = b.y - camY;
    const blink = b.t < 0.4 && Math.floor(gameTime * 10) % 2 === 0;
    ctx.fillStyle = blink ? '#fff' : '#333';
    ctx.beginPath();
    ctx.arc(px, py, 8, 0, Math.PI * 2);
    ctx.fill();
    ctx.fillStyle = '#e74c3c';
    ctx.fillRect(px - 1, py - 14, 2, 6);
    ctx.strokeStyle = `rgba(230,100,40,${0.3})`;
    ctx.beginPath();
    ctx.arc(px, py, b.r * (1 - b.t / 1.2) * 0.3 + 10, 0, Math.PI * 2);
    ctx.stroke();
  }

  function drawPlayer(camX, camY) {
    const px = player.x - camX;
    const py = player.y - camY;
    ctx.save();
    ctx.translate(px, py);

    // shadow
    ctx.fillStyle = 'rgba(0,0,0,0.3)';
    ctx.beginPath();
    ctx.ellipse(0, 12, 12, 5, 0, 0, Math.PI * 2);
    ctx.fill();

    if (player.invuln > 0 && Math.floor(gameTime * 20) % 2 === 0) {
      ctx.globalAlpha = 0.4;
    }

    // walk bob
    const bob = player.walking ? Math.sin(player.anim * 1.5) * 2 : 0;
    const dashStretch = player.dashT > 0 ? 1.2 : 1;

    // legs
    ctx.fillStyle = player.accessories.includes('boots') ? '#60a0e0' : '#2a3040';
    const leg = player.walking ? Math.sin(player.anim) * 4 : 0;
    ctx.fillRect(-7, 4 + bob, 5, 10 + leg);
    ctx.fillRect(2, 4 + bob, 5, 10 - leg);

    // body color by weapon tier
    const bodyCol = player.weapon === 'storm' ? '#2a4060'
      : player.weapon === 'ember' ? '#5a3020' : '#3a4a5a';
    ctx.fillStyle = player.hurtFlash > 0 ? '#e74c3c' : bodyCol;
    ctx.beginPath();
    ctx.ellipse(0, -2 + bob, 11 * dashStretch, 13, 0, 0, Math.PI * 2);
    ctx.fill();

    // scarf / amulet
    if (player.accessories.includes('amulet')) {
      ctx.fillStyle = '#e0c060';
      ctx.beginPath();
      ctx.arc(0, 2 + bob, 4, 0, Math.PI * 2);
      ctx.fill();
    }
    if (player.accessories.includes('charm')) {
      ctx.fillStyle = '#4c4';
      ctx.fillRect(8, -8 + bob, 5, 5);
    }
    if (player.accessories.includes('ring')) {
      ctx.fillStyle = '#e06080';
      ctx.beginPath();
      ctx.arc(-8, 0 + bob, 3, 0, Math.PI * 2);
      ctx.fill();
    }

    // head
    ctx.fillStyle = '#f0d0b0';
    ctx.beginPath();
    ctx.arc(0, -16 + bob, 8, 0, Math.PI * 2);
    ctx.fill();

    // hair / hat
    ctx.fillStyle = '#2a2030';
    ctx.beginPath();
    ctx.arc(0, -20 + bob, 7, Math.PI, 0);
    ctx.fill();

    // facing eyes
    const ex = Math.cos(player.facing) * 2;
    const ey = Math.sin(player.facing) * 1.5;
    ctx.fillStyle = '#1a1a1a';
    ctx.beginPath();
    ctx.arc(-3 + ex, -16 + bob + ey, 1.5, 0, Math.PI * 2);
    ctx.arc(3 + ex, -16 + bob + ey, 1.5, 0, Math.PI * 2);
    ctx.fill();

    // weapon visual
    const w = WEAPONS[player.weapon];
    ctx.save();
    ctx.translate(0, -2 + bob);
    ctx.rotate(player.facing + (player.attackT > 0 ? (0.18 - player.attackT) / 0.18 * w.arc * 2 - w.arc : 0.3));
    if (player.mode === 'melee' || player.attackT > 0) {
      ctx.fillStyle = w.color;
      ctx.fillRect(8, -3, w.range * 0.55, 6);
      ctx.fillStyle = '#ddd';
      ctx.fillRect(8 + w.range * 0.55 - 4, -5, 8, 10);
      if (player.weapon === 'ember') {
        ctx.fillStyle = '#ff8020';
        ctx.globalAlpha = 0.7;
        ctx.beginPath();
        ctx.arc(8 + w.range * 0.5, 0, 4, 0, Math.PI * 2);
        ctx.fill();
        ctx.globalAlpha = 1;
      }
      if (player.weapon === 'storm') {
        ctx.strokeStyle = '#a0e0ff';
        ctx.lineWidth = 2;
        ctx.beginPath();
        ctx.moveTo(12, -6);
        ctx.lineTo(18, 0);
        ctx.lineTo(14, 6);
        ctx.stroke();
      }
    } else if (player.mode === 'ranged' && player.ranged) {
      // bow
      ctx.strokeStyle = '#d0a060';
      ctx.lineWidth = 3;
      ctx.beginPath();
      ctx.arc(14, 0, 12, -1.2, 1.2);
      ctx.stroke();
      ctx.beginPath();
      ctx.moveTo(14 + Math.cos(-1.2) * 12, Math.sin(-1.2) * 12);
      ctx.lineTo(14 + Math.cos(1.2) * 12, Math.sin(1.2) * 12);
      ctx.stroke();
    }
    ctx.restore();

    ctx.restore();
  }

  function drawMinimap() {
    const w = 120, h = 90;
    mmCtx.fillStyle = 'rgba(8,10,16,0.9)';
    mmCtx.fillRect(0, 0, w, h);
    const sx = w / (COLS * TILE);
    const sy = h / (ROWS * TILE);
    // simplified tiles
    for (let ty = 0; ty < ROWS; ty += 1) {
      for (let tx = 0; tx < COLS; tx += 1) {
        const t = world.tiles[ty][tx];
        if (t === T.WALL || t === T.ROCK || t === T.TREE) mmCtx.fillStyle = '#3a4558';
        else if (t === T.WATER) mmCtx.fillStyle = '#1a3a5a';
        else if (t === T.LAVA) mmCtx.fillStyle = '#8a3010';
        else if (t === T.PATH || t === T.FLOOR || t === T.BRIDGE) mmCtx.fillStyle = '#4a5840';
        else if (t === T.ASH) mmCtx.fillStyle = '#3a3836';
        else mmCtx.fillStyle = '#2a3828';
        mmCtx.fillRect(tx * TILE * sx, ty * TILE * sy, Math.ceil(TILE * sx), Math.ceil(TILE * sy));
      }
    }
    // secrets if map
    if (hasItem('map')) {
      for (const e of world.entities) {
        if (e.type === 'chest' && !e.open) {
          mmCtx.fillStyle = '#f1c40f';
          mmCtx.fillRect(e.x * sx - 1, e.y * sy - 1, 3, 3);
        }
        if (e.type === 'save') {
          mmCtx.fillStyle = '#6ad0e8';
          mmCtx.fillRect(e.x * sx - 1, e.y * sy - 1, 3, 3);
        }
      }
    }
    // enemies
    mmCtx.fillStyle = '#e74c3c';
    for (const e of world.enemies) {
      if (!e.alive) continue;
      mmCtx.fillRect(e.x * sx - 1, e.y * sy - 1, 2, 2);
    }
    // player
    mmCtx.fillStyle = '#fff';
    mmCtx.beginPath();
    mmCtx.arc(player.x * sx, player.y * sy, 2.5, 0, Math.PI * 2);
    mmCtx.fill();
    // camera rect
    mmCtx.strokeStyle = 'rgba(255,255,255,0.25)';
    mmCtx.strokeRect(camera.x * sx, camera.y * sy, VIEW_W * sx, VIEW_H * sy);
  }

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

    AudioFX.resume();

    if (state === 'dialogue') {
      if (e.code === 'KeyE' || e.code === 'Space' || e.code === 'Enter') advanceDialogue();
      return;
    }
    if (state === 'title' || state === 'end') return;

    if (e.code === 'Escape') {
      if (state === 'inv') closeInventory();
      else if (state === 'shop') closeShop();
      else if (state === 'pause') {
        document.getElementById('overlay-pause').classList.add('hidden');
        state = 'play';
      } else if (state === 'play') {
        state = 'pause';
        document.getElementById('overlay-pause').classList.remove('hidden');
        document.getElementById('btn-save-manual').disabled = !canSaveHere();
      }
      return;
    }
    if (state === 'pause' || state === 'shop' || state === 'inv') {
      if (e.code === 'KeyI' && state === 'inv') closeInventory();
      return;
    }

    if (state !== 'play') return;

    if (e.code === 'KeyE') interact();
    if (e.code === 'KeyI') openInventory();
    if (e.code === 'Digit1') { player.mode = 'melee'; updateHUD(); }
    if (e.code === 'Digit2') {
      if (player.ranged) { player.mode = 'ranged'; updateHUD(); }
      else toast('No ranged weapon yet.');
    }
    if (e.code === 'KeyQ') {
      // quick potion
      if (hasItem('potion')) {
        if (ITEMS.potion.use(player)) {
          takeItem('potion');
          updateHUD();
          toast('Used Life Vial');
        } else toast('HP is full.');
      } else toast('No Life Vials.');
    }
    if (e.code === 'KeyF') placeBomb();
  });

  window.addEventListener('keyup', (e) => { keys[e.code] = false; });

  canvas.addEventListener('mousemove', (e) => {
    const rect = canvas.getBoundingClientRect();
    const sx = canvas.width / rect.width;
    const sy = canvas.height / rect.height;
    mouse.x = (e.clientX - rect.left) * sx;
    mouse.y = (e.clientY - rect.top) * sy;
    mouse.worldX = mouse.x + camera.x;
    mouse.worldY = mouse.y + camera.y;
  });
  canvas.addEventListener('mousedown', (e) => {
    mouse.down = true;
    AudioFX.resume();
    if (state === 'dialogue') { advanceDialogue(); return; }
    if (state === 'play') {
      // update facing immediately
      const rect = canvas.getBoundingClientRect();
      const sx = canvas.width / rect.width;
      const sy = canvas.height / rect.height;
      mouse.x = (e.clientX - rect.left) * sx;
      mouse.y = (e.clientY - rect.top) * sy;
      mouse.worldX = mouse.x + camera.x;
      mouse.worldY = mouse.y + camera.y;
      player.facing = angTo(player.x, player.y, mouse.worldX, mouse.worldY);
      playerAttack();
    }
  });
  window.addEventListener('mouseup', () => { mouse.down = false; });

  document.getElementById('dialogue').addEventListener('click', () => {
    if (state === 'dialogue') advanceDialogue();
  });

  // UI buttons
  document.getElementById('btn-new').addEventListener('click', () => {
    document.getElementById('overlay-title').classList.add('hidden');
    newGame();
  });
  document.getElementById('btn-continue').addEventListener('click', () => {
    if (loadGame()) {
      document.getElementById('overlay-title').classList.add('hidden');
      state = 'play';
      toast('Save loaded.');
      AudioFX.resume();
    }
  });
  document.getElementById('btn-close-inv').addEventListener('click', closeInventory);
  document.getElementById('btn-use-item').addEventListener('click', useSelectedItem);
  document.getElementById('btn-close-shop').addEventListener('click', closeShop);
  document.getElementById('btn-resume').addEventListener('click', () => {
    document.getElementById('overlay-pause').classList.add('hidden');
    state = 'play';
  });
  document.getElementById('btn-save-manual').addEventListener('click', () => {
    if (canSaveHere()) saveGame();
    else toast('Stand by a Save Stone to save.');
  });
  document.getElementById('btn-to-title').addEventListener('click', () => {
    document.getElementById('overlay-pause').classList.add('hidden');
    document.getElementById('overlay-title').classList.remove('hidden');
    document.getElementById('btn-continue').disabled = !hasSave();
    state = 'title';
  });
  document.getElementById('btn-end-title').addEventListener('click', () => {
    document.getElementById('overlay-end').classList.add('hidden');
    document.getElementById('overlay-title').classList.remove('hidden');
    document.getElementById('btn-continue').disabled = !hasSave();
    state = 'title';
  });

  // ============================================================
  // LOOP
  // ============================================================
  function frame(t) {
    const dt = Math.min(0.033, (t - lastT) / 1000 || 0);
    lastT = t;
    // refresh mouse world
    mouse.worldX = mouse.x + camera.x;
    mouse.worldY = mouse.y + camera.y;
    update(dt);
    draw();
    requestAnimationFrame(frame);
  }

  // init
  document.getElementById('btn-continue').disabled = !hasSave();
  requestAnimationFrame(frame);

  // Debug/test hooks (read-only-ish helpers)
  window.__ember = {
    get state() { return state; },
    get player() { return player; },
    get flags() { return flags; },
    get questId() { return questId; },
    get world() { return world; },
    newGame,
    saveGame,
    loadGame,
    giveItem,
    updateHUD,
    toast,
    setQuest: (q) => { questId = q; updateQuestUI(); },
  };
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"x-ai/grok-4.5","provider":"openrouter","persona":"full","sessionId":"2b644906-ddce-47ac-8085-cb4438ad7589","tokensIn":2645594,"tokensOut":52250,"tokensTotal":2697844,"turns":43,"toolCalls":42,"failedToolCalls":10,"timestamp":"2026-07-18T16:32:27.561Z"} -->