← RPG results

RPG

Grok 4.6 · 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 81,436 bytes · SHA-256 af44043be54a
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Emberwake — A Short Adventure</title>
<style>
  :root {
    --ink: #1a1410;
    --parchment: #e8d5a3;
    --gold: #d4a017;
    --blood: #c23b22;
    --moss: #2d4a3e;
  }
  * { box-sizing: border-box; margin: 0; padding: 0; }
  html, body {
    width: 100%; height: 100%; overflow: hidden;
    background: #0b0a09;
    font-family: "Palatino Linotype", Palatino, "Book Antiqua", Georgia, serif;
    color: var(--parchment);
    user-select: none;
  }
  #wrap {
    width: 100%; height: 100%;
    display: flex; align-items: center; justify-content: center;
    background:
      radial-gradient(ellipse at 50% 40%, #1c1612 0%, #0b0a09 70%);
  }
  canvas {
    display: block;
    image-rendering: pixelated;
    image-rendering: crisp-edges;
    box-shadow: 0 0 0 3px #3a2e22, 0 0 0 6px #1a1410, 0 20px 60px rgba(0,0,0,.7);
    background: #111;
    max-width: 100vw; max-height: 100vh;
  }
  #overlay {
    position: fixed; inset: 0; pointer-events: none;
    display: flex; align-items: center; justify-content: center;
  }
  .panel {
    pointer-events: auto;
    background: linear-gradient(180deg, #2a2118 0%, #1a1410 100%);
    border: 3px solid #c9a227;
    box-shadow: 0 0 0 2px #1a1410, 0 16px 48px rgba(0,0,0,.75), inset 0 0 40px rgba(0,0,0,.35);
    color: #f0e6c8;
    padding: 28px 36px;
    max-width: 560px;
    width: min(92vw, 560px);
  }
  .panel h1 {
    font-size: 42px; letter-spacing: 4px; text-align: center;
    color: #f3c14a; text-shadow: 0 2px 0 #5a3208, 0 0 24px rgba(212,160,23,.4);
    margin-bottom: 6px;
  }
  .panel .sub { text-align: center; color: #b9a57a; margin-bottom: 18px; font-style: italic; }
  .panel p { line-height: 1.5; margin: 8px 0; font-size: 15px; }
  .panel kbd {
    display: inline-block; background: #3a2e22; border: 1px solid #7a6544;
    padding: 1px 7px; border-radius: 3px; font-family: ui-monospace, monospace; font-size: 12px;
    color: #f3e2b0;
  }
  .btn {
    display: block; width: 100%; margin-top: 14px; padding: 12px;
    background: linear-gradient(180deg, #c9a227, #8a6a12);
    color: #1a1204; font-weight: 700; font-size: 16px; letter-spacing: 2px;
    border: 2px solid #f3d98a; cursor: pointer; font-family: inherit;
  }
  .btn:hover { filter: brightness(1.12); }
  .btn.ghost {
    background: transparent; color: #e8d5a3; border-color: #7a6544; font-weight: 500;
  }
  .hidden { display: none !important; }
  #toast {
    position: fixed; top: 18%; left: 50%; transform: translateX(-50%);
    background: rgba(20,14,10,.88); border: 1px solid #c9a227; color: #f3e2b0;
    padding: 8px 18px; pointer-events: none; font-size: 15px; letter-spacing: 1px;
    opacity: 0; transition: opacity .25s; z-index: 5;
  }
  #toast.show { opacity: 1; }
</style>
</head>
<body>
<div id="wrap"><canvas id="c" width="960" height="640"></canvas></div>
<div id="toast"></div>
<div id="overlay">
  <div class="panel" id="titlePanel">
    <h1>EMBERWAKE</h1>
    <div class="sub">A one-sitting action-adventure</div>
    <p>The village of Brackenford sleeps under a dying sun. Something in the old temple has begun to breathe again. Take up a blade. Walk the wilds. Come back changed.</p>
    <p style="text-align:center;line-height:1.9">
      <kbd>WASD</kbd> move
      <kbd>J</kbd> sword
      <kbd>K</kbd> bow
      <kbd>L</kbd> dash
      <kbd>E</kbd> interact
      <kbd>I</kbd> pack
      <kbd>H</kbd> tonic
      <kbd>B</kbd> bomb
    </p>
    <button class="btn" id="btnNew">NEW JOURNEY</button>
    <button class="btn ghost hidden" id="btnCont">CONTINUE</button>
  </div>
</div>
<script>
(() => {
"use strict";

const canvas = document.getElementById("c");
const ctx = canvas.getContext("2d");
const overlay = document.getElementById("overlay");
const titlePanel = document.getElementById("titlePanel");
const btnNew = document.getElementById("btnNew");
const btnCont = document.getElementById("btnCont");
const toastEl = document.getElementById("toast");

const TW = 48, TH = 48;
const COLS = 16, ROWS = 11;
const MAP_OX = 96, MAP_OY = 72;
const VIEW_W = COLS * TW, VIEW_H = ROWS * TH;
const SAVE_KEY = "emberwake_v1";

const TILE = {
  GRASS: 0, DIRT: 1, WATER: 2, TREE: 3, STONE: 4, CAVE: 5,
  SAND: 6, CRACK: 7, TALL: 8, LAVA: 9, WOOD: 10, FLOWER: 11,
  FENCE: 12, DARK: 13, SHRINE: 14, CARPET: 15, WALL: 16,
  RUIN: 17, SNOW: 18, ICE: 19, PILLAR: 20, HEARTH: 21, PATH: 22
};

const SOLID = new Set([
  TILE.WATER, TILE.TREE, TILE.CAVE, TILE.FENCE,
  TILE.WALL, TILE.PILLAR, TILE.HEARTH, TILE.CRACK
]);

// ---------------------------------------------------------------------------
// Audio (synthesized)
// ---------------------------------------------------------------------------
const AudioFX = {
  ctx: null,
  muted: false,
  stepT: 0,
  ensure() {
    if (!this.ctx) {
      const AC = window.AudioContext || window.webkitAudioContext;
      if (AC) this.ctx = new AC();
    }
    if (this.ctx && this.ctx.state === "suspended") this.ctx.resume();
  },
  beep(freq, dur, type, vol, slide, delay) {
    if (this.muted || !this.ctx) return;
    const t = this.ctx.currentTime + (delay || 0);
    const o = this.ctx.createOscillator();
    const g = this.ctx.createGain();
    o.type = type || "square";
    o.frequency.setValueAtTime(freq, t);
    if (slide) o.frequency.exponentialRampToValueAtTime(Math.max(40, slide), t + dur);
    g.gain.setValueAtTime(vol || 0.08, t);
    g.gain.exponentialRampToValueAtTime(0.001, t + dur);
    o.connect(g); g.connect(this.ctx.destination);
    o.start(t); o.stop(t + dur + 0.02);
  },
  noise(dur, vol, hp) {
    if (this.muted || !this.ctx) return;
    const n = this.ctx.sampleRate * dur;
    const buf = this.ctx.createBuffer(1, n, this.ctx.sampleRate);
    const d = buf.getChannelData(0);
    for (let i = 0; i < n; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / n);
    const src = this.ctx.createBufferSource();
    src.buffer = buf;
    const f = this.ctx.createBiquadFilter();
    f.type = "highpass"; f.frequency.value = hp || 400;
    const g = this.ctx.createGain();
    g.gain.value = vol || 0.06;
    src.connect(f); f.connect(g); g.connect(this.ctx.destination);
    src.start();
  },
  walk() {
    const now = performance.now();
    if (now - this.stepT < 220) return;
    this.stepT = now;
    this.beep(90 + Math.random() * 30, 0.05, "triangle", 0.03);
  },
  sword() { this.beep(320, 0.08, "square", 0.07, 140); this.noise(0.06, 0.04, 800); },
  bow() { this.beep(520, 0.12, "sawtooth", 0.05, 180); },
  hit() { this.beep(180, 0.1, "square", 0.09, 70); this.noise(0.08, 0.07, 200); },
  hurt() { this.beep(140, 0.18, "sawtooth", 0.1, 50); },
  pickup() { this.beep(660, 0.08, "sine", 0.07); this.beep(880, 0.1, "sine", 0.06, null, 0.07); },
  coin() { this.beep(980, 0.06, "square", 0.05); this.beep(1320, 0.08, "square", 0.04, null, 0.05); },
  shop() { this.beep(440, 0.08, "triangle", 0.06); this.beep(554, 0.1, "triangle", 0.05, null, 0.08); this.beep(659, 0.14, "triangle", 0.05, null, 0.16); },
  save() { this.beep(392, 0.12, "sine", 0.06); this.beep(523, 0.14, "sine", 0.05, null, 0.1); this.beep(659, 0.22, "sine", 0.05, null, 0.2); },
  heal() { this.beep(523, 0.1, "sine", 0.06); this.beep(659, 0.14, "sine", 0.05, null, 0.08); },
  unlock() { this.beep(300, 0.08, "square", 0.06, 600); },
  boom() { this.noise(0.25, 0.12, 80); this.beep(80, 0.25, "sawtooth", 0.1, 40); },
  death() { this.beep(220, 0.4, "sawtooth", 0.08, 50); },
  win() {
    [523, 659, 784, 1046].forEach((f, i) => this.beep(f, 0.28, "triangle", 0.07, null, i * 0.14));
  }
};

// ---------------------------------------------------------------------------
// Particles
// ---------------------------------------------------------------------------
const particles = [];
function burst(x, y, n, cols, spd, life, size) {
  for (let i = 0; i < n; i++) {
    const a = Math.random() * Math.PI * 2;
    const s = Math.random() * spd;
    particles.push({
      x, y, vx: Math.cos(a) * s, vy: Math.sin(a) * s,
      life, max: life, r: size * (0.4 + Math.random()),
      col: cols[i % cols.length], g: 80 + Math.random() * 220
    });
  }
}
function spark(x, y, vx, vy, col) {
  particles.push({ x, y, vx, vy, life: 0.35, max: 0.35, r: 2.5, col, g: 200 });
}
function tickParticles(dt) {
  for (let i = particles.length - 1; i >= 0; i--) {
    const p = particles[i];
    p.life -= dt;
    p.x += p.vx * dt; p.y += p.vy * dt;
    p.vy += p.g * dt;
    if (p.life <= 0) particles.splice(i, 1);
  }
}
function drawParticles() {
  for (const p of particles) {
    ctx.globalAlpha = Math.max(0, p.life / p.max);
    ctx.fillStyle = p.col;
    ctx.beginPath();
    ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
    ctx.fill();
  }
  ctx.globalAlpha = 1;
}

// ---------------------------------------------------------------------------
// Input
// ---------------------------------------------------------------------------
const keys = Object.create(null);
const just = Object.create(null);
window.addEventListener("keydown", e => {
  if (["Space", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.code)) e.preventDefault();
  if (!keys[e.code]) just[e.code] = true;
  keys[e.code] = true;
  AudioFX.ensure();
});
window.addEventListener("keyup", e => { keys[e.code] = false; });
function pressed(code) { return !!just[code]; }
function down(code) { return !!keys[code]; }
function flushJust() { for (const k in just) delete just[k]; }

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
const lerp = (a, b, t) => a + (b - a) * t;
const dist = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
const rand = (a, b) => a + Math.random() * (b - a);
const irand = (a, b) => (a + Math.floor(Math.random() * (b - a + 1)));
const choice = a => a[Math.floor(Math.random() * a.length)];

function toast(msg, ms) {
  toastEl.textContent = msg;
  toastEl.classList.add("show");
  clearTimeout(toast._t);
  toast._t = setTimeout(() => toastEl.classList.remove("show"), ms || 1800);
}

function heartPath(ctx, x, y, s) {
  ctx.beginPath();
  ctx.moveTo(x, y + s * 0.3);
  ctx.bezierCurveTo(x, y, x - s, y, x - s, y + s * 0.35);
  ctx.bezierCurveTo(x - s, y + s * 0.7, x, y + s * 0.95, x, y + s * 1.15);
  ctx.bezierCurveTo(x, y + s * 0.95, x + s, y + s * 0.7, x + s, y + s * 0.35);
  ctx.bezierCurveTo(x + s, y, x, y, x, y + s * 0.3);
  ctx.closePath();
}

// ---------------------------------------------------------------------------
// World generation
// ---------------------------------------------------------------------------
const MAP_W = 5, MAP_H = 4;
// room ids: x + y * MAP_W
//  0 caveW   1 cave    2 caveE   3 peak    4 temple
//  5 forestW 6 forest  7 village 8 meadow  9 ruinsN
// 10 grove  11 lake   12 south  13 cliff  14 ruins
// 15 swamp  16 shrine 17 farm   18 gate   19 ruinsS

const ROOM_META = [
  { name: "West Hollow", biome: "cave", dark: true },
  { name: "Sunken Gallery", biome: "cave", dark: true },
  { name: "Echo Mouth", biome: "cave", dark: true },
  { name: "Ashen Peak", biome: "peak" },
  { name: "Ember Temple", biome: "temple" },
  { name: "Deepwood", biome: "forest" },
  { name: "Old Growth", biome: "forest" },
  { name: "Brackenford", biome: "village" },
  { name: "Sunny Lea", biome: "meadow" },
  { name: "North Cloister", biome: "ruins" },
  { name: "Smith's Grove", biome: "grove" },
  { name: "Mirror Lake", biome: "lake" },
  { name: "South Road", biome: "field" },
  { name: "Windcliff", biome: "cliff" },
  { name: "Broken Court", biome: "ruins" },
  { name: "Mirefen", biome: "swamp" },
  { name: "Wayside Shrine", biome: "shrine" },
  { name: "Barley Farm", biome: "farm" },
  { name: "Ruin Gate", biome: "field" },
  { name: "Catacomb Yard", biome: "ruins" }
];

function fillRoom(id) {
  const tiles = new Uint8Array(COLS * ROWS);
  const meta = ROOM_META[id];
  const biome = meta.biome;
  let base = TILE.GRASS;
  if (biome === "cave" || biome === "temple") base = TILE.STONE;
  if (biome === "peak") base = TILE.SNOW;
  if (biome === "ruins") base = TILE.RUIN;
  if (biome === "swamp") base = TILE.DIRT;
  if (biome === "lake") base = TILE.GRASS;
  if (biome === "village" || biome === "farm" || biome === "shrine") base = TILE.GRASS;
  if (biome === "cliff") base = TILE.DIRT;

  for (let i = 0; i < tiles.length; i++) tiles[i] = base;

  const set = (x, y, t) => { if (x >= 0 && y >= 0 && x < COLS && y < ROWS) tiles[y * COLS + x] = t; };
  const get = (x, y) => tiles[y * COLS + x];

  // scatter
  for (let y = 1; y < ROWS - 1; y++) {
    for (let x = 1; x < COLS - 1; x++) {
      const r = Math.random();
      if (biome === "meadow" || biome === "field" || biome === "farm") {
        if (r < 0.08) set(x, y, TILE.FLOWER);
        else if (r < 0.12) set(x, y, TILE.TALL);
        else if (r < 0.16) set(x, y, TILE.DIRT);
      } else if (biome === "forest" || biome === "grove") {
        if (r < 0.07) set(x, y, TILE.TREE);
        else if (r < 0.12) set(x, y, TILE.TALL);
        else if (r < 0.16) set(x, y, TILE.FLOWER);
      } else if (biome === "swamp") {
        if (r < 0.1) set(x, y, TILE.WATER);
        else if (r < 0.16) set(x, y, TILE.TALL);
        else if (r < 0.22) set(x, y, TILE.GRASS);
      } else if (biome === "cave") {
        if (r < 0.06) set(x, y, TILE.CAVE);
        else if (r < 0.1) set(x, y, TILE.DARK);
      } else if (biome === "ruins") {
        if (r < 0.07) set(x, y, TILE.WALL);
        else if (r < 0.12) set(x, y, TILE.STONE);
      } else if (biome === "peak") {
        if (r < 0.08) set(x, y, TILE.WALL);
        else if (r < 0.12) set(x, y, TILE.LAVA);
      } else if (biome === "temple") {
        if (r < 0.05) set(x, y, TILE.PILLAR);
        else if (r < 0.1) set(x, y, TILE.CARPET);
      } else if (biome === "village") {
        if (r < 0.1) set(x, y, TILE.PATH);
        else if (r < 0.14) set(x, y, TILE.FLOWER);
      }
    }
  }

  // borders
  const rx = id % MAP_W, ry = Math.floor(id / MAP_W);
  const wallTile = (biome === "cave" || biome === "temple") ? TILE.CAVE
    : (biome === "peak" || biome === "ruins") ? TILE.WALL
    : TILE.TREE;
  for (let x = 0; x < COLS; x++) {
    set(x, 0, wallTile); set(x, ROWS - 1, wallTile);
  }
  for (let y = 0; y < ROWS; y++) {
    set(0, y, wallTile); set(COLS - 1, y, wallTile);
  }

  // openings to neighbors
  const openN = ry > 0, openS = ry < MAP_H - 1, openW = rx > 0, openE = rx < MAP_W - 1;
  const floorOpen = base;
  if (openN) { set(7, 0, floorOpen); set(8, 0, floorOpen); }
  if (openS) { set(7, ROWS - 1, floorOpen); set(8, ROWS - 1, floorOpen); }
  if (openW) { set(0, 5, floorOpen); set(0, 4, floorOpen); }
  if (openE) { set(COLS - 1, 5, floorOpen); set(COLS - 1, 4, floorOpen); }

  // room-specific architecture
  if (id === 7) { // village
    for (let x = 3; x <= 6; x++) for (let y = 2; y <= 5; y++) set(x, y, TILE.WOOD);
    set(3, 2, TILE.WALL); set(4, 2, TILE.WALL); set(5, 2, TILE.WALL); set(6, 2, TILE.WALL);
    set(3, 3, TILE.WALL); set(6, 3, TILE.WALL);
    set(3, 4, TILE.WALL); set(6, 4, TILE.WALL);
    set(3, 5, TILE.WALL); set(6, 5, TILE.WALL);
    set(4, 5, TILE.WOOD); set(5, 5, TILE.WOOD); // door
    set(4, 3, TILE.CARPET); set(5, 3, TILE.CARPET); set(4, 4, TILE.CARPET); set(5, 4, TILE.CARPET);
    for (let x = 10; x <= 13; x++) for (let y = 2; y <= 5; y++) set(x, y, TILE.WOOD);
    set(10, 2, TILE.WALL); set(11, 2, TILE.HEARTH); set(12, 2, TILE.WALL); set(13, 2, TILE.WALL);
    set(10, 3, TILE.WALL); set(13, 3, TILE.WALL);
    set(10, 4, TILE.WALL); set(13, 4, TILE.WALL);
    set(10, 5, TILE.WALL); set(13, 5, TILE.WALL);
    set(11, 5, TILE.WOOD); set(12, 5, TILE.WOOD);
    for (let x = 6; x <= 9; x++) set(x, 7, TILE.PATH);
    set(7, 6, TILE.PATH); set(8, 6, TILE.PATH); set(7, 8, TILE.PATH); set(8, 8, TILE.PATH);
    set(8, 8, TILE.SHRINE);
  }
  if (id === 8) { // meadow
    set(12, 3, TILE.FLOWER); set(3, 7, TILE.TALL);
    set(11, 7, TILE.TREE); set(4, 3, TILE.TREE);
  }
  if (id === 6) { // forest
    set(3, 3, TILE.TREE); set(4, 3, TILE.TREE); set(12, 3, TILE.TREE);
    set(3, 7, TILE.TREE); set(12, 7, TILE.TREE); set(11, 6, TILE.TREE);
    set(8, 5, TILE.DIRT);
  }
  if (id === 5) {
    for (let i = 0; i < 8; i++) set(2 + (i % 4) * 3, 2 + Math.floor(i / 4) * 4, TILE.TREE);
  }
  if (id === 10) { // grove - smith
    for (let x = 5; x <= 10; x++) for (let y = 3; y <= 6; y++) set(x, y, TILE.DIRT);
    set(7, 3, TILE.HEARTH); set(8, 3, TILE.HEARTH);
    set(6, 4, TILE.WALL); set(9, 4, TILE.WALL);
  }
  if (id === 11) { // lake
    for (let y = 3; y <= 7; y++) for (let x = 4; x <= 11; x++) {
      if (Math.hypot(x - 7.5, y - 5) < 3.2) set(x, y, TILE.WATER);
    }
    set(7, 5, TILE.WATER); set(8, 5, TILE.WATER);
  }
  if (id === 15) { // swamp
    set(4, 4, TILE.WATER); set(5, 4, TILE.WATER); set(4, 5, TILE.WATER);
    set(10, 6, TILE.WATER); set(11, 6, TILE.WATER); set(11, 7, TILE.WATER);
    set(2, 7, TILE.TREE); set(2, 8, TILE.TREE); set(3, 7, TILE.CRACK);
    set(4, 7, TILE.TREE); set(4, 8, TILE.TREE); set(4, 9, TILE.TREE);
  }
  if (id === 16) { // shrine field
    set(7, 4, TILE.SHRINE); set(8, 4, TILE.STONE); set(7, 5, TILE.STONE); set(8, 5, TILE.STONE);
    set(6, 3, TILE.PILLAR); set(9, 3, TILE.PILLAR);
  }
  if (id === 17) {
    for (let x = 3; x <= 6; x++) for (let y = 3; y <= 6; y++) set(x, y, TILE.WOOD);
    set(3, 3, TILE.WALL); set(6, 3, TILE.WALL); set(3, 6, TILE.WALL); set(6, 6, TILE.WALL);
    set(4, 6, TILE.WOOD); set(5, 6, TILE.WOOD);
    for (let x = 9; x <= 13; x++) set(x, 4, TILE.TALL);
    for (let x = 9; x <= 13; x++) set(x, 5, TILE.TALL);
  }
  if (id === 1) { // cave center
    set(7, 5, TILE.SHRINE); set(8, 5, TILE.STONE);
    set(4, 3, TILE.CAVE); set(11, 3, TILE.CAVE); set(4, 7, TILE.CAVE); set(11, 7, TILE.CAVE);
  }
  if (id === 0) {
    set(3, 2, TILE.CAVE); set(4, 2, TILE.CAVE); set(2, 2, TILE.CAVE);
    set(2, 3, TILE.CAVE); set(2, 4, TILE.CAVE);
    set(3, 4, TILE.CRACK); set(4, 4, TILE.CAVE);
    set(3, 7, TILE.DARK);
  }
  if (id === 2) {
    set(11, 3, TILE.CAVE); set(12, 3, TILE.CAVE); set(5, 7, TILE.DARK);
  }
  if (id === 3) { // peak
    set(7, 3, TILE.HEARTH); set(8, 3, TILE.HEARTH);
    set(5, 6, TILE.LAVA); set(10, 6, TILE.LAVA);
    set(6, 2, TILE.WALL); set(9, 2, TILE.WALL);
  }
  if (id === 4) { // temple
    for (let x = 5; x <= 10; x++) set(x, 2, TILE.CARPET);
    for (let y = 2; y <= 8; y++) { set(7, y, TILE.CARPET); set(8, y, TILE.CARPET); }
    set(4, 3, TILE.PILLAR); set(11, 3, TILE.PILLAR);
    set(4, 7, TILE.PILLAR); set(11, 7, TILE.PILLAR);
    set(7, 2, TILE.HEARTH); set(8, 2, TILE.HEARTH);
  }
  if (id === 14) {
    set(5, 4, TILE.WALL); set(6, 4, TILE.WALL); set(10, 4, TILE.WALL);
    set(7, 6, TILE.CRACK);
  }
  if (id === 9) {
    set(4, 3, TILE.PILLAR); set(11, 3, TILE.PILLAR);
    set(7, 5, TILE.STONE); set(8, 5, TILE.STONE);
  }
  if (id === 19) {
    set(3, 3, TILE.WALL); set(12, 3, TILE.WALL);
    set(8, 7, TILE.CRACK);
  }
  if (id === 13) {
    for (let x = 2; x < 14; x++) if (x !== 7 && x !== 8) set(x, 6, TILE.WALL);
  }

  return tiles;
}

// static room tiles cache
const ROOM_TILES = [];
for (let i = 0; i < MAP_W * MAP_H; i++) ROOM_TILES.push(fillRoom(i));

function tileAt(room, x, y) {
  if (x < 0 || y < 0 || x >= COLS || y >= ROWS) return TILE.WALL;
  return room[y * COLS + x];
}
function solidTile(t) { return SOLID.has(t); }

// ---------------------------------------------------------------------------
// Game state
// ---------------------------------------------------------------------------
const ITEMS = {
  potion: { id: "potion", name: "Heart Tonic", kind: "consumable", desc: "Restore 4 health.", icon: "potion" },
  bombs: { id: "bombs", name: "Blast Caps", kind: "consumable", desc: "Shatter cracked stone. Press B.", icon: "bomb" },
  boots: { id: "boots", name: "Windstride Boots", kind: "gear", desc: "Move faster. Dash farther.", icon: "boots" },
  shield: { id: "shield", name: "Iron Shield", kind: "gear", desc: "Halve incoming damage.", icon: "shield" },
  compass: { id: "compass", name: "Seeker Compass", kind: "gear", desc: "Reveal secrets and unvisited rooms.", icon: "compass" },
  lantern: { id: "lantern", name: "Sunwell Lantern", kind: "gear", desc: "Light the deep dark.", icon: "lantern" },
  charm: { id: "charm", name: "Vitality Charm", kind: "gear", desc: "Slowly mend wounds out of combat.", icon: "charm" },
  bow: { id: "bow", name: "Ashwood Bow", kind: "weapon", desc: "Ranged shots. Press K.", icon: "bow" }
};

const WEAPONS = {
  wood: { id: "wood", name: "Ashen Stick", dmg: 1, cd: 0.42, range: 34, arc: 1.15, color: "#8b5a2b", glow: null },
  iron: { id: "iron", name: "Riversteel", dmg: 2, cd: 0.30, range: 40, arc: 1.35, color: "#c5d0dc", glow: null },
  ember: { id: "ember", name: "Emberbrand", dmg: 3, cd: 0.36, range: 44, arc: 1.5, color: "#ff6a2a", glow: "#ffaa33" }
};

function freshState() {
  return {
    mode: "play", // play, inventory, shop, talk, dead, win, pause
    roomX: 2, roomY: 1,
    player: {
      x: 8 * TW, y: 8 * TH, vx: 0, vy: 0, dir: 0, // 0 down 1 left 2 right 3 up
      hp: 6, maxHp: 6, hearts: 3,
      stamina: 100, maxStamina: 100,
      invuln: 0, flash: 0, anim: 0, attacking: 0, attackCd: 0,
      bowCd: 0, dash: 0, dashCd: 0, hurtT: 0,
      weapon: "wood",
      facing: { x: 0, y: 1 }
    },
    gold: 0,
    items: { potion: 1, bombs: 0, boots: false, shield: false, compass: false, lantern: false, charm: false, bow: false },
    flags: {
      heartMeadow: false, heartRuins: false,
      ironGot: false, emberGot: false, bowGot: false,
      lanternGot: false, compassGot: false, charmGot: false,
      bossDead: false, intro: false,
      cracked: {}, // "rx,ry,tx,ty"
      chests: {},
      kills: 0,
      savedAt: null
    },
    visited: { "2,1": true },
    talk: null,
    shopSel: 0,
    invSel: 0,
    floaters: [],
    projectiles: [],
    bombs: [],
    time: 0,
    shake: 0,
    msg: "",
    msgT: 0
  };
}

let S = freshState();
let entities = []; // rebuilt per room

function roomId() { return S.roomX + S.roomY * MAP_W; }
function roomTiles() { return ROOM_TILES[roomId()]; }
function crackedKey(tx, ty) { return `${S.roomX},${S.roomY},${tx},${ty}`; }

function currentTileSolid(tx, ty) {
  // Out-of-bounds must be walkable so doorways can be crossed.
  // (tileAt() treats OOB as WALL, which previously blocked transitions
  // one pixel short of the room-change threshold.)
  if (tx < 0 || ty < 0 || tx >= COLS || ty >= ROWS) return false;
  if (S.flags.cracked[crackedKey(tx, ty)]) return false;
  return solidTile(tileAt(roomTiles(), tx, ty));
}

function circleHitsSolid(x, y, r) {
  const x0 = Math.floor((x - r) / TW), x1 = Math.floor((x + r) / TW);
  const y0 = Math.floor((y - r) / TH), y1 = Math.floor((y + r) / TH);
  for (let ty = y0; ty <= y1; ty++) for (let tx = x0; tx <= x1; tx++) {
    if (!currentTileSolid(tx, ty)) continue;
    const rx = clamp(x, tx * TW, tx * TW + TW);
    const ry = clamp(y, ty * TH, ty * TH + TH);
    if (Math.hypot(x - rx, y - ry) < r) return true;
  }
  return false;
}

function tryMove(ent, dx, dy, r) {
  if (dx && !circleHitsSolid(ent.x + dx, ent.y, r)) ent.x += dx;
  else if (dx) {
    for (let i = 0; i < 4; i++) {
      const s = dx * (1 - i * 0.25);
      if (!circleHitsSolid(ent.x + s, ent.y, r)) { ent.x += s; break; }
    }
  }
  if (dy && !circleHitsSolid(ent.x, ent.y + dy, r)) ent.y += dy;
  else if (dy) {
    for (let i = 0; i < 4; i++) {
      const s = dy * (1 - i * 0.25);
      if (!circleHitsSolid(ent.x, ent.y + s, r)) { ent.y += s; break; }
    }
  }
}

function worldX(px) { return MAP_OX + px; }
function worldY(py) { return MAP_OY + py; }

function floater(x, y, text, col) {
  S.floaters.push({ x, y, text, col, t: 0.9 });
}

// ---------------------------------------------------------------------------
// Entities per room
// ---------------------------------------------------------------------------
function spawnRoom() {
  entities = [];
  S.projectiles = S.projectiles.filter(p => p.keep);
  const id = roomId();
  const chestTaken = (cid) => S.flags.chests[`${S.roomX},${S.roomY}:${cid}`];
  const markChest = (cid) => { S.flags.chests[`${S.roomX},${S.roomY}:${cid}`] = true; };

  const enemy = (kind, x, y, extra) => {
    const proto = {
      slime: { hp: 3, dmg: 1, spd: 38, r: 12, gold: [1, 3], xp: 1 },
      wolf: { hp: 4, dmg: 1, spd: 70, r: 13, gold: [2, 5], xp: 2 },
      bat: { hp: 2, dmg: 1, spd: 80, r: 10, gold: [1, 4], xp: 1 },
      skel: { hp: 7, dmg: 2, spd: 48, r: 13, gold: [3, 7], xp: 3 },
      mage: { hp: 6, dmg: 2, spd: 30, r: 12, gold: [4, 8], xp: 3 },
      guard: { hp: 14, dmg: 2, spd: 42, r: 15, gold: [10, 16], xp: 8 },
      boss: { hp: 36, dmg: 2, spd: 46, r: 20, gold: [40, 40], xp: 20 }
    }[kind];
    entities.push(Object.assign({
      type: "enemy", kind, x: x * TW + TW / 2, y: y * TH + TH / 2,
      hp: proto.hp, maxHp: proto.hp, dmg: proto.dmg, spd: proto.spd, r: proto.r,
      gold: proto.gold, hurt: 0, t: Math.random() * 10, ai: 0, facing: 0
    }, extra || {}));
  };
  const npc = (name, x, y, lines, role) => {
    entities.push({ type: "npc", name, x: x * TW + TW / 2, y: y * TH + TH / 2, r: 14, lines, role: role || "talk", i: 0 });
  };
  const chest = (cid, x, y, loot) => {
    if (chestTaken(cid)) return;
    entities.push({ type: "chest", cid, x: x * TW + TW / 2, y: y * TH + TH / 2, r: 14, loot, open: false });
  };
  const shrine = (x, y) => {
    entities.push({ type: "shrine", x: x * TW + TW / 2, y: y * TH + TH / 2, r: 18 });
  };
  const pickup = (kind, x, y, amt) => {
    entities.push({ type: "pickup", kind, amt: amt || 1, x: x * TW + TW / 2, y: y * TH + TH / 2, r: 10, bob: Math.random() * 6 });
  };

  if (id === 7) { // village
    npc("Elder Bram", 5, 4, [
      "The temple on the far peak stirs. Embers crawl in its stone.",
      "Start east, in the lea — a heart of the old kings is buried there.",
      "West, the forest hides a lantern. Without light the hollows will swallow you.",
      "When you can see in the dark, seek the hunter's bow beneath the mountain.",
      "Two blades wait: one in the grove to the southwest, one in the forge of the peak.",
      "Rest at any shrine. The world will remember you."
    ]);
    npc("Mara the Peddler", 12, 4, [
      "Coin spends cleaner than blood. Browse if you like."
    ], "shop");
    npc("Tovin", 9, 8, [
      "J to swing, L to dash, K to loose an arrow — once you find a bow.",
      "Cracked walls hate Blast Caps. Buy some from Mara.",
      "I opens your pack. E talks, opens, prays."
    ]);
    shrine(8, 8);
  }
  if (id === 8) {
    enemy("slime", 4, 4); enemy("slime", 11, 4); enemy("slime", 10, 7);
    if (!S.flags.heartMeadow) chest("heart", 12, 3, "heart");
    pickup("gold", 5, 8, 5);
  }
  if (id === 12) {
    enemy("slime", 4, 5); enemy("wolf", 11, 6);
    pickup("gold", 3, 3, 4);
  }
  if (id === 17) {
    npc("Millie", 5, 4, [
      "Wolves took a ram last night. The road south of the village isn't kind.",
      "If you see my brother Tovin, tell him the stew's gone cold."
    ]);
    pickup("potion", 12, 7);
  }
  if (id === 18) {
    enemy("wolf", 5, 4); enemy("wolf", 11, 5); enemy("slime", 8, 7);
  }
  if (id === 6) {
    enemy("wolf", 4, 4); enemy("wolf", 12, 6); enemy("bat", 8, 3);
    if (!S.flags.lanternGot) chest("lantern", 8, 5, "lantern");
  }
  if (id === 5) {
    enemy("bat", 4, 3); enemy("bat", 11, 4); enemy("wolf", 8, 7);
    pickup("gold", 12, 8, 8);
  }
  if (id === 10) {
    npc("Smith Kael", 8, 5, [
      S.flags.ironGot ? "Riversteel suits you. The peak-forge still sleeps hotter metal." :
        "Bring me proof you can stand a fight and I'll wake Riversteel for you.",
      "Ten wolf-teeth... or just clear the grove of its guardian."
    ], "smith");
    if (!S.flags.ironGot) enemy("guard", 4, 7);
    pickup("gold", 12, 3, 6);
  }
  if (id === 11) {
    enemy("bat", 3, 3); enemy("bat", 12, 3); enemy("slime", 12, 8);
    pickup("potion", 3, 8);
  }
  if (id === 15) {
    enemy("slime", 6, 3); enemy("slime", 9, 7); enemy("wolf", 11, 4);
    if (!S.flags.compassGot) chest("compass", 3, 8, "compass");
  }
  if (id === 16) {
    shrine(7, 4);
    npc("Pilgrim", 10, 7, [
      "Shrines remember. Kneel, and even death is only a door back.",
      "I dreamed of five hearts beating as one. You wear three."
    ]);
  }
  if (id === 13) {
    enemy("skel", 4, 4); enemy("wolf", 11, 4);
    pickup("gold", 12, 8, 10);
  }
  if (id === 9) {
    enemy("skel", 4, 6); enemy("skel", 11, 6); enemy("mage", 8, 3);
  }
  if (id === 14) {
    enemy("skel", 4, 3); enemy("mage", 11, 3); enemy("skel", 11, 7);
    if (!S.flags.heartRuins) chest("heart2", 8, 8, "heart");
  }
  if (id === 19) {
    enemy("skel", 5, 5); enemy("mage", 11, 5);
    if (!S.flags.charmGot) chest("charm", 3, 8, "charm");
  }
  if (id === 2) {
    enemy("bat", 5, 4); enemy("bat", 11, 6); enemy("skel", 8, 8);
  }
  if (id === 1) {
    shrine(7, 5);
    if (!S.flags.bowGot && S.items.lantern) chest("bow", 12, 3, "bow");
    else if (!S.flags.bowGot) {
      entities.push({ type: "npc", name: "Sealed Niche", x: 12 * TW + TW / 2, y: 3 * TH + TH / 2, r: 14, role: "talk", lines: ["A hunter's bow waits in the dark. Light must touch it first."], i: 0 });
    }
    enemy("bat", 4, 3); enemy("skel", 4, 8);
  }
  if (id === 0) {
    enemy("skel", 8, 5); enemy("mage", 11, 3);
    pickup("gold", 5, 8, 12);
    if (!S.items.shield) chest("shield", 3, 3, "shield");
  }
  if (id === 3) {
    npc("Forge-Echo", 8, 4, [
      S.flags.emberGot ? "It remembers fire. So will the temple." :
        "Offer a worthy blade and a hunter's eye. Then the Emberbrand wakes."
    ], "forge");
    enemy("mage", 4, 7); enemy("skel", 12, 7);
  }
  if (id === 4) {
    if (!S.flags.bossDead) enemy("boss", 8, 4);
    else pickup("gold", 8, 4, 0);
  }

  // cracked walls become bomb targets already in tiles
}

function giveLoot(loot) {
  if (loot === "heart") {
    if (S.player.hearts < 5) {
      S.player.hearts++;
      S.player.maxHp = S.player.hearts * 2;
      S.player.hp = S.player.maxHp;
      if (S.roomX === 3 && S.roomY === 1) S.flags.heartMeadow = true;
      else S.flags.heartRuins = true;
      announce("A Heart Vessel! Maximum health up.");
      AudioFX.heal();
    }
  } else if (loot === "lantern") {
    S.items.lantern = true; S.flags.lanternGot = true;
    announce("Sunwell Lantern — the dark recoils.");
    AudioFX.unlock();
  } else if (loot === "compass") {
    S.items.compass = true; S.flags.compassGot = true;
    announce("Seeker Compass — secrets shiver.");
    AudioFX.unlock();
  } else if (loot === "charm") {
    S.items.charm = true; S.flags.charmGot = true;
    announce("Vitality Charm — wounds forget themselves.");
    AudioFX.unlock();
  } else if (loot === "bow") {
    S.items.bow = true; S.flags.bowGot = true;
    announce("Ashwood Bow — press K to loose.");
    AudioFX.unlock();
  } else if (loot === "shield") {
    S.items.shield = true;
    announce("Iron Shield — blows land softer.");
    AudioFX.unlock();
  } else if (loot === "gold") {
    S.gold += 10; AudioFX.coin();
  }
}

function announce(t) {
  S.msg = t; S.msgT = 3.2;
  toast(t, 2400);
}

// ---------------------------------------------------------------------------
// Shop
// ---------------------------------------------------------------------------
const SHOP = [
  { item: "potion", price: 12, qty: 1, name: "Heart Tonic", desc: "Restore 4 HP." },
  { item: "bombs", price: 18, qty: 3, name: "Blast Caps x3", desc: "Crack the world open." },
  { item: "boots", price: 40, qty: 0, name: "Windstride Boots", desc: "Swift feet, longer dash.", unique: true },
  { item: "shield", price: 45, qty: 0, name: "Iron Shield", desc: "Halve damage taken.", unique: true }
];

function buyIndex(i) {
  const it = SHOP[i];
  if (it.unique && S.items[it.item]) { announce("Already yours."); return; }
  if (S.gold < it.price) { announce("Not enough coin."); AudioFX.hurt(); return; }
  S.gold -= it.price;
  if (it.item === "potion") S.items.potion += it.qty;
  else if (it.item === "bombs") S.items.bombs += it.qty;
  else S.items[it.item] = true;
  AudioFX.shop();
  burst(S.player.x, S.player.y - 10, 10, ["#f3c14a", "#fff3a0"], 60, 0.5, 3);
  announce("Purchased " + it.name + ".");
}

// ---------------------------------------------------------------------------
// Combat
// ---------------------------------------------------------------------------
function weapon() { return WEAPONS[S.player.weapon]; }

function doSword() {
  const p = S.player;
  if (p.attackCd > 0 || p.attacking > 0) return;
  const w = weapon();
  p.attacking = 0.18;
  p.attackCd = w.cd;
  AudioFX.sword();
  const fx = p.facing.x, fy = p.facing.y;
  const ox = p.x + fx * 18, oy = p.y + fy * 14;
  burst(ox + fx * 16, oy + fy * 16, 6, w.glow ? ["#ff6a2a", "#ffd166", "#fff"] : ["#e8d5a3", "#fff"], 80, 0.25, 2);

  for (const e of entities) {
    if (e.type !== "enemy" || e.hp <= 0) continue;
    const dx = e.x - p.x, dy = e.y - p.y;
    const d = Math.hypot(dx, dy);
    if (d > w.range + e.r) continue;
    const ang = Math.atan2(dy, dx);
    const fang = Math.atan2(fy, fx);
    let diff = Math.abs(ang - fang);
    if (diff > Math.PI) diff = Math.PI * 2 - diff;
    if (diff < w.arc) hitEnemy(e, w.dmg, Math.cos(ang) * 120, Math.sin(ang) * 120, w.id === "ember");
  }
}

function shootBow() {
  const p = S.player;
  if (!S.items.bow) { announce("No bow yet."); return; }
  if (p.bowCd > 0) return;
  if (p.stamina < 12) { announce("Winded."); return; }
  p.stamina -= 12;
  p.bowCd = 0.38;
  AudioFX.bow();
  const fx = p.facing.x || 0, fy = p.facing.y || 1;
  S.projectiles.push({
    type: "arrow", x: p.x + fx * 16, y: p.y + fy * 10,
    vx: fx * 280, vy: fy * 280, life: 1.1, dmg: 2, friendly: true
  });
}

function placeBomb() {
  if (S.items.bombs <= 0) { announce("No Blast Caps."); return; }
  if (S.bombs.length >= 2) return;
  S.items.bombs--;
  S.bombs.push({ x: S.player.x, y: S.player.y, t: 1.05 });
  AudioFX.beep(180, 0.05, "square", 0.04);
}

function explode(x, y) {
  AudioFX.boom();
  S.shake = 10;
  burst(x, y, 28, ["#ffb347", "#ff6a2a", "#555", "#f3c14a"], 180, 0.55, 4);
  for (const e of entities) {
    if (e.type !== "enemy" || e.hp <= 0) continue;
    if (Math.hypot(e.x - x, e.y - y) < 70) hitEnemy(e, 4, (e.x - x) * 4, (e.y - y) * 4, false);
  }
  if (Math.hypot(S.player.x - x, S.player.y - y) < 50) damagePlayer(1);
  // crack tiles
  const tx = Math.floor(x / TW), ty = Math.floor(y / TH);
  for (let oy = -1; oy <= 1; oy++) for (let ox = -1; ox <= 1; ox++) {
    const t = tileAt(roomTiles(), tx + ox, ty + oy);
    if (t === TILE.CRACK) S.flags.cracked[crackedKey(tx + ox, ty + oy)] = true;
  }
}

function hitEnemy(e, dmg, kx, ky, fire) {
  e.hp -= dmg;
  e.hurt = 0.18;
  e.x += (kx || 0) * 0.04;
  e.y += (ky || 0) * 0.04;
  floater(e.x, e.y - 16, "−" + dmg, fire ? "#ff6a2a" : "#fff4d0");
  AudioFX.hit();
  burst(e.x, e.y, 8, fire ? ["#ff6a2a", "#ffd166"] : ["#c23b22", "#fff"], 90, 0.3, 2.4);
  if (fire) {
    e.burn = 2.2;
    burst(e.x, e.y, 10, ["#ff6a2a", "#ffaa33"], 70, 0.4, 2);
  }
  if (e.hp <= 0) killEnemy(e);
}

function killEnemy(e) {
  e.hp = 0;
  S.flags.kills++;
  const g = irand(e.gold[0], e.gold[1]);
  if (g > 0) {
    entities.push({ type: "pickup", kind: "gold", amt: g, x: e.x, y: e.y, r: 10, bob: 0 });
  }
  if (Math.random() < 0.12) {
    entities.push({ type: "pickup", kind: "potion", amt: 1, x: e.x + 8, y: e.y, r: 10, bob: 1 });
  }
  burst(e.x, e.y, 16, ["#6b2a2a", "#c23b22", "#111"], 100, 0.45, 3);
  AudioFX.death();
  if (e.kind === "guard" && !S.flags.ironGot) {
    S.flags.ironGot = true;
    S.player.weapon = "iron";
    announce("The grove yields Riversteel. Faster. Sharper.");
    AudioFX.unlock();
  }
  if (e.kind === "boss") {
    S.flags.bossDead = true;
    S.mode = "win";
    AudioFX.win();
    burst(e.x, e.y, 40, ["#ff6a2a", "#f3c14a", "#fff"], 200, 1.2, 5);
  }
}

function damagePlayer(n) {
  const p = S.player;
  if (p.invuln > 0 || p.hp <= 0) return;
  if (S.items.shield) n = Math.max(1, Math.ceil(n * 0.5));
  p.hp -= n;
  p.invuln = 0.85;
  p.flash = 0.2;
  p.hurtT = 0.25;
  S.shake = 8;
  floater(p.x, p.y - 18, "−" + n, "#ff6b6b");
  AudioFX.hurt();
  burst(p.x, p.y, 12, ["#c23b22", "#ffb3b3"], 100, 0.35, 2.5);
  if (p.hp <= 0) {
    p.hp = 0;
    S.mode = "dead";
    AudioFX.death();
  }
}

function healPlayer(n) {
  const p = S.player;
  const before = p.hp;
  p.hp = Math.min(p.maxHp, p.hp + n);
  if (p.hp > before) {
    AudioFX.heal();
    burst(p.x, p.y, 10, ["#7dcea0", "#fff"], 50, 0.5, 3);
    floater(p.x, p.y - 16, "+" + (p.hp - before), "#7dcea0");
  }
}

function usePotion() {
  if (S.items.potion <= 0) { announce("No tonics."); return; }
  if (S.player.hp >= S.player.maxHp) { announce("Already hale."); return; }
  S.items.potion--;
  healPlayer(4);
}

// ---------------------------------------------------------------------------
// Save
// ---------------------------------------------------------------------------
function serialize() {
  return JSON.stringify({
    roomX: S.roomX, roomY: S.roomY,
    px: S.player.x, py: S.player.y, dir: S.player.dir,
    hp: S.player.hp, maxHp: S.player.maxHp, hearts: S.player.hearts,
    weapon: S.player.weapon, gold: S.gold, items: S.items, flags: S.flags,
    visited: S.visited
  });
}
function applySave(d) {
  S = freshState();
  S.roomX = d.roomX; S.roomY = d.roomY;
  S.player.x = d.px; S.player.y = d.py; S.player.dir = d.dir;
  S.player.hp = d.hp; S.player.maxHp = d.maxHp; S.player.hearts = d.hearts;
  S.player.weapon = d.weapon; S.gold = d.gold; S.items = d.items; S.flags = d.flags;
  S.visited = d.visited;
  const dirs = [{x:0,y:1},{x:-1,y:0},{x:1,y:0},{x:0,y:-1}];
  S.player.facing = dirs[S.player.dir] || dirs[0];
  spawnRoom();
}
function saveGame(silent) {
  try { localStorage.setItem(SAVE_KEY, serialize()); } catch (e) {}
  S.flags.savedAt = `${S.roomX},${S.roomY}`;
  if (!silent) {
    announce("The shrine remembers.");
    AudioFX.save();
    burst(S.player.x, S.player.y, 16, ["#9ad1ff", "#fff"], 40, 0.8, 3);
  }
}
function loadGame() {
  try {
    const raw = localStorage.getItem(SAVE_KEY);
    if (!raw) return false;
    applySave(JSON.parse(raw));
    return true;
  } catch (e) { return false; }
}
function hasSave() {
  try { return !!localStorage.getItem(SAVE_KEY); } catch (e) { return false; }
}

// ---------------------------------------------------------------------------
// Interaction
// ---------------------------------------------------------------------------
function interact() {
  const p = S.player;
  let best = null, bd = 36;
  for (const e of entities) {
    if (e.type === "enemy") continue;
    const d = Math.hypot(e.x - p.x, e.y - p.y);
    if (d < bd + (e.r || 10)) { bd = d; best = e; }
  }
  if (!best) return;
  if (best.type === "npc") {
    if (best.role === "shop") { S.mode = "shop"; S.shopSel = 0; AudioFX.shop(); return; }
    if (best.role === "smith") {
      if (!S.flags.ironGot) {
        const groveClear = !entities.some(e => e.type === "enemy" && e.hp > 0);
        if (groveClear || S.flags.kills >= 8) {
          S.flags.ironGot = true;
          S.player.weapon = "iron";
          announce("Kael lays Riversteel in your hands.");
          AudioFX.unlock();
          return;
        }
      }
    }
    if (best.role === "forge") {
      if (!S.flags.emberGot && S.flags.ironGot && S.items.bow) {
        S.flags.emberGot = true;
        S.player.weapon = "ember";
        announce("The peak-forge sings. Emberbrand is yours.");
        AudioFX.unlock();
        burst(p.x, p.y, 24, ["#ff6a2a", "#ffd166"], 120, 0.8, 4);
        return;
      }
    }
    S.mode = "talk";
    S.talk = { name: best.name, lines: best.lines, i: best.i || 0, ent: best };
    return;
  }
  if (best.type === "chest" && !best.open) {
    best.open = true;
    S.flags.chests[`${S.roomX},${S.roomY}:${best.cid}`] = true;
    giveLoot(best.loot);
    burst(best.x, best.y, 14, ["#f3c14a", "#fff3a0"], 70, 0.5, 3);
    AudioFX.pickup();
    return;
  }
  if (best.type === "shrine") {
    healPlayer(99);
    saveGame(false);
  }
}

function collectPickup(e) {
  if (e.kind === "gold") {
    S.gold += e.amt; AudioFX.coin();
    floater(e.x, e.y, "+" + e.amt + "g", "#f3c14a");
  } else if (e.kind === "potion") {
    S.items.potion += e.amt; AudioFX.pickup();
    announce("Heart Tonic acquired.");
  }
  burst(e.x, e.y, 8, ["#f3c14a", "#fff"], 50, 0.35, 2);
  e.dead = true;
}

// ---------------------------------------------------------------------------
// Room transitions
// ---------------------------------------------------------------------------
function changeRoom(dx, dy) {
  const nx = S.roomX + dx, ny = S.roomY + dy;
  if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) return;
  S.roomX = nx; S.roomY = ny;
  S.visited[`${nx},${ny}`] = true;
  if (dx > 0) S.player.x = 28;
  if (dx < 0) S.player.x = VIEW_W - 28;
  if (dy > 0) S.player.y = 28;
  if (dy < 0) S.player.y = VIEW_H - 28;
  S.projectiles = [];
  S.bombs = [];
  spawnRoom();
}

// ---------------------------------------------------------------------------
// Update
// ---------------------------------------------------------------------------
function update(dt) {
  S.time += dt;
  if (S.shake > 0) S.shake = Math.max(0, S.shake - dt * 28);
  if (S.msgT > 0) S.msgT -= dt;

  if (S.mode === "dead" || S.mode === "win") {
    if (pressed("KeyR") || pressed("Enter") || pressed("Space")) {
      if (S.mode === "dead") {
        if (hasSave() && loadGame()) {
          S.mode = "play";
          S.player.hp = Math.max(2, S.player.hp);
          announce("You wake at the last shrine.");
        } else {
          S = freshState();
          spawnRoom();
          S.mode = "play";
        }
      } else {
        overlay.classList.remove("hidden");
        titlePanel.classList.remove("hidden");
        if (hasSave()) btnCont.classList.remove("hidden");
        S.mode = "title";
      }
    }
    flushJust();
    return;
  }

  if (S.mode === "talk") {
    if (pressed("KeyE") || pressed("Space") || pressed("Enter") || pressed("KeyJ")) {
      S.talk.i++;
      if (S.talk.ent) S.talk.ent.i = S.talk.i;
      if (S.talk.i >= S.talk.lines.length) { S.mode = "play"; S.talk = null; }
    }
    if (pressed("Escape")) { S.mode = "play"; S.talk = null; }
    flushJust();
    return;
  }

  if (S.mode === "shop") {
    if (pressed("ArrowUp") || pressed("KeyW")) S.shopSel = (S.shopSel + SHOP.length - 1) % SHOP.length;
    if (pressed("ArrowDown") || pressed("KeyS")) S.shopSel = (S.shopSel + 1) % SHOP.length;
    if (pressed("Enter") || pressed("KeyE") || pressed("Space") || pressed("KeyJ")) buyIndex(S.shopSel);
    if (pressed("Escape") || pressed("KeyI")) S.mode = "play";
    flushJust();
    return;
  }

  if (S.mode === "inventory") {
    if (pressed("KeyI") || pressed("Escape")) S.mode = "play";
    const slots = invSlots();
    if (pressed("ArrowLeft") || pressed("KeyA")) S.invSel = (S.invSel + slots.length - 1) % Math.max(1, slots.length);
    if (pressed("ArrowRight") || pressed("KeyD")) S.invSel = (S.invSel + 1) % Math.max(1, slots.length);
    if (pressed("Enter") || pressed("Space") || pressed("KeyE") || pressed("KeyJ")) {
      const sl = slots[S.invSel];
      if (sl && sl.id === "potion") usePotion();
    }
    flushJust();
    return;
  }

  if (S.mode === "pause") {
    if (pressed("Escape") || pressed("KeyP")) S.mode = "play";
    flushJust();
    return;
  }

  if (S.mode !== "play") { flushJust(); return; }

  if (pressed("KeyI")) { S.mode = "inventory"; S.invSel = 0; flushJust(); return; }
  if (pressed("Escape") || pressed("KeyP")) { S.mode = "pause"; flushJust(); return; }
  if (pressed("KeyH")) usePotion();
  if (pressed("KeyB")) placeBomb();
  if (pressed("KeyE")) interact();
  if (pressed("KeyJ") || pressed("Space")) doSword();
  if (pressed("KeyK")) shootBow();

  const p = S.player;
  p.attackCd = Math.max(0, p.attackCd - dt);
  p.bowCd = Math.max(0, p.bowCd - dt);
  p.dashCd = Math.max(0, p.dashCd - dt);
  p.invuln = Math.max(0, p.invuln - dt);
  p.flash = Math.max(0, p.flash - dt);
  p.hurtT = Math.max(0, p.hurtT - dt);
  p.attacking = Math.max(0, p.attacking - dt);
  p.dash = Math.max(0, p.dash - dt);

  let ix = 0, iy = 0;
  if (down("KeyA") || down("ArrowLeft")) ix -= 1;
  if (down("KeyD") || down("ArrowRight")) ix += 1;
  if (down("KeyW") || down("ArrowUp")) iy -= 1;
  if (down("KeyS") || down("ArrowDown")) iy += 1;
  if (ix || iy) {
    const l = Math.hypot(ix, iy);
    ix /= l; iy /= l;
    p.facing = { x: ix, y: iy };
    if (Math.abs(ix) > Math.abs(iy)) p.dir = ix < 0 ? 1 : 2;
    else p.dir = iy < 0 ? 3 : 0;
  }

  if (pressed("KeyL") && p.dashCd <= 0 && p.stamina >= 22) {
    p.dash = 0.16;
    p.dashCd = 0.55;
    p.stamina -= 22;
    p.invuln = Math.max(p.invuln, 0.16);
    AudioFX.noise(0.08, 0.05, 600);
    burst(p.x, p.y, 8, ["#cfe8ff", "#fff"], 70, 0.25, 2);
  }

  const tired = p.stamina < 18;
  let spd = 118;
  if (S.items.boots) spd = 148;
  if (tired) spd *= 0.72;
  if (p.attacking > 0) spd *= 0.45;
  if (p.dash > 0) spd = S.items.boots ? 340 : 280;

  if (ix || iy) {
    tryMove(p, ix * spd * dt, iy * spd * dt, 11);
    p.anim += dt * (tired ? 6 : 10);
    if (p.dash <= 0) AudioFX.walk();
    p.stamina = Math.max(0, p.stamina - dt * (S.items.boots ? 2 : 4));
  } else {
    p.anim *= 0.9;
    p.stamina = Math.min(p.maxStamina, p.stamina + dt * 28);
  }

  // charm regen
  if (S.items.charm && p.hp < p.maxHp) {
    p._regen = (p._regen || 0) + dt;
    if (p._regen > 6) { p._regen = 0; healPlayer(1); }
  }

  // lava
  const tStanding = tileAt(roomTiles(), Math.floor(p.x / TW), Math.floor(p.y / TH));
  if (tStanding === TILE.LAVA) damagePlayer(1);

  // room edges — trigger while still inside the 2-tile doorway
  if (p.x < 22) changeRoom(-1, 0);
  else if (p.x > VIEW_W - 22) changeRoom(1, 0);
  else if (p.y < 22) changeRoom(0, -1);
  else if (p.y > VIEW_H - 22) changeRoom(0, 1);
  p.x = clamp(p.x, 8, VIEW_W - 8);
  p.y = clamp(p.y, 8, VIEW_H - 8);

  // pickups
  for (const e of entities) {
    if (e.type === "pickup" && !e.dead && Math.hypot(e.x - p.x, e.y - p.y) < 18) collectPickup(e);
  }
  entities = entities.filter(e => !e.dead && !(e.type === "enemy" && e.hp <= 0));

  // bombs
  for (let i = S.bombs.length - 1; i >= 0; i--) {
    S.bombs[i].t -= dt;
    if (S.bombs[i].t <= 0) { explode(S.bombs[i].x, S.bombs[i].y); S.bombs.splice(i, 1); }
  }

  // projectiles
  for (let i = S.projectiles.length - 1; i >= 0; i--) {
    const pr = S.projectiles[i];
    pr.x += pr.vx * dt; pr.y += pr.vy * dt; pr.life -= dt;
    if (pr.life <= 0 || pr.x < -20 || pr.y < -20 || pr.x > VIEW_W + 20 || pr.y > VIEW_H + 20) {
      S.projectiles.splice(i, 1); continue;
    }
    const tx = Math.floor(pr.x / TW), ty = Math.floor(pr.y / TH);
    if (currentTileSolid(tx, ty)) { S.projectiles.splice(i, 1); continue; }
    if (pr.friendly) {
      let hit = false;
      for (const e of entities) {
        if (e.type !== "enemy" || e.hp <= 0) continue;
        if (Math.hypot(e.x - pr.x, e.y - pr.y) < e.r + 6) {
          hitEnemy(e, pr.dmg, pr.vx, pr.vy, false);
          hit = true; break;
        }
      }
      if (hit) S.projectiles.splice(i, 1);
    } else {
      if (Math.hypot(p.x - pr.x, p.y - pr.y) < 12) {
        damagePlayer(pr.dmg);
        S.projectiles.splice(i, 1);
      }
    }
  }

  // enemies
  for (const e of entities) {
    if (e.type !== "enemy") continue;
    e.t += dt;
    e.hurt = Math.max(0, e.hurt - dt);
    if (e.burn > 0) {
      e.burn -= dt;
      if (Math.floor(e.t * 3) !== Math.floor((e.t - dt) * 3)) {
        e.hp -= 1;
        floater(e.x, e.y - 12, "−1", "#ff6a2a");
        spark(e.x, e.y, rand(-20, 20), rand(-40, -10), "#ff6a2a");
        if (e.hp <= 0) killEnemy(e);
      }
    }
    if (e.hp <= 0) continue;

    const dx = p.x - e.x, dy = p.y - e.y;
    const d = Math.hypot(dx, dy) || 1;
    const darkBlind = ROOM_META[roomId()].dark && !S.items.lantern && d > 70;

    if (e.kind === "slime") {
      if (e.t % 1.4 < 0.35 && !darkBlind) tryMove(e, (dx / d) * e.spd * dt, (dy / d) * e.spd * dt, e.r);
    } else if (e.kind === "wolf" || e.kind === "skel" || e.kind === "guard") {
      if (!darkBlind) tryMove(e, (dx / d) * e.spd * dt, (dy / d) * e.spd * dt, e.r);
    } else if (e.kind === "bat") {
      const ox = Math.cos(e.t * 3) * 30;
      const oy = Math.sin(e.t * 2.2) * 24;
      const tx = darkBlind ? 0 : dx + ox, ty = darkBlind ? 0 : dy + oy;
      const tl = Math.hypot(tx, ty) || 1;
      tryMove(e, (tx / tl) * e.spd * dt, (ty / tl) * e.spd * dt, e.r);
    } else if (e.kind === "mage") {
      if (d < 90) tryMove(e, -(dx / d) * e.spd * dt, -(dy / d) * e.spd * dt, e.r);
      else if (!darkBlind) tryMove(e, (dx / d) * e.spd * 0.4 * dt, (dy / d) * e.spd * 0.4 * dt, e.r);
      e.ai += dt;
      if (e.ai > 1.6 && d < 260 && !darkBlind) {
        e.ai = 0;
        S.projectiles.push({
          type: "bolt", x: e.x, y: e.y, vx: (dx / d) * 140, vy: (dy / d) * 140,
          life: 2.2, dmg: 2, friendly: false
        });
        AudioFX.beep(240, 0.08, "sawtooth", 0.04, 80);
      }
    } else if (e.kind === "boss") {
      const phase = e.hp < e.maxHp * 0.45 ? 2 : 1;
      e.ai += dt;
      const ang = Math.atan2(dy, dx);
      if (phase === 1) {
        tryMove(e, Math.cos(ang) * e.spd * dt, Math.sin(ang) * e.spd * dt, e.r);
        if (e.ai > 1.4) {
          e.ai = 0;
          for (let k = 0; k < 4; k++) {
            const a = ang + (k - 1.5) * 0.25;
            S.projectiles.push({
              type: "bolt", x: e.x, y: e.y, vx: Math.cos(a) * 160, vy: Math.sin(a) * 160,
              life: 2, dmg: 2, friendly: false
            });
          }
          AudioFX.beep(160, 0.12, "sawtooth", 0.06, 60);
        }
      } else {
        tryMove(e, Math.cos(ang + Math.sin(e.t * 2)) * e.spd * 1.3 * dt,
          Math.sin(ang + Math.cos(e.t * 2)) * e.spd * 1.3 * dt, e.r);
        if (e.ai > 0.9) {
          e.ai = 0;
          for (let k = 0; k < 8; k++) {
            const a = e.t + k * Math.PI / 4;
            S.projectiles.push({
              type: "bolt", x: e.x, y: e.y, vx: Math.cos(a) * 130, vy: Math.sin(a) * 130,
              life: 2.4, dmg: 2, friendly: false
            });
          }
        }
      }
    }

    if (Math.hypot(e.x - p.x, e.y - p.y) < e.r + 11 && p.invuln <= 0) {
      damagePlayer(e.dmg);
      const kx = (p.x - e.x), ky = (p.y - e.y), kl = Math.hypot(kx, ky) || 1;
      tryMove(p, (kx / kl) * 18, (ky / kl) * 18, 11);
    }
  }

  tickParticles(dt);
  for (let i = S.floaters.length - 1; i >= 0; i--) {
    S.floaters[i].t -= dt;
    S.floaters[i].y -= 22 * dt;
    if (S.floaters[i].t <= 0) S.floaters.splice(i, 1);
  }

  flushJust();
}

function invSlots() {
  const s = [];
  s.push({ id: "potion", count: S.items.potion });
  s.push({ id: "bombs", count: S.items.bombs });
  if (S.items.boots) s.push({ id: "boots" });
  if (S.items.shield) s.push({ id: "shield" });
  if (S.items.compass) s.push({ id: "compass" });
  if (S.items.lantern) s.push({ id: "lantern" });
  if (S.items.charm) s.push({ id: "charm" });
  if (S.items.bow) s.push({ id: "bow" });
  return s;
}

// ---------------------------------------------------------------------------
// Draw
// ---------------------------------------------------------------------------
function tileColor(t, x, y) {
  const h = ((x * 13 + y * 7) % 5);
  switch (t) {
    case TILE.GRASS: return h < 2 ? "#3d6b3a" : "#355e34";
    case TILE.DIRT: return "#6b4f32";
    case TILE.WATER: return h < 2 ? "#2a6a8a" : "#245e7c";
    case TILE.TREE: return "#1e3d22";
    case TILE.STONE: return "#5a5856";
    case TILE.CAVE: return "#2b2624";
    case TILE.SAND: return "#c2a66a";
    case TILE.CRACK: return "#6a6258";
    case TILE.TALL: return "#2f552c";
    case TILE.LAVA: return h < 2 ? "#c44512" : "#e05a18";
    case TILE.WOOD: return "#7a5330";
    case TILE.FLOWER: return "#3d6b3a";
    case TILE.FENCE: return "#5a3c22";
    case TILE.DARK: return "#3a3836";
    case TILE.SHRINE: return "#6e7a88";
    case TILE.CARPET: return "#7a2e2e";
    case TILE.WALL: return "#3e3a38";
    case TILE.RUIN: return "#6a645c";
    case TILE.SNOW: return "#d5dde4";
    case TILE.ICE: return "#a8c8d4";
    case TILE.PILLAR: return "#4a4644";
    case TILE.HEARTH: return "#8a3a18";
    case TILE.PATH: return "#8a7348";
    default: return "#355e34";
  }
}

function drawTile(t, sx, sy, tx, ty) {
  if (S.flags.cracked[`${S.roomX},${S.roomY},${tx},${ty}`] && t === TILE.CRACK) t = TILE.STONE;
  ctx.fillStyle = tileColor(t, tx, ty);
  ctx.fillRect(sx, sy, TW, TH);

  if (t === TILE.GRASS || t === TILE.FLOWER || t === TILE.TALL) {
    ctx.fillStyle = "rgba(0,0,0,0.08)";
    ctx.fillRect(sx, sy + TH - 6, TW, 6);
  }
  if (t === TILE.TREE) {
    ctx.fillStyle = "#1a301c";
    ctx.fillRect(sx, sy, TW, TH);
    ctx.fillStyle = "#2d6b34";
    ctx.beginPath(); ctx.arc(sx + 24, sy + 20, 18, 0, Math.PI * 2); ctx.fill();
    ctx.fillStyle = "#3e8a44";
    ctx.beginPath(); ctx.arc(sx + 16, sy + 16, 10, 0, Math.PI * 2); ctx.fill();
    ctx.fillStyle = "#5a3a20";
    ctx.fillRect(sx + 20, sy + 30, 8, 16);
  }
  if (t === TILE.WATER) {
    ctx.fillStyle = "rgba(180,230,255,0.18)";
    const w = (Math.sin(S.time * 2 + tx + ty) + 1) * 8;
    ctx.fillRect(sx + 8, sy + 16 + w * 0.2, 20, 4);
  }
  if (t === TILE.FLOWER) {
    ctx.fillStyle = (tx + ty) % 2 ? "#e07a9a" : "#f3c14a";
    ctx.beginPath(); ctx.arc(sx + 22, sy + 20, 4, 0, Math.PI * 2); ctx.fill();
    ctx.fillStyle = "#2f552c";
    ctx.fillRect(sx + 21, sy + 24, 3, 8);
  }
  if (t === TILE.TALL) {
    ctx.strokeStyle = "#245028"; ctx.lineWidth = 2;
    for (let i = 0; i < 4; i++) {
      ctx.beginPath();
      ctx.moveTo(sx + 10 + i * 9, sy + 40);
      ctx.quadraticCurveTo(sx + 12 + i * 9, sy + 20, sx + 8 + i * 9 + Math.sin(S.time * 2 + i) * 3, sy + 10);
      ctx.stroke();
    }
  }
  if (t === TILE.WALL || t === TILE.CAVE || t === TILE.PILLAR) {
    ctx.fillStyle = "rgba(0,0,0,0.25)";
    ctx.fillRect(sx, sy + TH - 10, TW, 10);
    ctx.fillStyle = "rgba(255,255,255,0.05)";
    ctx.fillRect(sx + 4, sy + 4, TW - 8, 8);
  }
  if (t === TILE.PILLAR) {
    ctx.fillStyle = "#6a6662";
    ctx.fillRect(sx + 12, sy + 4, 24, 40);
    ctx.fillStyle = "#8a8680";
    ctx.fillRect(sx + 8, sy + 2, 32, 8);
  }
  if (t === TILE.HEARTH) {
    ctx.fillStyle = `hsl(${18 + Math.sin(S.time * 8 + tx) * 12},80%,45%)`;
    ctx.beginPath(); ctx.arc(sx + 24, sy + 28, 10 + Math.sin(S.time * 10) * 2, 0, Math.PI * 2); ctx.fill();
    ctx.fillStyle = "#ffd166";
    ctx.beginPath(); ctx.arc(sx + 24, sy + 26, 4, 0, Math.PI * 2); ctx.fill();
  }
  if (t === TILE.SHRINE) {
    ctx.fillStyle = "#cfd8e0";
    ctx.fillRect(sx + 10, sy + 22, 28, 18);
    ctx.fillStyle = "#9ad1ff";
    ctx.globalAlpha = 0.55 + Math.sin(S.time * 3) * 0.2;
    ctx.beginPath(); ctx.arc(sx + 24, sy + 16, 8, 0, Math.PI * 2); ctx.fill();
    ctx.globalAlpha = 1;
  }
  if (t === TILE.LAVA) {
    ctx.fillStyle = "#ffd166";
    ctx.globalAlpha = 0.35 + Math.sin(S.time * 6 + tx * 2) * 0.15;
    ctx.fillRect(sx + 8, sy + 16, 12, 6);
    ctx.globalAlpha = 1;
  }
  if (t === TILE.CRACK) {
    ctx.fillStyle = "#3e3830";
    ctx.fillRect(sx + 2, sy + 2, TW - 4, TH - 4);
    ctx.strokeStyle = "#c9a227"; ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.moveTo(sx + 10, sy + 8); ctx.lineTo(sx + 22, sy + 26); ctx.lineTo(sx + 16, sy + 40);
    ctx.moveTo(sx + 32, sy + 10); ctx.lineTo(sx + 28, sy + 30);
    ctx.stroke();
  }
  if (t === TILE.CARPET) {
    ctx.fillStyle = "#9a3a3a";
    ctx.fillRect(sx + 4, sy + 4, TW - 8, TH - 8);
    ctx.fillStyle = "#f3c14a";
    ctx.fillRect(sx + 20, sy + 6, 6, TH - 12);
  }
  if (t === TILE.WOOD) {
    ctx.strokeStyle = "rgba(0,0,0,0.15)";
    ctx.beginPath(); ctx.moveTo(sx, sy + 24); ctx.lineTo(sx + TW, sy + 24); ctx.stroke();
  }
  if (t === TILE.FENCE) {
    ctx.fillStyle = "#8a6238";
    ctx.fillRect(sx + 8, sy + 16, 6, 28);
    ctx.fillRect(sx + 32, sy + 16, 6, 28);
    ctx.fillRect(sx + 6, sy + 20, 36, 6);
  }
}

function drawWeapon(p, wx, wy) {
  const w = weapon();
  const swing = p.attacking > 0 ? (1 - p.attacking / 0.18) : 0;
  ctx.save();
  ctx.translate(wx, wy);
  let ang = 0;
  if (p.dir === 0) ang = 1.2;
  if (p.dir === 1) ang = Math.PI + 0.2;
  if (p.dir === 2) ang = -0.2;
  if (p.dir === 3) ang = -1.4;
  ang += swing * (p.dir === 1 || p.dir === 3 ? -2.2 : 2.2);
  ctx.rotate(ang);
  if (w.glow) {
    ctx.shadowColor = w.glow; ctx.shadowBlur = 12;
  }
  ctx.fillStyle = w.color;
  ctx.fillRect(6, -3, 26, 6);
  ctx.fillStyle = "#3a2a18";
  ctx.fillRect(0, -4, 8, 8);
  if (w.id === "ember") {
    ctx.fillStyle = "#ffd166";
    ctx.fillRect(24, -2, 10, 4);
  }
  if (w.id === "iron") {
    ctx.fillStyle = "#8aa0b4";
    ctx.fillRect(28, -5, 6, 10);
  }
  ctx.restore();
}

function drawPlayer() {
  const p = S.player;
  const x = worldX(p.x), y = worldY(p.y);
  const bob = (p.dash > 0) ? 0 : Math.sin(p.anim) * (S.player.stamina < 18 ? 1.2 : 2.4);
  const tired = p.stamina < 18;

  // shadow
  ctx.fillStyle = "rgba(0,0,0,0.3)";
  ctx.beginPath(); ctx.ellipse(x, y + 14, 11, 5, 0, 0, Math.PI * 2); ctx.fill();

  ctx.save();
  if (p.flash > 0 && Math.floor(S.time * 20) % 2 === 0) ctx.globalAlpha = 0.35;
  if (p.hurtT > 0) ctx.translate(Math.sin(S.time * 50) * 2, 0);

  const cloak = p.weapon === "ember" ? "#8a2210" : p.weapon === "iron" ? "#2c3a4a" : "#3a4a32";
  const skin = "#e6b48a";
  const bootC = S.items.boots ? "#c9a227" : "#3a2a18";

  // legs
  ctx.fillStyle = bootC;
  const step = Math.sin(p.anim) * (tired ? 3 : 5);
  ctx.fillRect(x - 7, y + 6 + bob, 5, 9);
  ctx.fillRect(x + 2, y + 6 + bob - step * 0.3, 5, 9);

  // body
  ctx.fillStyle = cloak;
  ctx.fillRect(x - 9, y - 10 + bob, 18, 18);
  // belt
  ctx.fillStyle = "#c9a227";
  ctx.fillRect(x - 9, y + 4 + bob, 18, 3);

  // shield
  if (S.items.shield && p.dir !== 2) {
    ctx.fillStyle = "#8a929a";
    ctx.beginPath();
    ctx.ellipse(x - 12, y - 2 + bob, 6, 10, 0, 0, Math.PI * 2);
    ctx.fill();
    ctx.strokeStyle = "#d4dce4"; ctx.lineWidth = 1.5;
    ctx.stroke();
  }

  // head
  ctx.fillStyle = skin;
  ctx.beginPath(); ctx.arc(x, y - 16 + bob, 8, 0, Math.PI * 2); ctx.fill();
  ctx.fillStyle = "#2a1c10";
  ctx.beginPath(); ctx.arc(x, y - 18 + bob, 8, Math.PI, 0); ctx.fill();

  // eyes
  ctx.fillStyle = "#1a120c";
  if (p.dir !== 3) {
    const ex = p.dir === 1 ? -3 : p.dir === 2 ? 3 : 0;
    ctx.fillRect(x - 3 + ex, y - 17 + bob, 2, 2);
    ctx.fillRect(x + 2 + ex, y - 17 + bob, 2, 2);
  }

  // lantern
  if (S.items.lantern) {
    ctx.fillStyle = "#c9a227";
    ctx.fillRect(x + 10, y - 4 + bob, 5, 8);
    ctx.fillStyle = "rgba(255,220,120,0.7)";
    ctx.beginPath(); ctx.arc(x + 12, y - 6 + bob, 4 + Math.sin(S.time * 6), 0, Math.PI * 2); ctx.fill();
  }

  // bow on back if owned and not attacking with it
  if (S.items.bow && p.dir === 0) {
    ctx.strokeStyle = "#6b3a18"; ctx.lineWidth = 2;
    ctx.beginPath(); ctx.arc(x + 10, y - 4 + bob, 10, -1, 1.2); ctx.stroke();
  }

  if (p.dir !== 3) drawWeapon(p, x + (p.dir === 1 ? -6 : 6), y - 2 + bob);
  else drawWeapon(p, x + 8, y - 8 + bob);

  if (S.items.shield && p.dir === 2) {
    ctx.fillStyle = "#8a929a";
    ctx.beginPath();
    ctx.ellipse(x + 12, y - 2 + bob, 6, 10, 0, 0, Math.PI * 2);
    ctx.fill();
  }

  ctx.restore();

  // sword arc
  if (p.attacking > 0) {
    const w = weapon();
    ctx.save();
    ctx.translate(x, y);
    const fang = Math.atan2(p.facing.y, p.facing.x);
    ctx.rotate(fang);
    ctx.globalAlpha = p.attacking / 0.18 * 0.45;
    ctx.strokeStyle = w.glow || "#fff4d0";
    ctx.lineWidth = 6;
    ctx.beginPath();
    ctx.arc(0, 0, w.range - 4, -w.arc, w.arc);
    ctx.stroke();
    ctx.restore();
  }
}

function drawEnemy(e) {
  const x = worldX(e.x), y = worldY(e.y);
  ctx.save();
  if (e.hurt > 0) ctx.globalAlpha = 0.55;
  ctx.fillStyle = "rgba(0,0,0,0.28)";
  ctx.beginPath(); ctx.ellipse(x, y + e.r, e.r * 0.8, 4, 0, 0, Math.PI * 2); ctx.fill();

  if (e.kind === "slime") {
    const sq = 1 + Math.sin(e.t * 6) * 0.08;
    ctx.fillStyle = "#5bbd4a";
    ctx.beginPath(); ctx.ellipse(x, y, 14, 11 * sq, 0, 0, Math.PI * 2); ctx.fill();
    ctx.fillStyle = "#163818";
    ctx.fillRect(x - 5, y - 4, 3, 4); ctx.fillRect(x + 3, y - 4, 3, 4);
  } else if (e.kind === "wolf") {
    ctx.fillStyle = "#6a5344";
    ctx.fillRect(x - 14, y - 8, 28, 14);
    ctx.fillRect(x + 8, y - 14, 12, 10);
    ctx.fillStyle = "#1a120c";
    ctx.fillRect(x + 16, y - 11, 3, 3);
    ctx.fillStyle = "#4a3a30";
    ctx.fillRect(x - 14, y + 4, 5, 8); ctx.fillRect(x + 4, y + 4, 5, 8);
  } else if (e.kind === "bat") {
    ctx.fillStyle = "#4a3060";
    const flap = Math.sin(e.t * 12) * 8;
    ctx.beginPath();
    ctx.moveTo(x, y);
    ctx.lineTo(x - 18, y - 4 - flap);
    ctx.lineTo(x - 8, y + 2);
    ctx.closePath(); ctx.fill();
    ctx.beginPath();
    ctx.moveTo(x, y);
    ctx.lineTo(x + 18, y - 4 - flap);
    ctx.lineTo(x + 8, y + 2);
    ctx.closePath(); ctx.fill();
    ctx.fillStyle = "#2a1838";
    ctx.beginPath(); ctx.arc(x, y, 6, 0, Math.PI * 2); ctx.fill();
  } else if (e.kind === "skel") {
    ctx.fillStyle = "#e8dcc8";
    ctx.fillRect(x - 8, y - 8, 16, 18);
    ctx.beginPath(); ctx.arc(x, y - 14, 8, 0, Math.PI * 2); ctx.fill();
    ctx.fillStyle = "#1a120c";
    ctx.fillRect(x - 4, y - 16, 3, 3); ctx.fillRect(x + 2, y - 16, 3, 3);
    ctx.fillStyle = "#8a929a";
    ctx.fillRect(x + 8, y - 4, 14, 3);
  } else if (e.kind === "mage") {
    ctx.fillStyle = "#3a2870";
    ctx.beginPath();
    ctx.moveTo(x, y + 14); ctx.lineTo(x - 14, y + 14); ctx.lineTo(x, y - 16); ctx.lineTo(x + 14, y + 14);
    ctx.fill();
    ctx.fillStyle = "#e6b48a";
    ctx.beginPath(); ctx.arc(x, y - 14, 7, 0, Math.PI * 2); ctx.fill();
    ctx.fillStyle = "#221848";
    ctx.beginPath(); ctx.moveTo(x - 8, y - 16); ctx.lineTo(x, y - 28); ctx.lineTo(x + 8, y - 16); ctx.fill();
  } else if (e.kind === "guard") {
    ctx.fillStyle = "#4a5a38";
    ctx.fillRect(x - 12, y - 10, 24, 22);
    ctx.fillStyle = "#8a929a";
    ctx.fillRect(x - 16, y - 8, 6, 18);
    ctx.fillStyle = "#e6b48a";
    ctx.beginPath(); ctx.arc(x, y - 16, 8, 0, Math.PI * 2); ctx.fill();
    ctx.fillStyle = "#3a2a10";
    ctx.fillRect(x - 8, y - 24, 16, 6);
  } else if (e.kind === "boss") {
    const pulse = 1 + Math.sin(e.t * 4) * 0.04;
    ctx.fillStyle = "#4a1010";
    ctx.beginPath(); ctx.ellipse(x, y, 22 * pulse, 26 * pulse, 0, 0, Math.PI * 2); ctx.fill();
    ctx.fillStyle = "#ff6a2a";
    ctx.beginPath(); ctx.arc(x - 8, y - 10, 4, 0, Math.PI * 2); ctx.arc(x + 8, y - 10, 4, 0, Math.PI * 2); ctx.fill();
    ctx.fillStyle = "#1a0808";
    ctx.fillRect(x - 8, y + 2, 16, 4);
    ctx.fillStyle = "#c9a227";
    ctx.fillRect(x - 18, y - 20, 36, 6);
    // hp bar
    const bw = 64, bh = 6;
    ctx.fillStyle = "#1a0808";
    ctx.fillRect(x - bw / 2, y - 42, bw, bh);
    ctx.fillStyle = "#c23b22";
    ctx.fillRect(x - bw / 2, y - 42, bw * (e.hp / e.maxHp), bh);
  }

  if (e.burn > 0) {
    ctx.fillStyle = "rgba(255,100,20,0.5)";
    ctx.beginPath(); ctx.arc(x, y - 8, 10 + Math.sin(S.time * 10) * 2, 0, Math.PI * 2); ctx.fill();
  }
  if (e.kind !== "boss" && e.hp < e.maxHp) {
    ctx.fillStyle = "#1a0808";
    ctx.fillRect(x - 12, y - e.r - 12, 24, 4);
    ctx.fillStyle = "#c23b22";
    ctx.fillRect(x - 12, y - e.r - 12, 24 * (e.hp / e.maxHp), 4);
  }
  ctx.restore();
}

function drawEntity(e) {
  const x = worldX(e.x), y = worldY(e.y);
  if (e.type === "enemy") { drawEnemy(e); return; }
  if (e.type === "npc") {
    ctx.fillStyle = "rgba(0,0,0,0.28)";
    ctx.beginPath(); ctx.ellipse(x, y + 12, 10, 4, 0, 0, Math.PI * 2); ctx.fill();
    const robe = e.role === "shop" ? "#6a3a78" : e.role === "smith" ? "#6a4a28" : e.role === "forge" ? "#8a3010" : "#3a4a68";
    ctx.fillStyle = robe;
    ctx.fillRect(x - 8, y - 8, 16, 20);
    ctx.fillStyle = "#e6b48a";
    ctx.beginPath(); ctx.arc(x, y - 14, 7, 0, Math.PI * 2); ctx.fill();
    ctx.fillStyle = "#f3c14a";
    ctx.font = "bold 12px serif";
    ctx.textAlign = "center";
    ctx.fillText("▾", x, y - 26 + Math.sin(S.time * 3) * 2);
    if (Math.hypot(e.x - S.player.x, e.y - S.player.y) < 40) {
      ctx.fillStyle = "#f0e6c8";
      ctx.font = "12px serif";
      ctx.fillText(e.name, x, y + 26);
    }
  }
  if (e.type === "chest") {
    ctx.fillStyle = e.open ? "#4a3a20" : "#c9a227";
    ctx.fillRect(x - 12, y - 8, 24, 16);
    ctx.fillStyle = e.open ? "#2a2010" : "#8a6a12";
    ctx.fillRect(x - 12, y - 14, 24, 8);
    ctx.fillStyle = "#6a2010";
    ctx.fillRect(x - 2, y - 4, 4, 6);
  }
  if (e.type === "shrine") {
    ctx.globalAlpha = 0.35 + Math.sin(S.time * 2) * 0.1;
    ctx.fillStyle = "#9ad1ff";
    ctx.beginPath(); ctx.arc(x, y - 8, 16, 0, Math.PI * 2); ctx.fill();
    ctx.globalAlpha = 1;
    if (Math.hypot(e.x - S.player.x, e.y - S.player.y) < 40) {
      ctx.fillStyle = "#cfe8ff";
      ctx.font = "12px serif"; ctx.textAlign = "center";
      ctx.fillText("E  Pray / Save", x, y + 28);
    }
  }
  if (e.type === "pickup") {
    const bob = Math.sin(S.time * 4 + (e.bob || 0)) * 4;
    if (e.kind === "gold") {
      ctx.fillStyle = "#f3c14a";
      ctx.beginPath(); ctx.arc(x, y + bob, 6, 0, Math.PI * 2); ctx.fill();
      ctx.fillStyle = "#fff3a0";
      ctx.beginPath(); ctx.arc(x - 1, y - 1 + bob, 2, 0, Math.PI * 2); ctx.fill();
    } else if (e.kind === "potion") {
      ctx.fillStyle = "#c23b22";
      ctx.fillRect(x - 5, y - 4 + bob, 10, 12);
      ctx.fillStyle = "#e8d5a3";
      ctx.fillRect(x - 3, y - 8 + bob, 6, 4);
    }
  }
}

function drawProjectiles() {
  for (const pr of S.projectiles) {
    const x = worldX(pr.x), y = worldY(pr.y);
    if (pr.type === "arrow") {
      ctx.save();
      ctx.translate(x, y);
      ctx.rotate(Math.atan2(pr.vy, pr.vx));
      ctx.fillStyle = "#d8c8a0";
      ctx.fillRect(-8, -1.5, 16, 3);
      ctx.fillStyle = "#8a2a12";
      ctx.beginPath(); ctx.moveTo(8, 0); ctx.lineTo(2, -4); ctx.lineTo(2, 4); ctx.fill();
      ctx.restore();
    } else {
      ctx.fillStyle = "#c96bff";
      ctx.shadowColor = "#c96bff"; ctx.shadowBlur = 10;
      ctx.beginPath(); ctx.arc(x, y, 5, 0, Math.PI * 2); ctx.fill();
      ctx.shadowBlur = 0;
    }
  }
  for (const b of S.bombs) {
    const x = worldX(b.x), y = worldY(b.y);
    ctx.fillStyle = "#2a2a28";
    ctx.beginPath(); ctx.arc(x, y, 8, 0, Math.PI * 2); ctx.fill();
    ctx.fillStyle = b.t < 0.3 && Math.floor(S.time * 12) % 2 ? "#fff" : "#c23b22";
    ctx.beginPath(); ctx.arc(x, y - 4, 3, 0, Math.PI * 2); ctx.fill();
  }
}

function drawHearts(x, y) {
  const p = S.player;
  for (let i = 0; i < p.hearts; i++) {
    const hx = x + i * 28;
    const hp = p.hp;
    const full = hp >= (i + 1) * 2;
    const half = !full && hp >= i * 2 + 1;
    ctx.save();
    if (full || half) {
      ctx.fillStyle = "#c23b22";
      heartPath(ctx, hx, y, 10);
      ctx.fill();
      if (half) {
        ctx.save();
        ctx.beginPath();
        ctx.rect(hx, y - 4, 14, 20);
        ctx.clip();
        ctx.fillStyle = "#2a2118";
        heartPath(ctx, hx, y, 10);
        ctx.fill();
        ctx.restore();
      }
      ctx.strokeStyle = "#5a120c"; ctx.lineWidth = 1.2;
      heartPath(ctx, hx, y, 10);
      ctx.stroke();
    } else {
      ctx.fillStyle = "#2a2118";
      heartPath(ctx, hx, y, 10);
      ctx.fill();
      ctx.strokeStyle = "#5a4030"; ctx.lineWidth = 1; ctx.stroke();
    }
    ctx.restore();
  }
}

function drawItemIcon(id, x, y, s) {
  ctx.save();
  ctx.translate(x, y);
  if (id === "potion") {
    ctx.fillStyle = "#c23b22"; ctx.fillRect(-6, -2, 12, 14);
    ctx.fillStyle = "#e8d5a3"; ctx.fillRect(-4, -6, 8, 5);
  } else if (id === "bomb") {
    ctx.fillStyle = "#2a2a28"; ctx.beginPath(); ctx.arc(0, 2, 8, 0, Math.PI * 2); ctx.fill();
    ctx.strokeStyle = "#c9a227"; ctx.beginPath(); ctx.moveTo(4, -4); ctx.lineTo(8, -10); ctx.stroke();
  } else if (id === "boots") {
    ctx.fillStyle = "#c9a227"; ctx.fillRect(-10, 0, 8, 10); ctx.fillRect(2, 0, 8, 10);
  } else if (id === "shield") {
    ctx.fillStyle = "#8a929a";
    ctx.beginPath(); ctx.moveTo(0, -10); ctx.lineTo(10, -4); ctx.lineTo(8, 10); ctx.lineTo(0, 14); ctx.lineTo(-8, 10); ctx.lineTo(-10, -4);
    ctx.closePath(); ctx.fill();
  } else if (id === "compass") {
    ctx.strokeStyle = "#f3c14a"; ctx.lineWidth = 2;
    ctx.beginPath(); ctx.arc(0, 0, 9, 0, Math.PI * 2); ctx.stroke();
    ctx.fillStyle = "#c23b22"; ctx.beginPath(); ctx.moveTo(0, -7); ctx.lineTo(3, 6); ctx.lineTo(0, 3); ctx.lineTo(-3, 6); ctx.fill();
  } else if (id === "lantern") {
    ctx.fillStyle = "#c9a227"; ctx.fillRect(-5, -4, 10, 12);
    ctx.fillStyle = "#ffe08a"; ctx.fillRect(-3, -2, 6, 6);
  } else if (id === "charm") {
    ctx.fillStyle = "#7dcea0";
    heartPath(ctx, 0, -6, 7); ctx.fill();
  } else if (id === "bow") {
    ctx.strokeStyle = "#8b5a2b"; ctx.lineWidth = 3;
    ctx.beginPath(); ctx.arc(-2, 0, 10, -1.2, 1.2); ctx.stroke();
    ctx.strokeStyle = "#ddd"; ctx.lineWidth = 1;
    ctx.beginPath(); ctx.moveTo(-2, -9); ctx.lineTo(-2, 9); ctx.stroke();
  }
  ctx.restore();
}

function drawHUD() {
  // top bar
  ctx.fillStyle = "rgba(14,10,8,0.86)";
  ctx.fillRect(0, 0, canvas.width, 64);
  ctx.fillStyle = "#3a2e22";
  ctx.fillRect(0, 64, canvas.width, 2);

  drawHearts(24, 18);

  // stamina
  ctx.fillStyle = "#2a2118";
  ctx.fillRect(24, 46, 140, 8);
  ctx.fillStyle = S.player.stamina < 18 ? "#c23b22" : "#d4a017";
  ctx.fillRect(24, 46, 140 * (S.player.stamina / S.player.maxStamina), 8);
  ctx.strokeStyle = "#7a6544"; ctx.strokeRect(24, 46, 140, 8);

  // weapon
  ctx.fillStyle = "#f0e6c8";
  ctx.font = "14px Palatino Linotype, serif";
  ctx.textAlign = "left";
  ctx.fillText(weapon().name, 180, 28);
  ctx.fillStyle = "#b9a57a";
  ctx.font = "12px Palatino Linotype, serif";
  ctx.fillText(ROOM_META[roomId()].name, 180, 46);

  // gold
  ctx.fillStyle = "#f3c14a";
  ctx.font = "bold 16px Palatino Linotype, serif";
  ctx.textAlign = "right";
  ctx.fillText("◉ " + S.gold, canvas.width - 24, 30);

  // quick items
  ctx.textAlign = "right";
  ctx.font = "12px Palatino Linotype, serif";
  ctx.fillStyle = "#e8d5a3";
  ctx.fillText("H tonic ×" + S.items.potion + "   B caps ×" + S.items.bombs + (S.items.bow ? "   K bow" : ""), canvas.width - 24, 52);

  // minimap
  const mw = 11, mh = 9, mx = canvas.width - 24 - MAP_W * mw, my = canvas.height - 24 - MAP_H * mh;
  ctx.fillStyle = "rgba(14,10,8,0.7)";
  ctx.fillRect(mx - 6, my - 6, MAP_W * mw + 12, MAP_H * mh + 12);
  for (let y = 0; y < MAP_H; y++) for (let x = 0; x < MAP_W; x++) {
    const vis = S.visited[`${x},${y}`];
    const here = x === S.roomX && y === S.roomY;
    ctx.fillStyle = here ? "#f3c14a" : vis ? "#5a7a4a" : (S.items.compass ? "#3a3228" : "#1a1612");
    ctx.fillRect(mx + x * mw, my + y * mh, mw - 1, mh - 1);
    if (S.items.compass && vis) {
      const rid = x + y * MAP_W;
      // mark shrines
      if (rid === 7 || rid === 16 || rid === 1) {
        ctx.fillStyle = "#9ad1ff";
        ctx.fillRect(mx + x * mw + 3, my + y * mh + 3, 3, 3);
      }
    }
  }

  // bottom message
  if (S.msgT > 0) {
    ctx.globalAlpha = Math.min(1, S.msgT);
    ctx.fillStyle = "rgba(14,10,8,0.8)";
    ctx.fillRect(canvas.width / 2 - 260, canvas.height - 86, 520, 28);
    ctx.fillStyle = "#f3e2b0";
    ctx.font = "15px Palatino Linotype, serif";
    ctx.textAlign = "center";
    ctx.fillText(S.msg, canvas.width / 2, canvas.height - 67);
    ctx.globalAlpha = 1;
  }

  // interact prompt
  if (S.mode === "play") {
    for (const e of entities) {
      if (e.type === "enemy" || e.type === "pickup") continue;
      if (Math.hypot(e.x - S.player.x, e.y - S.player.y) < 32) {
        ctx.fillStyle = "#f3e2b0";
        ctx.font = "13px Palatino Linotype, serif";
        ctx.textAlign = "center";
        const label = e.type === "chest" ? "E  Open" : e.type === "shrine" ? "" : "E  Speak";
        if (label) ctx.fillText(label, worldX(e.x), worldY(e.y) - 34);
      }
    }
  }
}

function drawDarkness() {
  const meta = ROOM_META[roomId()];
  if (!meta.dark) return;
  const p = S.player;
  const gx = worldX(p.x), gy = worldY(p.y);
  const rad = S.items.lantern ? 180 : 56;
  const ink = S.items.lantern ? "rgba(4,6,10,0.62)" : "rgba(2,2,4,0.93)";
  ctx.save();
  const g = ctx.createRadialGradient(gx, gy, 16, gx, gy, rad);
  g.addColorStop(0, "rgba(2,2,4,0)");
  g.addColorStop(0.45, "rgba(2,2,4,0.08)");
  g.addColorStop(0.75, S.items.lantern ? "rgba(4,6,10,0.35)" : "rgba(2,2,4,0.72)");
  g.addColorStop(1, ink);
  ctx.fillStyle = g;
  ctx.fillRect(MAP_OX, MAP_OY, VIEW_W, VIEW_H);
  ctx.restore();
}

function drawPanelBox(x, y, w, h) {
  ctx.fillStyle = "rgba(18,12,8,0.94)";
  ctx.strokeStyle = "#c9a227";
  ctx.lineWidth = 2;
  ctx.fillRect(x, y, w, h);
  ctx.strokeRect(x, y, w, h);
}

function drawUIModes() {
  if (S.mode === "talk" && S.talk) {
    drawPanelBox(80, 470, 800, 140);
    ctx.fillStyle = "#f3c14a";
    ctx.font = "bold 16px Palatino Linotype, serif";
    ctx.textAlign = "left";
    ctx.fillText(S.talk.name, 104, 500);
    ctx.fillStyle = "#f0e6c8";
    ctx.font = "16px Palatino Linotype, serif";
    wrapText(S.talk.lines[Math.min(S.talk.i, S.talk.lines.length - 1)], 104, 530, 750, 22);
    ctx.fillStyle = "#b9a57a";
    ctx.font = "12px Palatino Linotype, serif";
    ctx.textAlign = "right";
    ctx.fillText("E  continue", 860, 592);
  }
  if (S.mode === "shop") {
    drawPanelBox(200, 80, 560, 460);
    ctx.fillStyle = "#f3c14a";
    ctx.font = "bold 22px Palatino Linotype, serif";
    ctx.textAlign = "center";
    ctx.fillText("MARA'S WARES", 480, 118);
    ctx.fillStyle = "#b9a57a";
    ctx.font = "14px Palatino Linotype, serif";
    ctx.fillText("Coin: " + S.gold, 480, 142);
    SHOP.forEach((it, i) => {
      const y = 170 + i * 70;
      const owned = it.unique && S.items[it.item];
      ctx.fillStyle = i === S.shopSel ? "rgba(201,162,39,0.2)" : "transparent";
      ctx.fillRect(230, y - 8, 500, 62);
      ctx.strokeStyle = i === S.shopSel ? "#c9a227" : "#3a2e22";
      ctx.strokeRect(230, y - 8, 500, 62);
      drawItemIcon(it.item === "bombs" ? "bomb" : it.item, 262, y + 22, 1);
      ctx.textAlign = "left";
      ctx.fillStyle = owned ? "#7a6a50" : "#f0e6c8";
      ctx.font = "16px Palatino Linotype, serif";
      ctx.fillText(owned ? it.name + "  (owned)" : it.name, 292, y + 18);
      ctx.fillStyle = "#b9a57a";
      ctx.font = "13px Palatino Linotype, serif";
      ctx.fillText(it.desc, 292, y + 38);
      ctx.textAlign = "right";
      ctx.fillStyle = "#f3c14a";
      ctx.fillText(it.price + "g", 710, y + 28);
    });
    ctx.textAlign = "center";
    ctx.fillStyle = "#b9a57a";
    ctx.font = "13px Palatino Linotype, serif";
    ctx.fillText("W/S select   E buy   Esc leave", 480, 510);
  }
  if (S.mode === "inventory") {
    drawPanelBox(160, 90, 640, 430);
    ctx.fillStyle = "#f3c14a";
    ctx.font = "bold 22px Palatino Linotype, serif";
    ctx.textAlign = "center";
    ctx.fillText("TRAVELER'S PACK", 480, 128);
    const slots = invSlots();
    slots.forEach((sl, i) => {
      const col = i % 4, row = Math.floor(i / 4);
      const x = 220 + col * 130, y = 170 + row * 110;
      ctx.strokeStyle = i === S.invSel ? "#c9a227" : "#4a3a28";
      ctx.lineWidth = 2;
      ctx.fillStyle = "rgba(40,30,20,0.8)";
      ctx.fillRect(x, y, 110, 90);
      ctx.strokeRect(x, y, 110, 90);
      drawItemIcon(sl.id === "bombs" ? "bomb" : sl.id, x + 55, y + 32, 1);
      const def = ITEMS[sl.id];
      ctx.fillStyle = "#f0e6c8";
      ctx.font = "12px Palatino Linotype, serif";
      ctx.fillText(def ? def.name : sl.id, x + 55, y + 62);
      if (sl.count != null) {
        ctx.fillStyle = "#f3c14a";
        ctx.fillText("×" + sl.count, x + 55, y + 78);
      }
    });
    const cur = slots[S.invSel];
    if (cur && ITEMS[cur.id]) {
      ctx.fillStyle = "#b9a57a";
      ctx.font = "14px Palatino Linotype, serif";
      ctx.fillText(ITEMS[cur.id].desc, 480, 470);
    }
    ctx.fillStyle = "#7a6a50";
    ctx.font = "12px Palatino Linotype, serif";
    ctx.fillText("E use tonic   I close", 480, 498);
  }
  if (S.mode === "pause") {
    drawPanelBox(300, 200, 360, 200);
    ctx.fillStyle = "#f3c14a";
    ctx.font = "bold 22px Palatino Linotype, serif";
    ctx.textAlign = "center";
    ctx.fillText("PAUSED", 480, 250);
    ctx.fillStyle = "#f0e6c8";
    ctx.font = "15px Palatino Linotype, serif";
    ctx.fillText("Esc resume", 480, 290);
    ctx.fillStyle = "#b9a57a";
    ctx.font = "13px Palatino Linotype, serif";
    ctx.fillText("Find shrines to keep your story.", 480, 330);
  }
  if (S.mode === "dead") {
    ctx.fillStyle = "rgba(20,4,4,0.55)";
    ctx.fillRect(0, 0, canvas.width, canvas.height);
    ctx.fillStyle = "#c23b22";
    ctx.font = "bold 42px Palatino Linotype, serif";
    ctx.textAlign = "center";
    ctx.fillText("FALLEN", 480, 280);
    ctx.fillStyle = "#f0e6c8";
    ctx.font = "16px Palatino Linotype, serif";
    ctx.fillText("Press R to rise at your last shrine", 480, 330);
  }
  if (S.mode === "win") {
    ctx.fillStyle = "rgba(20,10,4,0.6)";
    ctx.fillRect(0, 0, canvas.width, canvas.height);
    ctx.fillStyle = "#f3c14a";
    ctx.font = "bold 40px Palatino Linotype, serif";
    ctx.textAlign = "center";
    ctx.fillText("THE TEMPLE SLEEPS", 480, 250);
    ctx.fillStyle = "#f0e6c8";
    ctx.font = "16px Palatino Linotype, serif";
    ctx.fillText("The Ember Warden is ash. Brackenford will see another dawn.", 480, 300);
    ctx.fillText("Hearts " + S.player.hearts + "/5   Blade: " + weapon().name + "   Coin " + S.gold, 480, 340);
    ctx.fillStyle = "#b9a57a";
    ctx.fillText("Press Enter to return to title", 480, 400);
  }
}

function wrapText(text, x, y, max, lh) {
  const words = text.split(" ");
  let line = "", yy = y;
  ctx.textAlign = "left";
  for (const w of words) {
    const test = line + w + " ";
    if (ctx.measureText(test).width > max) {
      ctx.fillText(line, x, yy); line = w + " "; yy += lh;
    } else line = test;
  }
  ctx.fillText(line, x, yy);
}

function draw() {
  ctx.setTransform(1, 0, 0, 1, 0, 0);
  ctx.clearRect(0, 0, canvas.width, canvas.height);

  // frame
  ctx.fillStyle = "#120e0b";
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  const shx = S.shake ? (Math.random() - 0.5) * S.shake : 0;
  const shy = S.shake ? (Math.random() - 0.5) * S.shake : 0;
  ctx.save();
  ctx.translate(shx, shy);

  // playfield backdrop
  ctx.fillStyle = "#1a1612";
  ctx.fillRect(MAP_OX - 4, MAP_OY - 4, VIEW_W + 8, VIEW_H + 8);

  const tiles = roomTiles();
  for (let y = 0; y < ROWS; y++) for (let x = 0; x < COLS; x++) {
    drawTile(tiles[y * COLS + x], MAP_OX + x * TW, MAP_OY + y * TH, x, y);
  }

  // sort drawables
  const drawList = entities.slice().sort((a, b) => a.y - b.y);
  let drawnP = false;
  for (const e of drawList) {
    if (!drawnP && S.player.y < e.y) { drawPlayer(); drawnP = true; }
    drawEntity(e);
  }
  if (!drawnP) drawPlayer();

  drawProjectiles();

  ctx.save();
  ctx.translate(MAP_OX, MAP_OY);
  drawParticles();
  ctx.restore();

  for (const f of S.floaters) {
    ctx.globalAlpha = Math.max(0, f.t / 0.9);
    ctx.fillStyle = f.col;
    ctx.font = "bold 14px Palatino Linotype, serif";
    ctx.textAlign = "center";
    ctx.fillText(f.text, worldX(f.x), worldY(f.y));
    ctx.globalAlpha = 1;
  }

  drawDarkness();

  ctx.restore();

  // vignette
  const vg = ctx.createRadialGradient(480, 340, 180, 480, 340, 560);
  vg.addColorStop(0, "rgba(0,0,0,0)");
  vg.addColorStop(1, "rgba(0,0,0,0.45)");
  ctx.fillStyle = vg;
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  drawHUD();
  drawUIModes();
}

// ---------------------------------------------------------------------------
// Loop / boot
// ---------------------------------------------------------------------------
let last = 0, acc = 0;
function frame(t) {
  const dt = Math.min(0.05, (t - last) / 1000 || 0.016);
  last = t;
  if (S.mode !== "title") update(dt);
  else flushJust();
  if (S.mode !== "title") draw();
  requestAnimationFrame(frame);
}

function startNew() {
  AudioFX.ensure();
  S = freshState();
  spawnRoom();
  overlay.classList.add("hidden");
  S.mode = "play";
  announce("East to the lea. West into trees. The shrine will remember.");
}

function startCont() {
  AudioFX.ensure();
  if (loadGame()) {
    overlay.classList.add("hidden");
    S.mode = "play";
    announce("The shrine returns you.");
  }
}

btnNew.addEventListener("click", startNew);
btnCont.addEventListener("click", startCont);

if (hasSave()) btnCont.classList.remove("hidden");

S.mode = "title";
window.__game = {
  get S() { return S; },
  get entities() { return entities; },
  WEAPONS, ITEMS, SHOP, ROOM_META, TILE,
  roomId, spawnRoom, giveLoot, saveGame, loadGame, hasSave,
  damagePlayer, healPlayer, hitEnemy, killEnemy, buyIndex,
  doSword, shootBow, placeBomb, usePotion, interact, changeRoom,
  weapon, announce
};
requestAnimationFrame(frame);
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"x-ai/grok-4.6","provider":"openrouter","persona":"full","sessionId":"e6955123-c5ed-4ff5-bc0e-68a2101c8255","tokensIn":262202,"tokensOut":5311,"tokensTotal":267513,"cost":0.276526,"turns":7,"toolCalls":7,"failedToolCalls":0,"timestamp":"2026-08-13T16:00:34.334Z"} -->