{"project":{"id":"UGqpTyN","userId":"davidyarham@gmail.com","username":null,"userPicture":null,"name":"KILN — Voxel Platformer","thumbnail":"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","visible":true,"contributors":"","githubRepo":null,"forkedFrom":null,"isTemplate":false,"tags":"","files":{"folder":"","files":[{"name":"index.html","content":"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<title>KILN</title>\n<link rel=\"stylesheet\" href=\"css/main.css\">\n<script type=\"importmap\">\n{\n  \"imports\": {\n    \"three\": \"https://unpkg.com/three@0.160.0/build/three.module.js\"\n  }\n}\n</script>\n</head>\n<body>\n<canvas id=\"game-canvas\" aria-label=\"KILN — a voxel platformer starring Clawd, Anthropic's crab\"></canvas>\n<div id=\"ui-root\"></div>\n<script type=\"module\" src=\"js/main.js\"></script>\n</body>\n</html>\n"},{"name":"README.md","content":"# Blnq Project\n"}],"folders":[{"folder":"css","files":[{"name":"main.css","content":"/* KILN — UI stylesheet. Game palette lives in js/palette.js */\n:root {\n  color-scheme: dark;\n  --c-soot: #1a1210;\n  --c-ash: #8a7f76;\n  --c-terra: #c96f3b;\n  --c-terra-deep: #8e4426;\n  --c-glaze: #3d7dc4;\n  --c-glaze-pale: #a8c8e8;\n  --c-ember: #e8a13a;\n  --c-bone: #e8ded0;\n}\n\n* { margin: 0; padding: 0; box-sizing: border-box; }\n\nhtml, body {\n  width: 100%;\n  height: 100%;\n  overflow: hidden;\n  background: var(--c-soot);\n  font-family: Georgia, 'Times New Roman', serif;\n  color: var(--c-bone);\n}\n\n#game-canvas {\n  position: fixed;\n  inset: 0;\n  width: 100%;\n  height: 100%;\n  display: block;\n}\n\n#ui-root {\n  position: fixed;\n  inset: 0;\n  pointer-events: none;\n  user-select: none;\n}\n\n/* ---------- screens (menus) ---------- */\n.screen {\n  position: absolute;\n  inset: 0;\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  justify-content: center;\n  gap: 18px;\n  pointer-events: auto;\n  background: transparent;\n}\n\n.screen--dim {\n  background: rgba(26, 18, 16, 0.72);\n}\n\n.screen[hidden] { display: none; }\n\n.kiln-title {\n  font-size: clamp(3.5rem, 10vw, 7rem);\n  letter-spacing: 0.35em;\n  margin-left: 0.35em; /* optically re-centre for tracking */\n  font-weight: normal;\n  color: var(--c-bone);\n  text-shadow: 0 2px 0 var(--c-terra-deep), 0 0 42px rgba(232, 161, 58, 0.35);\n}\n\n.kiln-subtitle {\n  font-size: 0.95rem;\n  letter-spacing: 0.42em;\n  margin-left: 0.42em;\n  text-transform: uppercase;\n  color: var(--c-ash);\n}\n\n.menu-list {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  gap: 6px;\n  margin-top: 14px;\n}\n\n.menu-btn {\n  appearance: none;\n  background: none;\n  border: none;\n  font-family: inherit;\n  font-size: 1.25rem;\n  letter-spacing: 0.18em;\n  padding: 8px 26px;\n  color: var(--c-bone);\n  cursor: pointer;\n  position: relative;\n  transition: color 120ms ease;\n}\n\n.menu-btn::before,\n.menu-btn::after {\n  content: \"\";\n  position: absolute;\n  top: 50%;\n  width: 8px;\n  height: 8px;\n  background: var(--c-ember);\n  transform: translateY(-50%) scale(0);\n  transition: transform 120ms ease;\n}\n.menu-btn::before { left: 4px; }\n.menu-btn::after { right: 4px; }\n\n.menu-btn:hover, .menu-btn:focus-visible, .menu-btn.is-focused {\n  color: var(--c-ember);\n  outline: none;\n}\n.menu-btn:hover::before, .menu-btn:focus-visible::before, .menu-btn.is-focused::before,\n.menu-btn:hover::after, .menu-btn:focus-visible::after, .menu-btn.is-focused::after {\n  transform: translateY(-50%) scale(1);\n}\n\n.menu-btn:disabled {\n  color: #574d45;\n  cursor: default;\n}\n.menu-btn:disabled::before, .menu-btn:disabled::after { display: none; }\n\n.screen-heading {\n  font-size: 2rem;\n  letter-spacing: 0.3em;\n  margin-left: 0.3em;\n  text-transform: uppercase;\n  color: var(--c-bone);\n}\n\n/* ---------- level select ---------- */\n.level-grid {\n  display: grid;\n  grid-template-columns: repeat(4, minmax(150px, 190px));\n  gap: 14px;\n  max-width: 860px;\n}\n@media (max-width: 800px) {\n  .level-grid { grid-template-columns: repeat(2, minmax(150px, 1fr)); }\n}\n\n.level-card {\n  appearance: none;\n  font-family: inherit;\n  background: rgba(232, 222, 208, 0.05);\n  border: 1px solid rgba(138, 127, 118, 0.45);\n  color: var(--c-bone);\n  padding: 14px 12px 12px;\n  cursor: pointer;\n  text-align: left;\n  display: flex;\n  flex-direction: column;\n  gap: 7px;\n  transition: border-color 120ms ease, background 120ms ease;\n}\n.level-card:hover:not(:disabled), .level-card:focus-visible, .level-card.is-focused {\n  border-color: var(--c-ember);\n  background: rgba(232, 161, 58, 0.08);\n  outline: none;\n}\n.level-card:disabled {\n  opacity: 0.38;\n  cursor: default;\n}\n\n.level-card__num {\n  font-size: 0.7rem;\n  letter-spacing: 0.28em;\n  color: var(--c-ash);\n  text-transform: uppercase;\n}\n.level-card__name {\n  font-size: 1.02rem;\n  letter-spacing: 0.05em;\n}\n.level-card__meta {\n  font-size: 0.78rem;\n  color: var(--c-ash);\n  display: flex;\n  justify-content: space-between;\n}\n.level-card__meta .done { color: var(--c-ember); }\n.level-card__meta .drops-full { color: var(--c-glaze-pale); }\n\n/* ---------- settings ---------- */\n.settings-list {\n  display: flex;\n  flex-direction: column;\n  gap: 16px;\n  min-width: 320px;\n}\n.setting-row {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 24px;\n  font-size: 1.05rem;\n  letter-spacing: 0.08em;\n}\n.setting-row label { color: var(--c-bone); }\n\n.setting-row input[type=\"range\"] {\n  width: 150px;\n  accent-color: var(--c-terra);\n  cursor: pointer;\n}\n.toggle-btn {\n  appearance: none;\n  font-family: inherit;\n  background: none;\n  border: 1px solid var(--c-ash);\n  color: var(--c-bone);\n  font-size: 0.9rem;\n  letter-spacing: 0.15em;\n  padding: 5px 16px;\n  cursor: pointer;\n  min-width: 74px;\n}\n.toggle-btn.on {\n  border-color: var(--c-ember);\n  color: var(--c-ember);\n}\n\n/* ---------- save code ---------- */\n.save-box {\n  display: flex;\n  flex-direction: column;\n  gap: 10px;\n  align-items: center;\n  max-width: 560px;\n}\n.save-code-area {\n  font-family: 'Courier New', monospace;\n  font-size: 0.85rem;\n  background: rgba(0,0,0,0.4);\n  color: var(--c-glaze-pale);\n  border: 1px solid rgba(138,127,118,0.45);\n  padding: 10px;\n  width: 460px;\n  max-width: 82vw;\n  height: 74px;\n  resize: none;\n}\n.save-hint { font-size: 0.8rem; color: var(--c-ash); letter-spacing: 0.06em; }\n.save-feedback { font-size: 0.85rem; color: var(--c-ember); min-height: 1.2em; letter-spacing: 0.06em; }\n\n/* ---------- HUD ---------- */\n#hud {\n  position: absolute;\n  inset: 0;\n  pointer-events: none;\n}\n#hud[hidden] { display: none; }\n\n.hud-pot {\n  position: absolute;\n  top: 20px;\n  right: 24px;\n  display: flex;\n  align-items: center;\n  gap: 10px;\n}\n.hud-pot canvas { display: block; }\n.hud-pot__count {\n  font-size: 1.05rem;\n  letter-spacing: 0.12em;\n  color: var(--c-glaze-pale);\n  text-shadow: 0 1px 3px rgba(0,0,0,0.7);\n}\n\n.hud-level-name {\n  position: absolute;\n  top: 22px;\n  left: 26px;\n  font-size: 0.85rem;\n  letter-spacing: 0.3em;\n  text-transform: uppercase;\n  color: var(--c-ash);\n  text-shadow: 0 1px 3px rgba(0,0,0,0.8);\n}\n\n.hud-glyph {\n  position: absolute;\n  bottom: 18px;\n  left: 26px;\n  font-size: 0.78rem;\n  letter-spacing: 0.14em;\n  color: rgba(138, 127, 118, 0.85);\n  text-shadow: 0 1px 3px rgba(0,0,0,0.8);\n}\n\n/* boss health bar — a cracked amphora silhouette filling */\n.boss-bar {\n  position: absolute;\n  top: 26px;\n  left: 50%;\n  transform: translateX(-50%);\n  width: 320px;\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  gap: 5px;\n}\n.boss-bar[hidden] { display: none; }\n.boss-bar__label {\n  font-size: 0.72rem;\n  letter-spacing: 0.4em;\n  margin-left: 0.4em;\n  text-transform: uppercase;\n  color: var(--c-terra);\n  text-shadow: 0 1px 3px rgba(0,0,0,0.8);\n}\n.boss-bar__track {\n  width: 100%;\n  height: 8px;\n  background: rgba(0,0,0,0.5);\n  border: 1px solid var(--c-terra-deep);\n}\n.boss-bar__fill {\n  height: 100%;\n  background: linear-gradient(90deg, var(--c-terra-deep), var(--c-terra));\n  width: 100%;\n  transition: width 180ms ease, background 400ms ease;\n}\n/* the bar heats up as the Kilnheart cracks open */\n.boss-bar__fill.p2 {\n  background: linear-gradient(90deg, var(--c-terra), var(--c-ember));\n}\n.boss-bar__fill.p3 {\n  background: linear-gradient(90deg, var(--c-ember), var(--c-bone));\n  animation: boss-p3-flicker 340ms steps(2) infinite;\n}\n@keyframes boss-p3-flicker {\n  50% { filter: brightness(1.25); }\n}\n\n/* toast messages (level complete, drop found...) */\n.toast {\n  position: absolute;\n  top: 18%;\n  left: 50%;\n  transform: translateX(-50%);\n  font-size: 1.15rem;\n  letter-spacing: 0.28em;\n  margin-left: 0.28em;\n  text-transform: uppercase;\n  color: var(--c-bone);\n  text-shadow: 0 2px 8px rgba(0,0,0,0.9);\n  opacity: 0;\n  transition: opacity 400ms ease;\n  text-align: center;\n  white-space: pre-line;\n}\n.toast.show { opacity: 1; }\n\n/* fade overlay for transitions */\n#fader {\n  position: absolute;\n  inset: 0;\n  background: var(--c-soot);\n  opacity: 0;\n  pointer-events: none;\n  transition: opacity 500ms ease;\n}\n#fader.dark { opacity: 1; }\n\n/* credits / ending */\n.credits-roll {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  gap: 10px;\n  text-align: center;\n  max-width: 500px;\n}\n.credits-roll h3 {\n  font-size: 0.8rem;\n  letter-spacing: 0.35em;\n  text-transform: uppercase;\n  color: var(--c-terra);\n  margin-top: 16px;\n  font-weight: normal;\n}\n.credits-roll p { font-size: 1rem; color: var(--c-bone); letter-spacing: 0.06em; }\n.credits-roll .small { font-size: 0.8rem; color: var(--c-ash); }\n"},{"name":"styles.css","content":"* {\n\tbox-sizing: border-box;\n}\n\n:root {\n\tcolor-scheme: light dark;\n\tfont-family: system-ui, sans-serif;\n}\n"}],"folders":[]},{"folder":"js","files":[{"name":"audio.js","content":"// KILN — all audio synthesised with the Web Audio API. No files.\n// Warm pentatonic ambient pad, ceramic tinks (filtered noise bursts\n// pitched ±3 semitones), a deep kiln rumble under the boss fight.\n// Music ducks while paused. Starts on first user input (autoplay policy).\n\nconst PENTA = [220.0, 261.63, 293.66, 329.63, 392.0, 440.0, 523.25]; // A min pentatonic\n\nexport function createAudio(settings) {\n  let ctx = null;\n  let master = null;\n  let musicGain = null;\n  let sfxGain = null;\n  let noiseBuf = null;\n  let padTimer = null;\n  let rumbleNodes = null;\n  let started = false;\n\n  function ensure() {\n    if (ctx) return true;\n    const AC = window.AudioContext || window.webkitAudioContext;\n    if (!AC) return false;\n    ctx = new AC();\n    master = ctx.createGain();\n    master.gain.value = settings.volume;\n    master.connect(ctx.destination);\n    musicGain = ctx.createGain();\n    musicGain.gain.value = 0.5;\n    musicGain.connect(master);\n    sfxGain = ctx.createGain();\n    sfxGain.gain.value = 0.9;\n    sfxGain.connect(master);\n    // shared noise buffer\n    noiseBuf = ctx.createBuffer(1, ctx.sampleRate * 1.2, ctx.sampleRate);\n    const d = noiseBuf.getChannelData(0);\n    for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;\n    return true;\n  }\n\n  // ---- ceramic tink: filtered noise burst + short sine ping ----\n  function tinkAt(baseHz, gain, dur, brightness) {\n    if (!ctx) return;\n    const t = ctx.currentTime;\n    const semis = (Math.random() * 6 - 3); // ±3 semitones\n    const rate = Math.pow(2, semis / 12);\n\n    const n = ctx.createBufferSource();\n    n.buffer = noiseBuf;\n    n.playbackRate.value = rate;\n    const bp = ctx.createBiquadFilter();\n    bp.type = 'bandpass';\n    bp.frequency.value = brightness * rate;\n    bp.Q.value = 14;\n    const g = ctx.createGain();\n    g.gain.setValueAtTime(gain, t);\n    g.gain.exponentialRampToValueAtTime(0.001, t + dur);\n    n.connect(bp); bp.connect(g); g.connect(sfxGain);\n    n.start(t); n.stop(t + dur + 0.05);\n\n    const o = ctx.createOscillator();\n    o.type = 'sine';\n    o.frequency.value = baseHz * rate;\n    const og = ctx.createGain();\n    og.gain.setValueAtTime(gain * 0.6, t);\n    og.gain.exponentialRampToValueAtTime(0.001, t + dur * 0.7);\n    o.connect(og); og.connect(sfxGain);\n    o.start(t); o.stop(t + dur);\n  }\n\n  function thud(gain, freq, dur) {\n    if (!ctx) return;\n    const t = ctx.currentTime;\n    const n = ctx.createBufferSource();\n    n.buffer = noiseBuf;\n    const lp = ctx.createBiquadFilter();\n    lp.type = 'lowpass';\n    lp.frequency.setValueAtTime(freq, t);\n    lp.frequency.exponentialRampToValueAtTime(60, t + dur);\n    const g = ctx.createGain();\n    g.gain.setValueAtTime(gain, t);\n    g.gain.exponentialRampToValueAtTime(0.001, t + dur);\n    n.connect(lp); lp.connect(g); g.connect(sfxGain);\n    n.start(t); n.stop(t + dur + 0.05);\n  }\n\n  function chimeSeq(freqs, gap, gain, dur) {\n    if (!ctx) return;\n    freqs.forEach((f, i) => {\n      const t = ctx.currentTime + i * gap;\n      const o = ctx.createOscillator();\n      o.type = 'triangle';\n      o.frequency.value = f;\n      const g = ctx.createGain();\n      g.gain.setValueAtTime(0.0001, t);\n      g.gain.exponentialRampToValueAtTime(gain, t + 0.02);\n      g.gain.exponentialRampToValueAtTime(0.001, t + dur);\n      o.connect(g); g.connect(sfxGain);\n      o.start(t); o.stop(t + dur + 0.05);\n    });\n  }\n\n  // ---- ambient pad: soft overlapping pentatonic swells ----\n  function padNote() {\n    if (!ctx) return;\n    const t = ctx.currentTime;\n    const f = PENTA[Math.floor(Math.random() * PENTA.length)] / 2;\n    const o1 = ctx.createOscillator();\n    const o2 = ctx.createOscillator();\n    o1.type = 'sine'; o2.type = 'triangle';\n    o1.frequency.value = f;\n    o2.frequency.value = f * 1.005; // slow beat between the pair\n    const g = ctx.createGain();\n    const dur = 6 + Math.random() * 4;\n    g.gain.setValueAtTime(0.0001, t);\n    g.gain.exponentialRampToValueAtTime(0.05 + Math.random() * 0.03, t + dur * 0.4);\n    g.gain.exponentialRampToValueAtTime(0.0001, t + dur);\n    const lp = ctx.createBiquadFilter();\n    lp.type = 'lowpass'; lp.frequency.value = 900;\n    o1.connect(g); o2.connect(g); g.connect(lp); lp.connect(musicGain);\n    o1.start(t); o2.start(t);\n    o1.stop(t + dur + 0.1); o2.stop(t + dur + 0.1);\n  }\n\n  const audio = {\n    // call on first user gesture — resumes context, starts the pad\n    start() {\n      if (!ensure()) return;\n      if (ctx.state === 'suspended') ctx.resume();\n      if (started) return;\n      started = true;\n      padNote();\n      padTimer = setInterval(() => {\n        if (ctx.state === 'running') { padNote(); if (Math.random() < 0.4) padNote(); }\n      }, 2600);\n    },\n\n    setVolume(v) { if (master) master.gain.value = v; },\n\n    duck(on) {\n      if (!musicGain) return;\n      musicGain.gain.cancelScheduledValues(ctx.currentTime);\n      musicGain.gain.linearRampToValueAtTime(on ? 0.14 : 0.5, ctx.currentTime + 0.35);\n    },\n\n    jump() { tinkAt(880, 0.16, 0.12, 2400); },\n    land(impact) { thud(Math.min(0.5, 0.1 + impact * 0.02), 320 + impact * 12, 0.16); },\n    collect() { tinkAt(1760, 0.3, 0.5, 3600); },\n    crack() { thud(0.5, 900, 0.25); tinkAt(440, 0.28, 0.3, 1400); },\n    shatter() {\n      thud(0.7, 1600, 0.5);\n      for (let i = 0; i < 5; i++) setTimeout(() => tinkAt(660, 0.2, 0.35, 2000), i * 55);\n    },\n    reassemble() { chimeSeq([392, 523.25, 659.25], 0.07, 0.14, 0.5); },\n    checkpoint() { chimeSeq([523.25, 659.25, 783.99], 0.09, 0.18, 0.8); },\n    goal() { chimeSeq([440, 523.25, 659.25, 880], 0.12, 0.2, 1.0); },\n    stomp() { thud(0.45, 700, 0.18); tinkAt(330, 0.25, 0.2, 1200); },\n    gust() { if (!ctx) return; thud(0.12, 500, 1.2); },\n    lob() { thud(0.3, 260, 0.3); },\n    splash() { thud(0.35, 1200, 0.3); tinkAt(220, 0.15, 0.25, 900); },\n    ventOpen() { chimeSeq([220, 277.18], 0.1, 0.2, 0.6); },\n    bossHit() { thud(0.7, 500, 0.5); tinkAt(180, 0.4, 0.6, 800); },\n    bossDie() {\n      thud(0.9, 2000, 1.4);\n      for (let i = 0; i < 10; i++) setTimeout(() => tinkAt(500 + i * 60, 0.22, 0.5, 1800), i * 90);\n    },\n    uiMove() { tinkAt(1320, 0.08, 0.08, 3000); },\n    uiSelect() { tinkAt(990, 0.14, 0.2, 2600); },\n\n    bossStart() {\n      audio.rumbleOn();\n      chimeSeq([110, 138.59, 110], 0.25, 0.25, 1.2);\n    },\n\n    // deep kiln rumble: looping filtered noise + sub oscillator\n    rumbleOn() {\n      if (!ctx || rumbleNodes) return;\n      const n = ctx.createBufferSource();\n      n.buffer = noiseBuf; n.loop = true; n.playbackRate.value = 0.35;\n      const lp = ctx.createBiquadFilter();\n      lp.type = 'lowpass'; lp.frequency.value = 90;\n      const sub = ctx.createOscillator();\n      sub.type = 'sine'; sub.frequency.value = 38;\n      const g = ctx.createGain();\n      g.gain.setValueAtTime(0.0001, ctx.currentTime);\n      g.gain.linearRampToValueAtTime(0.3, ctx.currentTime + 2);\n      n.connect(lp); lp.connect(g); sub.connect(g); g.connect(musicGain);\n      n.start(); sub.start();\n      rumbleNodes = { n, sub, g };\n    },\n    rumbleOff() {\n      if (!rumbleNodes) return;\n      const { n, sub, g } = rumbleNodes;\n      g.gain.linearRampToValueAtTime(0.0001, ctx.currentTime + 1.2);\n      setTimeout(() => { try { n.stop(); sub.stop(); } catch (e) { /* already stopped */ } }, 1400);\n      rumbleNodes = null;\n    },\n  };\n\n  return audio;\n}\n"},{"name":"camera.js","content":"// KILN — smooth-follow third-person camera with look-ahead in the\n// movement direction and subtle screen shake on heavy landings\n// (respecting the settings toggle).\n\nimport * as THREE from 'three';\n\nexport function createGameCamera(camera, settings) {\n  const cur = new THREE.Vector3(0, 8, 12);\n  const curTarget = new THREE.Vector3();\n  const lookAhead = new THREE.Vector3();\n  let shake = 0;\n  let seed = 0;\n\n  const cam = {\n    // instantly snap to a position (level start, respawn)\n    snap(px, py, pz) {\n      curTarget.set(px, py + 1, pz);\n      cur.set(px, py + 7.2, pz + 10.5);\n      lookAhead.set(0, 0, 0);\n    },\n\n    addShake(amount) {\n      if (!settings.screenShake) return;\n      shake = Math.min(1.2, shake + amount);\n    },\n\n    update(dt, player, boss) {\n      const b = player.body;\n\n      // look-ahead: lean toward where the golem is going\n      const targetLA = new THREE.Vector3(\n        THREE.MathUtils.clamp(b.vel.x * 0.32, -2.4, 2.4),\n        0,\n        THREE.MathUtils.clamp(b.vel.z * 0.22, -1.6, 1.6)\n      );\n      lookAhead.lerp(targetLA, 1 - Math.exp(-3 * dt));\n\n      // during the boss fight, pull back and frame both combatants\n      let dist = 10.5, height = 7.2;\n      if (boss && boss.active && !boss.dead) { dist = 14; height = 9.5; }\n\n      const want = new THREE.Vector3(\n        b.pos.x + lookAhead.x,\n        b.pos.y + height,\n        b.pos.z + dist\n      );\n      cur.lerp(want, 1 - Math.exp(-4.2 * dt));\n\n      const wantTarget = new THREE.Vector3(\n        b.pos.x + lookAhead.x,\n        b.pos.y + 1.2,\n        b.pos.z + lookAhead.z\n      );\n      curTarget.lerp(wantTarget, 1 - Math.exp(-6 * dt));\n\n      // shake: fast decaying pseudo-random offsets\n      let sx = 0, sy = 0;\n      if (shake > 0.001) {\n        seed += dt * 60;\n        sx = Math.sin(seed * 1.3) * Math.cos(seed * 3.7) * shake * 0.28;\n        sy = Math.cos(seed * 1.7) * Math.sin(seed * 2.9) * shake * 0.22;\n        shake *= Math.exp(-6 * dt);\n      }\n\n      camera.position.set(cur.x + sx, cur.y + sy, cur.z);\n      camera.lookAt(curTarget.x + sx, curTarget.y + sy, curTarget.z);\n    },\n\n    // slow orbit around a point — used by the title screen diorama\n    orbit(t, cx, cy, cz, radius) {\n      const a = t * 0.16;\n      camera.position.set(cx + Math.cos(a) * radius, cy + 7 + Math.sin(t * 0.11) * 1.5, cz + Math.sin(a) * radius);\n      camera.lookAt(cx, cy + 1.5, cz);\n    },\n  };\n\n  return cam;\n}\n"},{"name":"enemies.js","content":"// KILN — pot-shard crabs and the Kilnheart boss (a giant cracked amphora).\n// Crabs patrol and are stompable. The boss has three distinguishable phases,\n// lobbing molten glaze and exposing an ember vent at its base to stomp.\n\nimport * as THREE from 'three';\nimport { MAT } from './palette.js';\nimport { buildModelMesh, makeDebrisVoxel } from './voxels.js';\n\nconst CRAB_VOX = 0.12;\n\n// ---------- crab model: a skittering shard of broken pot ----------\nfunction crabVoxels() {\n  const v = [];\n  // curved shard shell (terra, glaze stripe on top)\n  for (let x = 0; x <= 4; x++)\n    for (let z = 0; z <= 3; z++) {\n      v.push([x, 1, z, MAT.TERRA]);\n      if (x >= 1 && x <= 3 && z >= 1 && z <= 2) v.push([x, 2, z, MAT.TERRA_DEEP]);\n    }\n  v.push([2, 3, 1, MAT.GLAZE]); v.push([2, 3, 2, MAT.GLAZE]); // glaze stripe\n  // ember eyes at the front\n  v.push([0, 2, 1, MAT.EMBER]); v.push([0, 2, 2, MAT.EMBER]);\n  return v;\n}\n\nexport function createCrab(scene, def, deps) {\n  // rigged: shell + four separate legs that piston as it skitters\n  const root = new THREE.Group();\n  const shell = buildModelMesh(crabVoxels(), { voxelSize: CRAB_VOX });\n  shell.position.y = CRAB_VOX * 0.9; // rides on the legs\n  root.add(shell);\n  const legs = [];\n  for (const [lx, lz] of [[-0.22, -0.17], [-0.22, 0.17], [0.22, -0.17], [0.22, 0.17]]) {\n    const leg = makeDebrisVoxel(MAT.SOOT, CRAB_VOX);\n    leg.material.transparent = false;\n    leg.position.set(lx, CRAB_VOX * 0.5, lz);\n    leg.userData.baseX = lx;\n    root.add(leg);\n    legs.push(leg);\n  }\n  scene.add(root);\n  const c = {\n    x: def.a[0], y: def.y, z: def.a[1],\n    dir: 1, t: 0, dead: false, mesh: root,\n    halfW: 0.34, height: 0.5,\n  };\n  const ax = def.a[0], az = def.a[1], bx = def.b[0], bz = def.b[1];\n  const len = Math.hypot(bx - ax, bz - az) || 1;\n  const ux = (bx - ax) / len, uz = (bz - az) / len;\n  let s = 0; // distance along the patrol line\n\n  c.update = (dt, player) => {\n    if (c.dead) return;\n    c.t += dt;\n    s += def.speed * c.dir * dt;\n    if (s > len) { s = len; c.dir = -1; }\n    if (s < 0) { s = 0; c.dir = 1; }\n    c.x = ax + ux * s;\n    c.z = az + uz * s;\n    root.position.set(c.x, c.y, c.z);\n    root.rotation.y = Math.atan2(ux * c.dir, uz * c.dir) + Math.PI / 2;\n\n    // skitter: legs piston alternately, shell jitters and bobs\n    const sk = c.t * (10 + def.speed * 4);\n    legs.forEach((leg, i) => {\n      leg.position.y = CRAB_VOX * 0.5 + Math.max(0, Math.sin(sk + i * 2.1)) * 0.08;\n      leg.position.x = leg.userData.baseX + Math.sin(sk + i * 2.1) * 0.03;\n    });\n    shell.position.y = CRAB_VOX * 0.9 + Math.abs(Math.sin(sk * 0.5)) * 0.045;\n    shell.rotation.z = Math.sin(sk * 0.5) * 0.08;\n    shell.rotation.x = Math.sin(sk * 0.63) * 0.05;\n\n    // player contact\n    if (player.state !== 'alive') return;\n    const b = player.body;\n    const dx = Math.abs(b.pos.x - c.x), dz = Math.abs(b.pos.z - c.z);\n    if (dx < c.halfW + b.halfW && dz < c.halfW + b.halfW) {\n      const pFeet = b.pos.y, cTop = c.y + c.height;\n      if (pFeet > cTop - 0.32 && b.vel.y < -1) {\n        // STOMP\n        c.die();\n        b.vel.y = 9.5;\n        deps.audio.stomp();\n        deps.addShake(0.25);\n      } else if (pFeet < cTop && b.pos.y + b.height > c.y) {\n        if (player.hit()) deps.onPlayerShatter();\n        else {\n          // knockback away from the crab\n          const kx = b.pos.x - c.x, kz = b.pos.z - c.z;\n          const kl = Math.hypot(kx, kz) || 1;\n          b.vel.x = (kx / kl) * 9; b.vel.z = (kz / kl) * 9; b.vel.y = 6;\n        }\n      }\n    }\n  };\n\n  c.die = () => {\n    c.dead = true;\n    root.visible = false;\n    for (let i = 0; i < 10; i++) {\n      const d = makeDebrisVoxel(i % 3 === 0 ? MAT.GLAZE : MAT.TERRA, CRAB_VOX);\n      d.position.set(c.x, c.y + 0.3, c.z);\n      d.userData = {\n        vel: new THREE.Vector3((Math.random() - 0.5) * 5, Math.random() * 5 + 2, (Math.random() - 0.5) * 5),\n        life: 0.9,\n      };\n      scene.add(d);\n      deps.debris.push(d);\n    }\n  };\n\n  c.dispose = () => { scene.remove(root); };\n  return c;\n}\n\n// ---------- the Kilnheart: a colossal cracked amphora ----------\n// Built from a lathe profile — radius per voxel row, 14 rows tall.\nconst BOSS_VOX = 0.34;\nconst PROFILE = [3.2, 4.0, 4.6, 4.9, 5.0, 4.9, 4.6, 4.2, 3.6, 3.0, 2.5, 2.3, 2.8, 3.4];\n\nfunction bossVoxels(crackLevel) {\n  const v = [];\n  for (let y = 0; y < PROFILE.length; y++) {\n    const r = PROFILE[y];\n    for (let x = -5; x <= 5; x++)\n      for (let z = -5; z <= 5; z++) {\n        const d = Math.hypot(x, z);\n        if (d <= r && d >= r - 1.6) {\n          let m = MAT.TERRA;\n          if (y >= 12) m = MAT.TERRA_DEEP;              // the lip\n          if (y === 4 && Math.abs(x) < 2 && z < -3) m = MAT.GLAZE; // old glaze scar\n          v.push([x + 5, y, z + 5, m]);\n        }\n      }\n  }\n  // handles\n  for (let y = 8; y <= 11; y++) { v.push([0, y, 5, MAT.TERRA_DEEP]); v.push([10, y, 5, MAT.TERRA_DEEP]); }\n  // cracks per phase: ember light bleeding through the shell\n  const CRACKS = [\n    [[2, 3, 1], [2, 4, 1], [3, 5, 1], [3, 6, 2], [8, 2, 9], [8, 3, 9]],\n    [[7, 7, 1], [7, 8, 1], [6, 9, 2], [1, 6, 8], [1, 5, 8], [2, 4, 9], [5, 10, 1], [5, 11, 2]],\n  ];\n  for (let c = 0; c < crackLevel && c < CRACKS.length; c++)\n    for (const [cx, cy, cz] of CRACKS[c]) {\n      const i = v.findIndex((e) => e[0] === cx && e[1] === cy && e[2] === cz);\n      if (i >= 0) v[i][3] = MAT.EMBER;\n    }\n  return v;\n}\n\nexport function createBoss(scene, def, deps) {\n  const [bx, by, bz] = def.pos;\n  const meshes = [0, 1, 2].map((lvl) => {\n    const g = buildModelMesh(bossVoxels(lvl), { voxelSize: BOSS_VOX });\n    g.position.set(bx, by, bz);\n    g.visible = lvl === 0;\n    scene.add(g);\n    return g;\n  });\n\n  // the vent: an ember weak point that opens at the amphora's base\n  const vent = buildModelMesh(\n    [[0, 0, 0, MAT.EMBER], [1, 0, 0, MAT.EMBER], [0, 1, 0, MAT.EMBER], [1, 1, 0, MAT.EMBER],\n     [0, 0, 1, MAT.EMBER], [1, 0, 1, MAT.EMBER]],\n    { voxelSize: 0.3 }\n  );\n  vent.position.set(bx - 2.4, by + 0.7, bz);\n  vent.visible = false;\n  scene.add(vent);\n\n  // the kilnheart's ember glow — pulses harder every phase, flares on death\n  const heartLight = new THREE.PointLight(0xe8a13a, 0, 22);\n  heartLight.position.set(bx, by + 2.6, bz);\n  scene.add(heartLight);\n\n  const projectiles = [];\n  const splashes = [];\n  const rings = []; // molten shockwaves that sweep the arena floor\n  const ringGeo = new THREE.RingGeometry(0.94, 1.06, 48);\n\n  function spawnRing(delay, harmless) {\n    rings.push({ r: 1.4, delay: delay || 0, harmless: !!harmless, mesh: null });\n  }\n\n  const boss = {\n    hp: 6, maxHp: 6,\n    dead: false,\n    active: false,\n    state: 'rising',    // rising | lobbing | vulnerable | staggered\n    timer: 0,\n    lobTimer: 1.5,\n    lobsDone: 0,\n    t: 0,\n    slump: 0,     // eased offset while the vent is open\n    recoil: 0,    // kick after each lob\n    riseScale: 1, // intro: the amphora heaves itself awake\n    deadT: 0,\n  };\n\n  const phase = () => (boss.hp > 4 ? 1 : boss.hp > 2 ? 2 : 3);\n  boss.phase = phase;\n\n  const PHASE_CFG = {\n    1: { lobEvery: 2.2, lobsPerCycle: 3, spread: 1, vuln: 4.2 },\n    2: { lobEvery: 1.5, lobsPerCycle: 4, spread: 2, vuln: 3.6 },\n    3: { lobEvery: 1.1, lobsPerCycle: 5, spread: 3, vuln: 3.0 },\n  };\n\n  function setCrackMesh() {\n    const lvl = phase() - 1;\n    meshes.forEach((m, i) => { m.visible = i === lvl; });\n  }\n\n  function lobAt(tx, tz) {\n    const geo = makeDebrisVoxel(MAT.EMBER, 0.34);\n    geo.material.transparent = false;\n    const sy = by + 4.6;\n    geo.position.set(bx, sy, bz);\n    const T = 1.35; // flight time\n    const g = -22;\n    // ballistic solve: vy such that the lob lands on the arena floor after T\n    geo.userData = {\n      vel: new THREE.Vector3((tx - bx) / T, (deps.groundY - sy - 0.5 * g * T * T) / T, (tz - bz) / T),\n      g,\n      proj: true,\n    };\n    scene.add(geo);\n    projectiles.push(geo);\n    deps.audio.lob();\n  }\n\n  function splashAt(x, z) {\n    const s = new THREE.Mesh(\n      new THREE.BoxGeometry(1.6, 0.18, 1.6),\n      new THREE.MeshLambertMaterial({ color: 0xe8a13a, emissive: 0xe8a13a, emissiveIntensity: 0.9, transparent: true })\n    );\n    s.position.set(x, deps.groundY + 0.09, z);\n    s.userData.life = 2.0;\n    scene.add(s);\n    splashes.push(s);\n    deps.audio.splash();\n  }\n\n  boss.update = (dt, player) => {\n    boss.t += dt;\n    const cfg = PHASE_CFG[phase()];\n    const b = player.body;\n    const ph = phase();\n\n    if (!boss.dead) {\n      // body language: breathing, rocking, a slump when vulnerable,\n      // a recoil kick on every lob — plus the phase-3 tremble\n      const m = meshes[ph - 1];\n      const slumpTarget = boss.state === 'vulnerable' ? -0.4 : 0;\n      boss.slump += (slumpTarget - boss.slump) * Math.min(1, dt * 5);\n      boss.recoil = Math.max(0, boss.recoil - dt * 1.3);\n      m.position.y = by + boss.slump + Math.sin(boss.t * 1.2) * 0.08;\n      m.scale.y = (1 + Math.sin(boss.t * 2.6) * 0.02 + boss.slump * 0.14) * boss.riseScale;\n      m.scale.x = (1 - boss.slump * 0.08) * boss.riseScale;\n      m.scale.z = (1 - boss.slump * 0.08) * boss.riseScale;\n      m.rotation.z = Math.sin(boss.t * 0.9) * 0.03;\n      m.rotation.x = -boss.recoil * 0.55;\n      if (ph === 3) m.position.x = bx + Math.sin(boss.t * 31) * 0.03;\n      else m.position.x = bx;\n\n      // the ember heart: brighter and angrier every phase\n      if (boss.active) {\n        const base = 0.7 + ph * 0.4 + Math.sin(boss.t * (1.5 + ph)) * 0.2;\n        heartLight.intensity += ((boss.state === 'vulnerable' ? base + 1.2 : base) - heartLight.intensity) * Math.min(1, dt * 4);\n      }\n\n      // activate when the player steps into the arena\n      if (!boss.active) {\n        const a = def.arena;\n        if (b.pos.x > a[0] + 1 && b.pos.x < a[0] + a[3] && b.pos.y > a[1] - 1 && b.pos.y < a[1] + a[4]) {\n          boss.active = true;\n          boss.state = 'rising';\n          boss.timer = 1.8;\n          boss.riseScale = 0.72;\n          deps.audio.bossStart();\n          deps.onBossStart();\n        }\n        return;\n      }\n\n      if (boss.state === 'rising') {\n        // the amphora heaves itself awake — swells up, trembling\n        boss.timer -= dt;\n        boss.riseScale += (1 - boss.riseScale) * Math.min(1, dt * 2.2);\n        m.position.x = bx + Math.sin(boss.t * 26) * 0.05;\n        deps.addShake(0.06);\n        if (boss.timer <= 0) {\n          boss.riseScale = 1;\n          boss.state = 'lobbing';\n          boss.lobTimer = 1.2;\n          spawnRing(0); // announces itself with a shockwave\n          deps.audio.ventOpen();\n        }\n      } else if (player.state === 'alive') {\n        if (boss.state === 'lobbing') {\n          boss.lobTimer -= dt;\n          if (boss.lobTimer <= 0) {\n            boss.lobTimer = cfg.lobEvery;\n            boss.lobsDone++;\n            // aim at the player, with phase-based spread shots\n            lobAt(b.pos.x, b.pos.z);\n            if (cfg.spread >= 2) lobAt(b.pos.x + b.vel.x * 0.7, b.pos.z + b.vel.z * 0.7);\n            if (cfg.spread >= 3) lobAt(b.pos.x + (Math.random() - 0.5) * 4, b.pos.z + (Math.random() - 0.5) * 4);\n            boss.recoil = 0.28; // the whole amphora kicks back\n            if (boss.lobsDone >= cfg.lobsPerCycle) {\n              boss.lobsDone = 0;\n              boss.state = 'vulnerable';\n              boss.timer = cfg.vuln;\n              vent.visible = true;\n              deps.audio.ventOpen();\n              deps.addShake(0.35);\n              // the slam: molten shockwaves sweep the floor (phase 2+)\n              if (ph >= 2) spawnRing(0);\n              if (ph >= 3) { spawnRing(0.7); deps.spawnBossCrab(); }\n            }\n          }\n        } else if (boss.state === 'vulnerable') {\n          boss.timer -= dt;\n          vent.scale.setScalar(1 + Math.sin(boss.t * 9) * 0.12);\n          // stomp check on the vent\n          const vx = bx - 2.4, vy = by + 1.0, vz = bz;\n          const dx = Math.abs(b.pos.x - vx), dz = Math.abs(b.pos.z - vz);\n          if (dx < 1.0 && dz < 1.0 && b.pos.y > vy - 0.6 && b.pos.y < vy + 1.2 && b.vel.y < -1) {\n            boss.hp--;\n            b.vel.y = 10;\n            vent.visible = false;\n            boss.state = boss.hp <= 0 ? 'lobbing' : 'staggered';\n            boss.timer = 1.6;\n            deps.audio.bossHit();\n            deps.addShake(0.7);\n            setCrackMesh();\n            deps.onBossDamaged(boss.hp, phase());\n            if (boss.hp <= 0) {\n              boss.dead = true;\n              boss.deadT = 0;\n              for (const mm of meshes) mm.visible = false;\n              vent.visible = false;\n              heartLight.intensity = 4.5; // the heart flares as it bursts\n              // shatter into a fountain of voxels\n              for (let i = 0; i < 140; i++) {\n                const d = makeDebrisVoxel(i % 4 === 0 ? MAT.EMBER : i % 3 === 0 ? MAT.TERRA_DEEP : MAT.TERRA, 0.34);\n                d.position.set(bx + (Math.random() - 0.5) * 3.4, by + Math.random() * 4.8, bz + (Math.random() - 0.5) * 3.4);\n                d.userData = {\n                  vel: new THREE.Vector3((Math.random() - 0.5) * 12, Math.random() * 12 + 2, (Math.random() - 0.5) * 12),\n                  life: 2 + Math.random() * 1.4,\n                };\n                scene.add(d);\n                deps.debris.push(d);\n              }\n              // three farewell shockwaves (harmless — pure spectacle)\n              spawnRing(0, true); spawnRing(0.35, true); spawnRing(0.7, true);\n              deps.audio.bossDie();\n              deps.addShake(1.4);\n              deps.onBossDead();\n            }\n          }\n          if (!boss.dead && boss.timer <= 0) {\n            boss.state = 'lobbing';\n            boss.lobTimer = 1.0;\n            vent.visible = false;\n          }\n        } else if (boss.state === 'staggered') {\n          boss.timer -= dt;\n          if (boss.timer <= 0) { boss.state = 'lobbing'; boss.lobTimer = 1.2; }\n        }\n      }\n    } else {\n      // dying: the heart's flare gutters out\n      boss.deadT += dt;\n      heartLight.intensity = Math.max(0, 4.5 - boss.deadT * 1.6);\n    }\n\n    // ---- molten shockwave rings ----\n    for (let i = rings.length - 1; i >= 0; i--) {\n      const ring = rings[i];\n      if (ring.delay > 0) { ring.delay -= dt; continue; }\n      if (!ring.mesh) {\n        ring.mesh = new THREE.Mesh(ringGeo, new THREE.MeshLambertMaterial({\n          color: 0xe8a13a, emissive: 0xe8a13a, emissiveIntensity: 1,\n          transparent: true, side: THREE.DoubleSide,\n        }));\n        ring.mesh.rotation.x = -Math.PI / 2;\n        ring.mesh.position.set(bx, deps.groundY + 0.07, bz);\n        scene.add(ring.mesh);\n        deps.audio.splash();\n      }\n      ring.r += 7.5 * dt;\n      ring.mesh.scale.set(ring.r, ring.r, 1);\n      ring.mesh.material.opacity = Math.min(1, (13 - ring.r) / 2.5);\n      // jump it or get scalded\n      if (!ring.harmless && player.state === 'alive') {\n        const d = Math.hypot(b.pos.x - bx, b.pos.z - bz);\n        if (Math.abs(d - ring.r) < 0.55 && b.pos.y < deps.groundY + 0.45) {\n          if (player.hit()) deps.onPlayerShatter();\n          else b.vel.y = 9;\n        }\n      }\n      if (ring.r > 13) {\n        scene.remove(ring.mesh);\n        ring.mesh.material.dispose();\n        rings.splice(i, 1);\n      }\n    }\n\n    // ---- projectiles ----\n    for (let i = projectiles.length - 1; i >= 0; i--) {\n      const p = projectiles[i];\n      p.userData.vel.y += p.userData.g * dt;\n      p.position.addScaledVector(p.userData.vel, dt);\n      p.rotation.x += dt * 6; p.rotation.y += dt * 4;\n      // direct hit\n      if (player.state === 'alive') {\n        const dx = p.position.x - b.pos.x, dy = p.position.y - (b.pos.y + 0.5), dz = p.position.z - b.pos.z;\n        if (dx * dx + dy * dy + dz * dz < 0.55) {\n          if (player.hit()) deps.onPlayerShatter();\n          scene.remove(p); projectiles.splice(i, 1);\n          continue;\n        }\n      }\n      if (p.position.y <= deps.groundY + 0.2) {\n        splashAt(p.position.x, p.position.z);\n        scene.remove(p);\n        projectiles.splice(i, 1);\n      }\n    }\n\n    // ---- molten splashes ----\n    for (let i = splashes.length - 1; i >= 0; i--) {\n      const s = splashes[i];\n      s.userData.life -= dt;\n      s.material.opacity = Math.min(1, s.userData.life);\n      if (player.state === 'alive' && s.userData.life > 0.2) {\n        const dx = Math.abs(s.position.x - b.pos.x), dz = Math.abs(s.position.z - b.pos.z);\n        if (dx < 0.9 && dz < 0.9 && b.pos.y < deps.groundY + 0.4) {\n          if (player.hit()) deps.onPlayerShatter();\n          else b.vel.y = 8;\n        }\n      }\n      if (s.userData.life <= 0) {\n        scene.remove(s);\n        s.geometry.dispose();\n        s.material.dispose();\n        splashes.splice(i, 1);\n      }\n    }\n  };\n\n  boss.dispose = () => {\n    for (const m of meshes) scene.remove(m);\n    scene.remove(vent);\n    scene.remove(heartLight);\n    for (const p of projectiles) scene.remove(p);\n    for (const s of splashes) scene.remove(s);\n    for (const ring of rings) if (ring.mesh) scene.remove(ring.mesh);\n  };\n\n  return boss;\n}"},{"name":"input.js","content":"// KILN — keyboard (WASD/arrows + space) and gamepad input,\n// tracking the last-used device so the UI can show the right glyphs.\n\nexport function createInput() {\n  const keys = new Set();\n  const state = {\n    moveX: 0,          // camera-relative, set by main each frame\n    moveZ: 0,\n    rawX: 0,           // raw input axes (-1..1)\n    rawZ: 0,\n    jumpHeld: false,\n    jumpPressed: false, // true only on the frame jump went down\n    pausePressed: false,\n    confirmPressed: false,\n    device: 'keyboard', // 'keyboard' | 'gamepad'\n    anyPressed: false,  // any input this frame (for \"press any key\")\n  };\n\n  let jumpWasDown = false;\n  let pauseWasDown = false;\n  let confirmWasDown = false;\n\n  window.addEventListener('keydown', (e) => {\n    if (e.repeat) return;\n    keys.add(e.code);\n    state.device = 'keyboard';\n    if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.code)) {\n      e.preventDefault();\n    }\n  });\n  window.addEventListener('keyup', (e) => keys.delete(e.code));\n  window.addEventListener('blur', () => keys.clear());\n\n  function pollGamepad() {\n    const pads = navigator.getGamepads ? navigator.getGamepads() : [];\n    for (const pad of pads) {\n      if (!pad || !pad.connected) continue;\n      const ax = pad.axes[0] || 0, az = pad.axes[1] || 0;\n      const dead = 0.22;\n      const gx = Math.abs(ax) > dead ? ax : 0;\n      const gz = Math.abs(az) > dead ? az : 0;\n      const dpadX = (pad.buttons[15] && pad.buttons[15].pressed ? 1 : 0) - (pad.buttons[14] && pad.buttons[14].pressed ? 1 : 0);\n      const dpadZ = (pad.buttons[13] && pad.buttons[13].pressed ? 1 : 0) - (pad.buttons[12] && pad.buttons[12].pressed ? 1 : 0);\n      const jump = (pad.buttons[0] && pad.buttons[0].pressed) || false; // A / Cross\n      const pause = (pad.buttons[9] && pad.buttons[9].pressed) || false; // Start\n      const confirm = jump;\n      if (gx || gz || dpadX || dpadZ || jump || pause) state.device = 'gamepad';\n      return { x: gx || dpadX, z: gz || dpadZ, jump, pause, confirm };\n    }\n    return null;\n  }\n\n  state.poll = () => {\n    const kx = (keys.has('KeyD') || keys.has('ArrowRight') ? 1 : 0) - (keys.has('KeyA') || keys.has('ArrowLeft') ? 1 : 0);\n    const kz = (keys.has('KeyS') || keys.has('ArrowDown') ? 1 : 0) - (keys.has('KeyW') || keys.has('ArrowUp') ? 1 : 0);\n    const kJump = keys.has('Space');\n    const kPause = keys.has('Escape') || keys.has('KeyP');\n    const kConfirm = keys.has('Enter') || keys.has('Space');\n\n    const gp = pollGamepad();\n    let x = kx, z = kz, jump = kJump, pause = kPause, confirm = kConfirm;\n    if (gp && (gp.x || gp.z || gp.jump || gp.pause)) {\n      x = gp.x || kx; z = gp.z || kz;\n      jump = gp.jump || kJump;\n      pause = gp.pause || kPause;\n      confirm = gp.confirm || kConfirm;\n    }\n\n    // normalise diagonal\n    const len = Math.hypot(x, z);\n    if (len > 1) { x /= len; z /= len; }\n\n    state.rawX = x;\n    state.rawZ = z;\n    state.jumpHeld = jump;\n    state.jumpPressed = jump && !jumpWasDown;\n    state.pausePressed = pause && !pauseWasDown;\n    state.confirmPressed = confirm && !confirmWasDown;\n    state.anyPressed = state.jumpPressed || state.pausePressed || !!x || !!z;\n    jumpWasDown = jump;\n    pauseWasDown = pause;\n    confirmWasDown = confirm;\n  };\n\n  state.glyphs = () =>\n    state.device === 'gamepad'\n      ? { move: 'Left Stick', jump: 'Ⓐ', pause: 'Start' }\n      : { move: 'WASD / Arrows', jump: 'Space', pause: 'Esc' };\n\n  return state;\n}\n"},{"name":"main.js","content":"// KILN — bootstrap + game loop. Wires every module together.\n// A voxel platformer set inside a colossal abandoned pottery kiln.\n\nimport * as THREE from 'three';\nimport { SOOT, ASH, EMBER, GLAZE_PALE, MAT } from './palette.js';\nimport { createField, buildFieldMesh, buildModelMesh, makeDebrisVoxel } from './voxels.js';\nimport { createWorld, moveBody, distSq } from './physics.js';\nimport { createPlayer } from './player.js';\nimport { createInput } from './input.js';\nimport { createGameCamera } from './camera.js';\nimport { createAudio } from './audio.js';\nimport { createUI } from './ui.js';\nimport { freshProgress, encodeSave, decodeSave, totalDrops } from './save.js';\nimport { createCrab, createBoss } from './enemies.js';\nimport { level1 } from './levels/level1.js';\nimport { level2 } from './levels/level2.js';\nimport { level3 } from './levels/level3.js';\nimport { level4 } from './levels/level4.js';\nimport { level5 } from './levels/level5.js';\nimport { level6 } from './levels/level6.js';\nimport { level7 } from './levels/level7.js';\nimport { bonusLevel } from './levels/bonus.js';\n\nconst LEVELS = [level1, level2, level3, level4, level5, level6, level7, bonusLevel];\n\n// ---------- renderer / scene ----------\nconst canvas = document.getElementById('game-canvas');\nconst renderer = new THREE.WebGLRenderer({ canvas, antialias: true });\nrenderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));\nrenderer.setSize(window.innerWidth, window.innerHeight);\nrenderer.shadowMap.enabled = true;\nrenderer.shadowMap.type = THREE.PCFSoftShadowMap;\nrenderer.toneMapping = THREE.ACESFilmicToneMapping; // richer embers, deeper soot\nrenderer.toneMappingExposure = 1.15;\n\nconst scene = new THREE.Scene();\nscene.background = new THREE.Color(SOOT);\nscene.fog = new THREE.Fog(SOOT, 34, 95);\n\nconst camera = new THREE.PerspectiveCamera(52, window.innerWidth / window.innerHeight, 0.1, 300);\nwindow.addEventListener('resize', () => {\n  camera.aspect = window.innerWidth / window.innerHeight;\n  camera.updateProjectionMatrix();\n  renderer.setSize(window.innerWidth, window.innerHeight);\n});\n\n// warm key light — low sun through a crack in the kiln wall\nconst keyLight = new THREE.DirectionalLight(0xffc98a, 1.5);\nkeyLight.castShadow = true;\nkeyLight.shadow.mapSize.set(2048, 2048);\nkeyLight.shadow.bias = -0.0018;\nscene.add(keyLight);\nscene.add(keyLight.target);\n// cool ambient fill\nscene.add(new THREE.HemisphereLight(GLAZE_PALE, SOOT, 0.55));\nscene.add(new THREE.AmbientLight(0x2a2a3a, 0.7));\n\n// ---------- persistent state ----------\nconst settings = { volume: 0.8, camSens: 1.0, screenShake: true };\nlet progress = freshProgress();\nprogress.settings = settings;\n\nconst audio = createAudio(settings);\nconst input = createInput();\nconst gameCam = createGameCamera(camera, settings);\nconst player = createPlayer(scene, audio);\n\n// a warm glow that follows Clawd — keeps him readable in the dark kiln\nconst clawdGlow = new THREE.PointLight(EMBER, 0.55, 6);\nscene.add(clawdGlow);\n\nlet mode = 'title'; // title | playing | paused | transition | bossfall | ending\nlet levelIdx = 0;\nlet levelDef = null;\nlet levelRoot = null;\nlet world = null;\nlet respawnAt = [0, 0, 0];\nlet elapsed = 0;\nlet titleT = 0;\nlet had35 = false;\n\n// per-level live objects\nlet movers = [];\nlet crumbles = [];\nlet dropObjs = [];\nlet lanterns = [];\nlet crabs = [];\nlet boss = null;\nlet heat = null;\nlet windZones = [];\nlet goalMarker = null;\nlet debris = []; // shared enemy/crumble debris\n\n// ---------- ash particles: slow drifting, always ----------\nconst ASH_COUNT = 320;\nconst ashGeo = new THREE.BufferGeometry();\nconst ashPos = new Float32Array(ASH_COUNT * 3);\nfor (let i = 0; i < ASH_COUNT; i++) {\n  ashPos[i * 3] = (Math.random() - 0.5) * 60;\n  ashPos[i * 3 + 1] = Math.random() * 30 - 5;\n  ashPos[i * 3 + 2] = (Math.random() - 0.5) * 60;\n}\nashGeo.setAttribute('position', new THREE.BufferAttribute(ashPos, 3));\nconst ashPts = new THREE.Points(ashGeo, new THREE.PointsMaterial({\n  color: ASH, size: 0.07, transparent: true, opacity: 0.65, sizeAttenuation: true,\n  depthWrite: false, // soft motes, no z-fighting halos\n}));\nscene.add(ashPts);\n\nfunction updateAsh(dt) {\n  const p = ashGeo.attributes.position.array;\n  const cx = camera.position.x, cy = camera.position.y, cz = camera.position.z;\n  for (let i = 0; i < ASH_COUNT; i++) {\n    p[i * 3] += Math.sin(elapsed * 0.6 + i) * 0.12 * dt;\n    p[i * 3 + 1] -= (0.25 + (i % 5) * 0.05) * dt;\n    p[i * 3 + 2] += Math.cos(elapsed * 0.5 + i * 2) * 0.1 * dt;\n    // wrap around the camera\n    if (p[i * 3] < cx - 30) p[i * 3] += 60; if (p[i * 3] > cx + 30) p[i * 3] -= 60;\n    if (p[i * 3 + 1] < cy - 18) p[i * 3 + 1] += 34;\n    if (p[i * 3 + 2] < cz - 30) p[i * 3 + 2] += 60; if (p[i * 3 + 2] > cz + 30) p[i * 3 + 2] -= 60;\n  }\n  ashGeo.attributes.position.needsUpdate = true;\n}\n\n// ---------- ember wisps: heat shimmer above lava-glaze pools ----------\nlet emberPts = null;\nlet emberHomes = null;\nfunction buildEmberWisps(def) {\n  const pools = [];\n  for (const b of def.boxes.concat(def.decor || [])) {\n    if (b[6] === 'ember') pools.push(b);\n  }\n  if (!pools.length) return;\n  const N = Math.min(160, pools.length * 24);\n  const geo = new THREE.BufferGeometry();\n  const pos = new Float32Array(N * 3);\n  emberHomes = new Float32Array(N * 3);\n  for (let i = 0; i < N; i++) {\n    const b = pools[i % pools.length];\n    const x = b[0] + Math.random() * b[3];\n    const y = b[1] + b[4];\n    const z = b[2] + Math.random() * b[5];\n    pos[i * 3] = x; pos[i * 3 + 1] = y + Math.random() * 2.5; pos[i * 3 + 2] = z;\n    emberHomes[i * 3] = x; emberHomes[i * 3 + 1] = y; emberHomes[i * 3 + 2] = z;\n  }\n  geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));\n  emberPts = new THREE.Points(geo, new THREE.PointsMaterial({\n    color: EMBER, size: 0.09, transparent: true, opacity: 0.8, depthWrite: false,\n  }));\n  levelRoot.add(emberPts);\n}\nfunction updateEmberWisps(dt) {\n  if (!emberPts) return;\n  const p = emberPts.geometry.attributes.position.array;\n  for (let i = 0; i < p.length / 3; i++) {\n    p[i * 3 + 1] += (0.9 + (i % 4) * 0.2) * dt;\n    p[i * 3] += Math.sin(elapsed * 3 + i * 1.7) * 0.35 * dt; // shimmer wobble\n    if (p[i * 3 + 1] > emberHomes[i * 3 + 1] + 2.8) {\n      p[i * 3] = emberHomes[i * 3]; p[i * 3 + 1] = emberHomes[i * 3 + 1]; p[i * 3 + 2] = emberHomes[i * 3 + 2];\n    }\n  }\n  emberPts.geometry.attributes.position.needsUpdate = true;\n}\n\n// ---------- small prop models ----------\nfunction makeDropMesh() {\n  return buildModelMesh([\n    [1, 0, 1, MAT.GLAZE],\n    [0, 1, 1, MAT.GLAZE], [2, 1, 1, MAT.GLAZE], [1, 1, 0, MAT.GLAZE], [1, 1, 2, MAT.GLAZE], [1, 1, 1, MAT.GLAZE],\n    [1, 2, 1, MAT.GLAZE_PALE],\n  ], { voxelSize: 0.17 });\n}\n\nfunction makeLanternMesh(lit) {\n  return buildModelMesh([\n    [1, 0, 1, MAT.SOOT], [1, 1, 1, MAT.SOOT], [1, 2, 1, MAT.SOOT],\n    [0, 3, 0, MAT.TERRA_DEEP], [2, 3, 0, MAT.TERRA_DEEP], [0, 3, 2, MAT.TERRA_DEEP], [2, 3, 2, MAT.TERRA_DEEP],\n    [1, 3, 1, lit ? MAT.EMBER : MAT.ASH],\n    [0, 4, 0, MAT.TERRA_DEEP], [1, 4, 0, MAT.TERRA_DEEP], [2, 4, 0, MAT.TERRA_DEEP],\n    [0, 4, 1, MAT.TERRA_DEEP], [1, 4, 1, MAT.TERRA_DEEP], [2, 4, 1, MAT.TERRA_DEEP],\n    [0, 4, 2, MAT.TERRA_DEEP], [1, 4, 2, MAT.TERRA_DEEP], [2, 4, 2, MAT.TERRA_DEEP],\n  ], { voxelSize: 0.22 });\n}\n\nfunction makeGoalFlame() {\n  return buildModelMesh([\n    [1, 0, 1, MAT.EMBER], [0, 1, 1, MAT.EMBER], [2, 1, 1, MAT.EMBER],\n    [1, 1, 0, MAT.EMBER], [1, 1, 2, MAT.EMBER], [1, 2, 1, MAT.EMBER], [1, 3, 1, MAT.EMBER],\n  ], { voxelSize: 0.15 });\n}\n\nfunction makeMoverMesh(size, mat) {\n  const vox = [];\n  for (let x = 0; x < 5; x++)\n    for (let z = 0; z < 5; z++) {\n      vox.push([x, 1, z, mat]);\n      vox.push([x, 0, z, MAT.TERRA_DEEP]);\n    }\n  const m = buildModelMesh(vox, { voxelSize: 0.4 });\n  m.scale.set(size[0] / 2, size[1] / 0.8, size[2] / 2);\n  return m;\n}\n\n// the lantern light — one shared point light that sits at the active checkpoint\nconst lanternLight = new THREE.PointLight(EMBER, 0.9, 9);\nlanternLight.visible = false;\nscene.add(lanternLight);\n\n// ---------- level loading ----------\nfunction disposeGroup(g) {\n  g.traverse((o) => {\n    if (o.geometry) o.geometry.dispose();\n    if (o.material && o.material.dispose && o.material.vertexColors === undefined) o.material.dispose();\n  });\n}\n\nfunction unloadLevel() {\n  if (levelRoot) {\n    scene.remove(levelRoot);\n    disposeGroup(levelRoot);\n    levelRoot = null;\n  }\n  for (const c of crabs) c.dispose();\n  if (boss) boss.dispose();\n  for (const d of debris) scene.remove(d);\n  debris = [];\n  movers = []; crumbles = []; dropObjs = []; lanterns = []; crabs = [];\n  boss = null; heat = null; windZones = []; goalMarker = null;\n  emberPts = null; emberHomes = null;\n  lanternLight.visible = false;\n}\n\nfunction loadLevel(idx, opts = {}) {\n  unloadLevel();\n  levelIdx = idx;\n  levelDef = LEVELS[idx];\n  levelRoot = new THREE.Group();\n  scene.add(levelRoot);\n\n  // static solid field\n  const field = createField();\n  for (const [x, y, z, w, h, d, m] of levelDef.boxes) field.box(x, y, z, w, h, d, m);\n  levelRoot.add(buildFieldMesh(field));\n\n  // decor (non-solid)\n  if (levelDef.decor && levelDef.decor.length) {\n    const decorField = createField();\n    for (const [x, y, z, w, h, d, m] of levelDef.decor) decorField.box(x, y, z, w, h, d, m);\n    const decorMesh = buildFieldMesh(decorField);\n    decorMesh.traverse((o) => { o.castShadow = false; });\n    levelRoot.add(decorMesh);\n  }\n\n  world = createWorld(field);\n  world.killY = levelDef.killY;\n\n  // movers\n  for (const def of levelDef.movers) {\n    const mesh = makeMoverMesh(def.size, def.mat);\n    levelRoot.add(mesh);\n    const mv = {\n      def, size: def.size, mat: def.mat, mesh,\n      pos: [0, 0, 0], vel: [0, 0, 0],\n      s: 0, dir: 1, dwell: 0,\n    };\n    movers.push(mv);\n    world.movers.push(mv);\n    updateMover(mv, 0); // set initial position\n  }\n\n  // crumble shelves\n  for (const box of levelDef.crumble) {\n    const cf = createField();\n    cf.box(box[0], box[1], box[2], box[3], box[4], box[5], 'ash');\n    const original = Array.from(cf.cells.keys());\n    const chunk = {\n      field: cf, original, state: 'intact', timer: 0, mesh: null,\n      ef: null,\n    };\n    chunk.ef = { field: cf, enabled: () => cf.cells.size > 0, chunk };\n    world.extraFields.push(chunk.ef);\n    chunk.mesh = buildFieldMesh(cf);\n    levelRoot.add(chunk.mesh);\n    crumbles.push(chunk);\n  }\n\n  // drops (skip already-found ones)\n  levelDef.drops.forEach((dp, i) => {\n    if (progress.drops[idx][i]) return;\n    const mesh = makeDropMesh();\n    mesh.position.set(dp[0], dp[1], dp[2]);\n    levelRoot.add(mesh);\n    dropObjs.push({ i, pos: dp, mesh, t: Math.random() * 6 });\n  });\n\n  // checkpoints\n  for (const cp of levelDef.checkpoints) {\n    const mesh = makeLanternMesh(false);\n    mesh.position.set(cp[0], cp[1], cp[2]);\n    levelRoot.add(mesh);\n    lanterns.push({ pos: cp, mesh, lit: false });\n  }\n\n  // goal\n  if (levelDef.goal) {\n    goalMarker = makeGoalFlame();\n    goalMarker.position.set(levelDef.goal[0], levelDef.goal[1] + 0.4, levelDef.goal[2]);\n    levelRoot.add(goalMarker);\n  }\n\n  // wind\n  windZones = levelDef.wind.map((w) => {\n    const N = 48;\n    const geo = new THREE.BufferGeometry();\n    const pos = new Float32Array(N * 3);\n    for (let i = 0; i < N; i++) {\n      pos[i * 3] = w.box[0] + Math.random() * w.box[3];\n      pos[i * 3 + 1] = w.box[1] + Math.random() * w.box[4];\n      pos[i * 3 + 2] = w.box[2] + Math.random() * w.box[5];\n    }\n    geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));\n    const pts = new THREE.Points(geo, new THREE.PointsMaterial({\n      color: 0xe8ded0, size: 0.06, transparent: true, opacity: 0.5, depthWrite: false,\n    }));\n    pts.visible = false;\n    levelRoot.add(pts);\n    return { def: w, pts, wasActive: false };\n  });\n\n  // heat\n  if (levelDef.heat) {\n    const geo = new THREE.PlaneGeometry(46, 46);\n    const mat = new THREE.MeshLambertMaterial({\n      color: EMBER, emissive: EMBER, emissiveIntensity: 0.8,\n      transparent: true, opacity: 0.55, side: THREE.DoubleSide,\n    });\n    const mesh = new THREE.Mesh(geo, mat);\n    mesh.rotation.x = -Math.PI / 2;\n    const ext = levelExtents(levelDef);\n    mesh.position.set((ext.minX + ext.maxX) / 2, levelDef.heat.startY, (ext.minZ + ext.maxZ) / 2);\n    levelRoot.add(mesh);\n    heat = { def: levelDef.heat, mesh, y: levelDef.heat.startY, t: 0 };\n  }\n\n  // crabs\n  const crabDeps = {\n    audio,\n    debris,\n    addShake: (a) => gameCam.addShake(a),\n    onPlayerShatter: () => gameCam.addShake(0.8),\n  };\n  for (const cd of levelDef.crabs) crabs.push(createCrab(levelRoot, cd, crabDeps));\n\n  // boss\n  if (levelDef.boss) {\n    boss = createBoss(levelRoot, levelDef.boss, {\n      audio,\n      debris,\n      groundY: 4,\n      addShake: (a) => gameCam.addShake(a),\n      onPlayerShatter: () => gameCam.addShake(0.8),\n      onBossStart: () => { ui.showBossBar(true); ui.setBossHp(1); ui.toast('THE KILNHEART', 2600); },\n      onBossDamaged: (hp, ph) => {\n        ui.setBossHp(hp / 6);\n        ui.setBossPhase(ph);\n        if (hp === 4) ui.toast('the amphora cracks — it burns hotter', 2400);\n        if (hp === 2) ui.toast('the kilnheart splits — shards awaken', 2400);\n      },\n      onBossDead: () => {\n        ui.showBossBar(false);\n        mode = 'bossfall';\n        bossFallT = 0;\n      },\n      spawnBossCrab: () => {\n        if (crabs.filter((c) => !c.dead).length >= 3) return;\n        const a = [90 + Math.random() * 4, -4 + Math.random() * 8];\n        const b = [104 - Math.random() * 4, a[1]];\n        crabs.push(createCrab(levelRoot, { a, b, y: 4, speed: 2.8 }, crabDeps));\n      },\n    });\n  }\n\n  // shadow camera fitted to the level\n  const ext = levelExtents(levelDef);\n  const cx = (ext.minX + ext.maxX) / 2, cz = (ext.minZ + ext.maxZ) / 2, cy = (ext.minY + ext.maxY) / 2;\n  keyLight.position.set(cx - 28, cy + 34, cz + 18);\n  keyLight.target.position.set(cx, cy, cz);\n  const spanX = (ext.maxX - ext.minX) / 2 + 8, spanZ = (ext.maxZ - ext.minZ) / 2 + 8;\n  const span = Math.max(spanX, spanZ, 20);\n  keyLight.shadow.camera.left = -span; keyLight.shadow.camera.right = span;\n  keyLight.shadow.camera.top = span; keyLight.shadow.camera.bottom = -span;\n  keyLight.shadow.camera.near = 1; keyLight.shadow.camera.far = 140;\n  keyLight.shadow.camera.updateProjectionMatrix();\n\n  // spawn\n  respawnAt = levelDef.spawn.slice();\n  player.teleport(...levelDef.spawn);\n  player.heal();\n  player.state = 'alive';\n  player.frozen = !!opts.diorama;\n  player.root.visible = true;\n  gameCam.snap(...levelDef.spawn);\n\n  if (!opts.diorama) {\n    ui.showHUD(levelDef.name);\n    ui.setDrops(progress.drops[idx].filter(Boolean).length, 5);\n    ui.showBossBar(false);\n  }\n}\n\nfunction levelExtents(def) {\n  let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity, minZ = Infinity, maxZ = -Infinity;\n  for (const b of def.boxes) {\n    minX = Math.min(minX, b[0]); maxX = Math.max(maxX, b[0] + b[3]);\n    minY = Math.min(minY, b[1]); maxY = Math.max(maxY, b[1] + b[4]);\n    minZ = Math.min(minZ, b[2]); maxZ = Math.max(maxZ, b[2] + b[5]);\n  }\n  return { minX, maxX, minY, maxY, minZ, maxZ };\n}\n\n// ---------- movers ----------\nfunction updateMover(mv, dt) {\n  const def = mv.def;\n  if (def.orbit) {\n    const o = def.orbit;\n    const a = o.phase + elapsed * o.speed;\n    const px = o.center[0] + Math.cos(a) * o.radius - mv.size[0] / 2;\n    const pz = o.center[2] + Math.sin(a) * o.radius - mv.size[2] / 2;\n    mv.vel[0] = -Math.sin(a) * o.radius * o.speed;\n    mv.vel[1] = 0;\n    mv.vel[2] = Math.cos(a) * o.radius * o.speed;\n    mv.pos[0] = px; mv.pos[1] = o.center[1]; mv.pos[2] = pz;\n  } else {\n    const [pa, pb] = def.path;\n    const len = Math.hypot(pb[0] - pa[0], pb[1] - pa[1], pb[2] - pa[2]);\n    if (mv.dwell > 0) {\n      mv.dwell -= dt;\n      mv.vel[0] = mv.vel[1] = mv.vel[2] = 0;\n    } else {\n      mv.s += def.speed * mv.dir * dt;\n      if (mv.s >= len) { mv.s = len; mv.dir = -1; mv.dwell = def.dwell; }\n      if (mv.s <= 0) { mv.s = 0; mv.dir = 1; mv.dwell = def.dwell; }\n      const u = [(pb[0] - pa[0]) / len, (pb[1] - pa[1]) / len, (pb[2] - pa[2]) / len];\n      mv.vel[0] = u[0] * def.speed * mv.dir;\n      mv.vel[1] = u[1] * def.speed * mv.dir;\n      mv.vel[2] = u[2] * def.speed * mv.dir;\n    }\n    const t = mv.s / len;\n    mv.pos[0] = pa[0] + (pb[0] - pa[0]) * t;\n    mv.pos[1] = pa[1] + (pb[1] - pa[1]) * t;\n    mv.pos[2] = pa[2] + (pb[2] - pa[2]) * t;\n  }\n  mv.mesh.position.set(mv.pos[0] + mv.size[0] / 2, mv.pos[1], mv.pos[2] + mv.size[2] / 2);\n}\n\n// ---------- crumbles ----------\nfunction updateCrumble(chunk, dt) {\n  if (chunk.state === 'intact') {\n    if (player.body.touched && player.body.touched.some((ef) => ef.chunk === chunk)) {\n      chunk.state = 'triggered';\n      chunk.timer = 0.6;\n      audio.crack();\n    }\n  } else if (chunk.state === 'triggered') {\n    chunk.timer -= dt;\n    // tremble\n    chunk.mesh.position.x = (Math.random() - 0.5) * 0.05;\n    chunk.mesh.position.z = (Math.random() - 0.5) * 0.05;\n    if (chunk.timer <= 0) {\n      chunk.state = 'collapsing';\n      chunk.timer = 0;\n      chunk.mesh.position.set(0, 0, 0);\n    }\n  } else if (chunk.state === 'collapsing') {\n    chunk.timer -= dt;\n    if (chunk.timer <= 0 && chunk.field.cells.size > 0) {\n      chunk.timer = 0.05;\n      // remove one voxel, let it fall as debris\n      const key = chunk.field.cells.keys().next().value;\n      const [x, y, z] = key.split(',').map(Number);\n      chunk.field.cells.delete(key);\n      const d = makeDebrisVoxel(MAT.ASH, 1);\n      d.position.set(x + 0.5, y + 0.5, z + 0.5);\n      d.userData = { vel: new THREE.Vector3((Math.random() - 0.5) * 1.5, -2, (Math.random() - 0.5) * 1.5), life: 0.8 };\n      scene.add(d);\n      debris.push(d);\n      rebuildCrumbleMesh(chunk);\n    }\n    if (chunk.field.cells.size === 0) {\n      chunk.state = 'gone';\n      chunk.timer = 4;\n    }\n  } else if (chunk.state === 'gone') {\n    chunk.timer -= dt;\n    if (chunk.timer <= 0) {\n      for (const key of chunk.original) chunk.field.cells.set(key, 'ash');\n      rebuildCrumbleMesh(chunk);\n      chunk.state = 'intact';\n    }\n  }\n}\n\nfunction rebuildCrumbleMesh(chunk) {\n  if (chunk.mesh) {\n    levelRoot.remove(chunk.mesh);\n    disposeGroup(chunk.mesh);\n  }\n  chunk.mesh = buildFieldMesh(chunk.field);\n  levelRoot.add(chunk.mesh);\n}\n\nfunction resetCrumbles() {\n  for (const chunk of crumbles) {\n    if (chunk.state !== 'intact') {\n      for (const key of chunk.original) chunk.field.cells.set(key, 'ash');\n      rebuildCrumbleMesh(chunk);\n      chunk.state = 'intact';\n    }\n  }\n}\n\n// ---------- debris (shared) ----------\nfunction updateDebris(dt) {\n  for (let i = debris.length - 1; i >= 0; i--) {\n    const d = debris[i];\n    d.userData.life -= dt;\n    d.userData.vel.y -= 20 * dt;\n    d.position.addScaledVector(d.userData.vel, dt);\n    d.rotation.x += dt * 4; d.rotation.y += dt * 3;\n    d.material.opacity = Math.max(0, Math.min(1, d.userData.life * 1.6));\n    if (d.userData.life <= 0) {\n      scene.remove(d);\n      d.material.dispose();\n      debris.splice(i, 1);\n    }\n  }\n}\n\n// ---------- deaths & respawn ----------\nfunction killPlayer() {\n  if (player.state !== 'alive') return;\n  player.shatter();\n  gameCam.addShake(0.8);\n}\n\nfunction doRespawn() {\n  player.respawn(respawnAt[0], respawnAt[1] + 0.05, respawnAt[2]);\n  if (heat) { heat.y = heat.def.startY; heat.t = 0; heat.mesh.position.y = heat.y; }\n  resetCrumbles();\n  gameCam.snap(...respawnAt);\n}\n\n// ---------- level completion ----------\nlet bossFallT = 0;\n\nfunction completeLevel() {\n  mode = 'transition';\n  audio.goal();\n  progress.completed[levelIdx] = true;\n  if (levelIdx < 6) progress.unlocked = Math.max(progress.unlocked, levelIdx + 2);\n  ui.toast(levelDef.name + '\\nfired ✓', 2000);\n  ui.fade(true);\n  setTimeout(() => {\n    if (levelIdx >= 6) {\n      // bonus level (or replayed Kilnheart without boss end) → back to title\n      goToTitle();\n    } else {\n      loadLevel(levelIdx + 1);\n      mode = 'playing';\n    }\n    ui.fade(false);\n  }, 1300);\n}\n\nfunction goToTitle() {\n  mode = 'title';\n  titleT = 0;\n  ui.hideHUD();\n  audio.rumbleOff();\n  audio.duck(false);\n  loadLevel(0, { diorama: true });\n  ui.refreshTitle();\n  ui.showTitle();\n}\n\n// ---------- UI ----------\nconst ui = createUI(document.getElementById('ui-root'), {\n  audio,\n  getProgress: () => progress,\n  onStartLevel: (idx) => {\n    audio.start();\n    ui.hideAll();\n    ui.fade(true);\n    setTimeout(() => {\n      loadLevel(idx);\n      mode = 'playing';\n      ui.fade(false);\n    }, 350);\n  },\n  onResume: () => { mode = 'playing'; ui.hideAll(); audio.duck(false); },\n  onRestart: () => {\n    ui.hideAll();\n    audio.duck(false);\n    loadLevel(levelIdx);\n    mode = 'playing';\n  },\n  onQuit: () => { ui.hideAll(); goToTitle(); },\n  onSettingsChanged: () => { audio.setVolume(settings.volume); },\n  getSaveCode: () => encodeSave(progress),\n  loadSaveCode: (code) => {\n    const res = decodeSave(code);\n    if (res.ok) {\n      // keep the live settings object so audio/camera stay wired\n      Object.assign(settings, res.progress.settings);\n      res.progress.settings = settings;\n      progress = res.progress;\n      audio.setVolume(settings.volume);\n      had35 = totalDrops(progress, true) >= 35;\n      ui.refreshTitle();\n    }\n    return res;\n  },\n});\n\n// ---------- game update ----------\nconst windMods = { windX: 0, windZ: 0 };\n\nfunction gustActive(w) {\n  return ((elapsed + w.offset) % w.period) < w.period * w.duty;\n}\n\nfunction updatePlaying(dt) {\n  // pause\n  if (input.pausePressed) {\n    mode = 'paused';\n    audio.duck(true);\n    ui.showPause();\n    return;\n  }\n\n  // wind\n  windMods.windX = 0; windMods.windZ = 0;\n  const b = player.body;\n  for (const wz of windZones) {\n    const w = wz.def;\n    const active = gustActive(w);\n    wz.pts.visible = active;\n    if (active) {\n      const p = wz.pts.geometry.attributes.position.array;\n      for (let i = 0; i < p.length / 3; i++) {\n        p[i * 3] += w.dir[0] * 14 * dt;\n        p[i * 3 + 2] += w.dir[1] * 14 * dt;\n        if (p[i * 3] > w.box[0] + w.box[3]) p[i * 3] -= w.box[3];\n        if (p[i * 3] < w.box[0]) p[i * 3] += w.box[3];\n        if (p[i * 3 + 2] > w.box[2] + w.box[5]) p[i * 3 + 2] -= w.box[5];\n        if (p[i * 3 + 2] < w.box[2]) p[i * 3 + 2] += w.box[5];\n      }\n      wz.pts.geometry.attributes.position.needsUpdate = true;\n      if (!wz.wasActive) audio.gust();\n      const inX = b.pos.x > w.box[0] && b.pos.x < w.box[0] + w.box[3];\n      const inY = b.pos.y > w.box[1] - 1 && b.pos.y < w.box[1] + w.box[4];\n      const inZ = b.pos.z > w.box[2] && b.pos.z < w.box[2] + w.box[5];\n      if (inX && inY && inZ) {\n        windMods.windX += w.dir[0] * w.strength;\n        windMods.windZ += w.dir[1] * w.strength;\n      }\n    }\n    wz.wasActive = active;\n  }\n\n  // input mapping: fixed camera yaw → screen axes are world axes\n  input.moveX = input.rawX;\n  input.moveZ = input.rawZ;\n\n  player.update(dt, input, world, windMods);\n\n  // movers / crumbles / debris\n  for (const mv of movers) updateMover(mv, dt);\n  for (const chunk of crumbles) updateCrumble(chunk, dt);\n  updateDebris(dt);\n  updateEmberWisps(dt);\n\n  if (player.state === 'alive') {\n    // fell off the kiln\n    if (b.pos.y < world.killY) killPlayer();\n\n    // heat\n    if (heat) {\n      heat.t += dt;\n      if (heat.t > heat.def.delay) {\n        heat.y = Math.min(heat.def.maxY, heat.y + heat.def.speed * dt);\n        heat.mesh.position.y = heat.y;\n      }\n      heat.mesh.material.opacity = 0.45 + Math.sin(elapsed * 5) * 0.08;\n      if (b.pos.y < heat.y - 0.1) {\n        if (!player.hit()) b.vel.y = 13; // scalded upward — one chance to climb out\n        gameCam.addShake(0.4);\n      }\n    }\n\n    // drops\n    for (let i = dropObjs.length - 1; i >= 0; i--) {\n      const dr = dropObjs[i];\n      dr.t += dt;\n      dr.mesh.rotation.y = dr.t * 1.6;\n      dr.mesh.position.y = dr.pos[1] + Math.sin(dr.t * 2.2) * 0.14;\n      if (distSq(b.pos.x, b.pos.y + 0.5, b.pos.z, dr.pos[0], dr.pos[1] + 0.2, dr.pos[2]) < 1.35) {\n        progress.drops[levelIdx][dr.i] = true;\n        levelRoot.remove(dr.mesh);\n        disposeGroup(dr.mesh);\n        dropObjs.splice(i, 1);\n        audio.collect();\n        ui.setDrops(progress.drops[levelIdx].filter(Boolean).length, 5);\n        const t35 = totalDrops(progress, true);\n        if (t35 >= 35 && !had35) {\n          had35 = true;\n          ui.toast(\"all 35 drops found\\nThe Potter's Wheel has begun to turn\", 3400);\n        }\n      }\n    }\n\n    // checkpoints\n    for (const ln of lanterns) {\n      if (!ln.lit && distSq(b.pos.x, b.pos.y, b.pos.z, ln.pos[0], ln.pos[1], ln.pos[2]) < 2.6) {\n        ln.lit = true;\n        // relight the lantern mesh\n        levelRoot.remove(ln.mesh);\n        disposeGroup(ln.mesh);\n        ln.mesh = makeLanternMesh(true);\n        ln.mesh.position.set(ln.pos[0], ln.pos[1], ln.pos[2]);\n        levelRoot.add(ln.mesh);\n        respawnAt = [ln.pos[0], ln.pos[1] + 0.1, ln.pos[2]];\n        lanternLight.position.set(ln.pos[0], ln.pos[1] + 1, ln.pos[2]);\n        lanternLight.visible = true;\n        audio.checkpoint();\n        ui.toast('lantern lit', 1400);\n      }\n    }\n\n    // goal\n    if (levelDef.goal && distSq(b.pos.x, b.pos.y, b.pos.z, levelDef.goal[0], levelDef.goal[1], levelDef.goal[2]) < 2.9) {\n      completeLevel();\n      return;\n    }\n    if (goalMarker) {\n      goalMarker.position.y = levelDef.goal[1] + 0.4 + Math.sin(elapsed * 2.4) * 0.12;\n      goalMarker.rotation.y = elapsed;\n    }\n  } else if (player.state === 'dead' && player.deadTimer > 0.95) {\n    doRespawn();\n  }\n\n  // enemies\n  for (const c of crabs) c.update(dt, player);\n  if (boss) boss.update(dt, player);\n\n  // heavy-landing shake hook\n  gameCam.update(dt, player, boss);\n}\n\n// landing shake\nplayer.onLanded = (impact) => {\n  if (impact > 15) gameCam.addShake(Math.min(0.6, (impact - 15) * 0.05));\n};\n\n// ---------- boss-fall / ending ----------\nfunction updateBossFall(dt) {\n  updateDebris(dt);\n  updateEmberWisps(dt);\n  if (boss) boss.update(dt, player); // farewell shockwaves + heart flare fade\n  player.update(dt, input, world, windMods);\n  gameCam.update(dt, player, null);\n  gameCam.addShake(0.04);\n  bossFallT += dt;\n  if (bossFallT > 3.2) {\n    mode = 'ending';\n    progress.completed[6] = true;\n    progress.bossDown = true;\n    audio.rumbleOff();\n    ui.fade(true);\n    setTimeout(() => {\n      ui.hideHUD();\n      ui.showEnding();\n      ui.fade(false);\n    }, 1200);\n  }\n}\n\n// ---------- main loop ----------\nlet last = performance.now();\nlet audioKicked = false;\nlet lastDevice = '';\n\nfunction frame(now) {\n  requestAnimationFrame(frame);\n  const dt = Math.min(0.033, (now - last) / 1000);\n  last = now;\n  elapsed += dt;\n\n  input.poll();\n  if (!audioKicked && input.anyPressed) { audio.start(); audioKicked = true; }\n\n  // glyph refresh on device change\n  if (input.device !== lastDevice) {\n    lastDevice = input.device;\n    ui.setGlyphs(input.glyphs());\n  }\n\n  if (mode === 'playing') {\n    updatePlaying(dt);\n  } else if (mode === 'title') {\n    titleT += dt;\n    // slow orbit of the level-one diorama\n    gameCam.orbit(titleT, 10, 2, 0, 17);\n    for (const mv of movers) updateMover(mv, dt);\n    updateEmberWisps(dt);\n  } else if (mode === 'bossfall') {\n    updateBossFall(dt);\n  } else if (mode === 'paused' || mode === 'transition' || mode === 'ending') {\n    // world holds its breath\n    updateDebris(dt);\n  }\n\n  updateAsh(dt);\n  // Clawd's glow tracks him everywhere (flickers gently like an ember)\n  clawdGlow.position.set(player.body.pos.x, player.body.pos.y + 0.9, player.body.pos.z);\n  clawdGlow.intensity = 0.5 + Math.sin(elapsed * 7) * 0.07;\n  clawdGlow.visible = player.state !== 'dead';\n  renderer.render(scene, camera);\n}\n\n// audio unlock on any pointer interaction too (menu clicks)\nwindow.addEventListener('pointerdown', () => { audio.start(); audioKicked = true; }, { once: false });\n\n// tiny debug handle (console-only; the game never reads it)\nwindow.__kiln = {\n  get mode() { return mode; },\n  get levelIdx() { return levelIdx; },\n  get player() { return player; },\n  get world() { return world; },\n  get progress() { return progress; },\n  get boss() { return boss; },\n  get crabs() { return crabs; },\n  LEVELS,\n  start(i) { loadLevel(i); mode = 'playing'; ui.hideAll(); },\n  tp(x, y, z) { player.teleport(x, y, z); gameCam.snap(x, y, z); },\n};\n\n// ---------- boot ----------\ngoToTitle();\nrequestAnimationFrame(frame);\n"},{"name":"palette.js","content":"// KILN — the whole game uses exactly these 8 colours.\n// SOOT       #1a1210  — kiln blacks, deep shadow, background\n// ASH        #8a7f76  — ash grey, dust, worn stone shelves\n// TERRA      #c96f3b  — fired terracotta, the hero clay colour\n// TERRA_DEEP #8e4426  — shadowed terracotta, cracks, boss body\n// GLAZE      #3d7dc4  — glaze blue, slippery surfaces, drops\n// GLAZE_PALE #a8c8e8  — pale glaze highlight, specular catch\n// EMBER      #e8a13a  — ember glow, lanterns, heat, lava\n// BONE       #e8ded0  — bone white, clawd's eyes & belly, UI text\n\nexport const SOOT = 0x1a1210;\nexport const ASH = 0x8a7f76;\nexport const TERRA = 0xc96f3b;\nexport const TERRA_DEEP = 0x8e4426;\nexport const GLAZE = 0x3d7dc4;\nexport const GLAZE_PALE = 0xa8c8e8;\nexport const EMBER = 0xe8a13a;\nexport const BONE = 0xe8ded0;\n\n// css string forms for 2d-canvas HUD drawing\nexport const CSS = {\n  SOOT: '#1a1210',\n  ASH: '#8a7f76',\n  TERRA: '#c96f3b',\n  TERRA_DEEP: '#8e4426',\n  GLAZE: '#3d7dc4',\n  GLAZE_PALE: '#a8c8e8',\n  EMBER: '#e8a13a',\n  BONE: '#e8ded0',\n};\n\n// material keys used by level data / voxel builder\nexport const MAT = {\n  TERRA: 'terra',       // matte terracotta — default ground\n  TERRA_DEEP: 'terraDeep', // darker matte — walls, undersides\n  ASH: 'ash',           // matte grey — shelves, racks\n  GLAZE: 'glaze',       // shiny blue — slippery\n  GLAZE_PALE: 'glazePale', // shiny pale — accents\n  BONE: 'bone',         // matte white — golem, trim\n  EMBER: 'ember',       // emissive — lanterns, hazards\n  SOOT: 'soot',         // near-black matte\n};\n"},{"name":"physics.js","content":"// KILN — axis-separated AABB physics against the voxel grid,\n// plus dynamic colliders (moving platforms, crumbling shelves).\n// Bodies are plain objects; pos is the FEET-CENTRE of the body.\n\nconst EPS = 0.001;\n\nexport const GRAVITY = -34;\n\nexport function createWorld(field) {\n  return {\n    field,          // main static voxel field (voxel size 1, integer coords)\n    extraFields: [],// [{field, enabled:() => bool}] — crumble chunks etc.\n    movers: [],     // [{pos:[x,y,z], size:[w,h,d], vel:[x,y,z], mat}]\n    killY: -12,\n  };\n}\n\nexport function makeBody(x, y, z, { halfW = 0.34, height = 1.05 } = {}) {\n  return {\n    pos: { x, y, z },\n    vel: { x: 0, y: 0, z: 0 },\n    halfW,\n    height,\n    onGround: false,\n    groundMat: null,\n    groundMover: null,\n    hitCeiling: false,\n    hitWall: false,\n  };\n}\n\nfunction aabbOf(body) {\n  return {\n    min: [body.pos.x - body.halfW, body.pos.y, body.pos.z - body.halfW],\n    max: [body.pos.x + body.halfW, body.pos.y + body.height, body.pos.z + body.halfW],\n  };\n}\n\nfunction overlaps(a, b) {\n  return (\n    a.min[0] < b.max[0] - EPS && a.max[0] > b.min[0] + EPS &&\n    a.min[1] < b.max[1] - EPS && a.max[1] > b.min[1] + EPS &&\n    a.min[2] < b.max[2] - EPS && a.max[2] > b.min[2] + EPS\n  );\n}\n\n// collect all solid boxes intersecting the given range\nfunction collectBoxes(world, range, out) {\n  out.length = 0;\n  const x0 = Math.floor(range.min[0]), x1 = Math.floor(range.max[0]);\n  const y0 = Math.floor(range.min[1]), y1 = Math.floor(range.max[1]);\n  const z0 = Math.floor(range.min[2]), z1 = Math.floor(range.max[2]);\n  for (let x = x0; x <= x1; x++) {\n    for (let y = y0; y <= y1; y++) {\n      for (let z = z0; z <= z1; z++) {\n        const m = world.field.get(x, y, z);\n        if (m !== undefined) {\n          out.push({ min: [x, y, z], max: [x + 1, y + 1, z + 1], mat: m, mover: null });\n          continue;\n        }\n        for (const ef of world.extraFields) {\n          if (!ef.enabled()) continue;\n          const em = ef.field.get(x, y, z);\n          if (em !== undefined) {\n            out.push({ min: [x, y, z], max: [x + 1, y + 1, z + 1], mat: em, mover: null, source: ef });\n            break;\n          }\n        }\n      }\n    }\n  }\n  for (const mv of world.movers) {\n    if (mv.disabled) continue;\n    const box = {\n      min: [mv.pos[0], mv.pos[1], mv.pos[2]],\n      max: [mv.pos[0] + mv.size[0], mv.pos[1] + mv.size[1], mv.pos[2] + mv.size[2]],\n      mat: mv.mat,\n      mover: mv,\n    };\n    if (overlaps(box, range)) out.push(box);\n  }\n  return out;\n}\n\nconst scratch = [];\n\n// Move a body by vel*dt with axis-separated collision. Sets onGround,\n// groundMat, groundMover, hitCeiling, hitWall, touched (list of boxes stood on / bumped).\nexport function moveBody(world, body, dt) {\n  body.onGround = false;\n  body.groundMat = null;\n  body.groundMover = null;\n  body.hitCeiling = false;\n  body.hitWall = false;\n  body.touched = body.touched || [];\n  body.touched.length = 0;\n\n  const dx = body.vel.x * dt;\n  const dy = body.vel.y * dt;\n  const dz = body.vel.z * dt;\n\n  // --- X ---\n  if (dx !== 0) {\n    body.pos.x += dx;\n    const bb = aabbOf(body);\n    collectBoxes(world, bb, scratch);\n    for (const box of scratch) {\n      if (!overlaps(bb, box)) continue;\n      if (dx > 0) body.pos.x = box.min[0] - body.halfW - EPS;\n      else body.pos.x = box.max[0] + body.halfW + EPS;\n      body.vel.x = 0;\n      body.hitWall = true;\n      const nb = aabbOf(body);\n      bb.min[0] = nb.min[0]; bb.max[0] = nb.max[0];\n    }\n  }\n\n  // --- Z ---\n  if (dz !== 0) {\n    body.pos.z += dz;\n    const bb = aabbOf(body);\n    collectBoxes(world, bb, scratch);\n    for (const box of scratch) {\n      if (!overlaps(bb, box)) continue;\n      if (dz > 0) body.pos.z = box.min[2] - body.halfW - EPS;\n      else body.pos.z = box.max[2] + body.halfW + EPS;\n      body.vel.z = 0;\n      body.hitWall = true;\n      const nb = aabbOf(body);\n      bb.min[2] = nb.min[2]; bb.max[2] = nb.max[2];\n    }\n  }\n\n  // --- Y ---\n  body.pos.y += dy;\n  {\n    const bb = aabbOf(body);\n    collectBoxes(world, bb, scratch);\n    for (const box of scratch) {\n      if (!overlaps(bb, box)) continue;\n      if (dy <= 0) {\n        body.pos.y = box.max[1];\n        body.vel.y = 0;\n        body.onGround = true;\n        body.groundMat = box.mat;\n        body.groundMover = box.mover;\n        if (box.source) body.touched.push(box.source);\n      } else {\n        body.pos.y = box.min[1] - body.height - EPS;\n        body.vel.y = 0;\n        body.hitCeiling = true;\n      }\n      const nb = aabbOf(body);\n      bb.min[1] = nb.min[1]; bb.max[1] = nb.max[1];\n    }\n  }\n\n  // when standing still (dy could be 0 on a mover), re-check ground just below feet\n  if (!body.onGround && body.vel.y === 0) {\n    const probe = {\n      min: [body.pos.x - body.halfW, body.pos.y - 0.08, body.pos.z - body.halfW],\n      max: [body.pos.x + body.halfW, body.pos.y + 0.02, body.pos.z + body.halfW],\n    };\n    collectBoxes(world, probe, scratch);\n    for (const box of scratch) {\n      if (!overlaps(probe, box)) continue;\n      if (box.max[1] <= body.pos.y + 0.06) {\n        body.onGround = true;\n        body.groundMat = box.mat;\n        body.groundMover = box.mover;\n        if (box.source) body.touched.push(box.source);\n        break;\n      }\n    }\n  }\n}\n\n// simple point-in-box helpers for triggers (drops, checkpoints, wind, goals)\nexport function pointInBox(px, py, pz, box) {\n  return (\n    px >= box[0] && px <= box[0] + box[3] &&\n    py >= box[1] && py <= box[1] + box[4] &&\n    pz >= box[2] && pz <= box[2] + box[5]\n  );\n}\n\nexport function distSq(ax, ay, az, bx, by, bz) {\n  const dx = ax - bx, dy = ay - by, dz = az - bz;\n  return dx * dx + dy * dy + dz * dz;\n}\n"},{"name":"player.js","content":"// KILN — Clawd, Anthropic's little crab, lost in a cold dark kiln.\n// The player is a voxel Clawd: a rounded orange crab body, two big\n// pincers, white eyes with dark pupils perched on top, a pale belly,\n// and two stubby legs. Movement feel lives here and is tuned:\n// variable jump height, 100ms coyote time, 120ms jump buffering,\n// landing squash, air control, glaze slip. These numbers ARE the game.\n\nimport * as THREE from 'three';\nimport { MAT } from './palette.js';\nimport { buildModelMesh, makeDebrisVoxel } from './voxels.js';\nimport { makeBody, moveBody, GRAVITY } from './physics.js';\n\n// ---------- tuning ----------\nconst MAX_SPEED = 7.2;\nconst ACCEL = 64;\nconst AIR_ACCEL = 34;\nconst FRICTION = 52;\nconst GLAZE_FRICTION = 5;      // slippery glaze — you keep sliding\nconst GLAZE_ACCEL = 22;\nconst JUMP_VEL = 13.2;  // max jump height ≈ 2.56 voxels — rise-2 hops have air, rise-3 never\nconst JUMP_CUT = 0.42;          // release jump early -> cut rise (variable height)\nconst COYOTE = 0.10;            // seconds of grace after walking off an edge\nconst BUFFER = 0.12;            // seconds a jump press is remembered before landing\nconst MAX_FALL = -26;\nconst HURT_INVULN = 1.1;\nconst VOX = 0.13;               // clawd voxel size (11 wide x 8 tall x 4 deep)\n\n// ---------- Clawd, 11 wide x 8 tall x 4 deep ----------\n// Wider than tall, like the plush: dome-shaped orange body, pale\n// belly on the front, eye bumps with soot pupils, and two chunky\n// open pincers at the sides. The +z face is the front (the paper-doll\n// rig keeps it turned to the camera). The two legs are rigged\n// separately so they can swing when Clawd scuttles.\nfunction clawdVoxels(crackLevel) {\n  const v = [];\n  const put = (x, y, z, m) => v.push([x, y, z, m]);\n\n  // body dome (x2..8, z1..3), narrowing toward the top\n  for (const z of [1, 2, 3]) {\n    for (let x = 2; x <= 8; x++) for (let y = 2; y <= 4; y++) put(x, y, z, MAT.TERRA);\n    for (let x = 3; x <= 7; x++) put(x, 5, z, MAT.TERRA);\n    for (let x = 4; x <= 6; x++) put(x, 6, z, MAT.TERRA);\n  }\n  // pale belly on the lower front\n  for (let x = 3; x <= 7; x++) for (const y of [2, 3]) put(x, y, 3, MAT.BONE);\n\n  // eyes: white bumps on top with soot pupils facing forward\n  put(4, 7, 2, MAT.BONE); put(4, 7, 3, MAT.SOOT);\n  put(6, 7, 2, MAT.BONE); put(6, 7, 3, MAT.SOOT);\n  // a little mouth\n  put(5, 4, 3, MAT.SOOT);\n\n  // pincers: hinge at the body, open jaws facing outward\n  for (const z of [1, 2]) {\n    // left claw (x0..1)\n    put(1, 4, z, MAT.TERRA);                          // hinge\n    put(0, 5, z, MAT.TERRA); put(1, 5, z, MAT.TERRA); // upper jaw\n    put(0, 3, z, MAT.TERRA); put(1, 3, z, MAT.TERRA); // lower jaw\n    // right claw (x9..10)\n    put(9, 4, z, MAT.TERRA);\n    put(9, 5, z, MAT.TERRA); put(10, 5, z, MAT.TERRA);\n    put(9, 3, z, MAT.TERRA); put(10, 3, z, MAT.TERRA);\n  }\n\n  // legs (y 0-1) — rigged separately at runtime, included here for debris\n  for (const xs of [[3, 4], [6, 7]])\n    for (const x of xs)\n      for (const z of [1, 2]) {\n        put(x, 1, z, MAT.TERRA);\n        put(x, 0, z, MAT.TERRA_DEEP);\n      }\n\n  // cracks: shell voxels char to soot as Clawd takes damage (diegetic health)\n  const CRACKS = [\n    [[3, 5, 3], [4, 5, 3], [2, 4, 3], [8, 3, 1], [7, 2, 1]],                       // 1 hit\n    [[6, 5, 3], [5, 6, 3], [4, 6, 1], [2, 3, 3], [8, 4, 1], [8, 2, 3]],            // 2 hits\n  ];\n  for (let c = 0; c < crackLevel && c < CRACKS.length; c++) {\n    for (const [cx, cy, cz] of CRACKS[c]) {\n      const i = v.findIndex((e) => e[0] === cx && e[1] === cy && e[2] === cz);\n      if (i >= 0) v[i][3] = MAT.SOOT;\n    }\n  }\n  return v;\n}\n\nexport function createPlayer(scene, audio) {\n  const body = makeBody(0, 2, 0, { halfW: 0.32, height: 1.02 });\n\n  // Clawd is rigged in three parts so he can actually SCUTTLE:\n  // a body (three damage-state meshes, swapped on hit) and two legs\n  // that swing from the hip. Squash/stretch wraps the whole rig.\n  const bodyStates = [0, 1, 2].map((lvl) => {\n    const g = buildModelMesh(clawdVoxels(lvl).filter((vx) => vx[1] >= 2), { voxelSize: VOX });\n    g.visible = lvl === 0;\n    return g;\n  });\n  const bodyGroup = new THREE.Group(); // sits on top of the legs\n  bodyGroup.position.y = 2 * VOX;\n  for (const m of bodyStates) bodyGroup.add(m);\n\n  function makeLeg(offX) {\n    const legVox = [];\n    for (const x of [0, 1]) for (const z of [0, 1]) {\n      legVox.push([x, 1, z, MAT.TERRA]);\n      legVox.push([x, 0, z, MAT.TERRA_DEEP]); // little dark feet\n    }\n    const pivot = new THREE.Group();         // hip joint\n    pivot.position.set(offX, 2 * VOX, 0);\n    const mesh = buildModelMesh(legVox, { voxelSize: VOX });\n    mesh.position.y = -2 * VOX;              // leg hangs down from the hip\n    pivot.add(mesh);\n    return pivot;\n  }\n  const legL = makeLeg(-1.5 * VOX);\n  const legR = makeLeg(1.5 * VOX);\n\n  const root = new THREE.Group();      // world position\n  const rig = new THREE.Group();       // facing rotation\n  const squashRig = new THREE.Group(); // squash & stretch, anchored at feet\n  squashRig.add(bodyGroup, legL, legR);\n  rig.add(squashRig);\n  root.add(rig);\n  scene.add(root);\n\n  const debris = []; // active dissolve/shatter voxels\n\n  const p = {\n    body,\n    root,\n    cracks: 0,          // 0..2 visible; 3rd hit shatters\n    state: 'alive',     // alive | dead | frozen\n    invuln: 0,\n    facing: 0,          // yaw radians\n    coyote: 0,\n    buffer: 0,\n    jumpHeld: false,\n    jumpCutDone: false,\n    squash: 1,\n    squashVel: 0,\n    walkPhase: 0,\n    time: 0,\n    deadTimer: 0,\n    onLanded: null,     // cb(impactSpeed)\n    frozen: false,\n  };\n\n  function setCrackMesh() {\n    for (let i = 0; i < 3; i++) bodyStates[i].visible = i === Math.min(p.cracks, 2);\n  }\n\n  p.teleport = (x, y, z) => {\n    body.pos.x = x; body.pos.y = y; body.pos.z = z;\n    body.vel.x = 0; body.vel.y = 0; body.vel.z = 0;\n    root.position.set(x, y, z);\n  };\n\n  p.heal = () => { p.cracks = 0; setCrackMesh(); };\n\n  // hit: returns true if this hit shattered the golem\n  p.hit = () => {\n    if (p.invuln > 0 || p.state !== 'alive') return false;\n    p.cracks++;\n    p.invuln = HURT_INVULN;\n    audio.crack();\n    if (p.cracks >= 3) { p.shatter(); return true; }\n    setCrackMesh();\n    return false;\n  };\n\n  p.shatter = () => {\n    if (p.state === 'dead') return;\n    p.state = 'dead';\n    p.deadTimer = 0;\n    rig.visible = false;\n    audio.shatter();\n    // burst into physics voxels\n    const vox = clawdVoxels(Math.min(p.cracks, 2));\n    for (let i = 0; i < vox.length; i += 2) { // every other voxel — enough debris\n      const [vx, vy, vz, m] = vox[i];\n      const d = makeDebrisVoxel(m, VOX);\n      d.position.set(\n        body.pos.x + (vx - 5.5) * VOX,\n        body.pos.y + vy * VOX,\n        body.pos.z + (vz - 2.5) * VOX\n      );\n      d.userData.vel = new THREE.Vector3(\n        (Math.random() - 0.5) * 6, Math.random() * 7 + 2, (Math.random() - 0.5) * 6\n      );\n      d.userData.life = 1.4;\n      scene.add(d);\n      debris.push(d);\n    }\n  };\n\n  // instant respawn: the spark's voxels re-gather at the checkpoint\n  p.respawn = (x, y, z) => {\n    p.teleport(x, y, z);\n    p.cracks = 0;\n    setCrackMesh();\n    p.state = 'alive';\n    p.invuln = 0.6;\n    rig.visible = true;\n    audio.reassemble();\n    // converging voxels effect\n    for (let i = 0; i < 26; i++) {\n      const d = makeDebrisVoxel(i % 3 === 0 ? MAT.EMBER : MAT.TERRA, VOX);\n      const ang = Math.random() * Math.PI * 2;\n      const r = 1.6 + Math.random() * 1.2;\n      d.position.set(x + Math.cos(ang) * r, y + Math.random() * 2.2, z + Math.sin(ang) * r);\n      d.userData.target = new THREE.Vector3(x, y + 0.5 + Math.random() * 0.5, z);\n      d.userData.life = 0.45 + Math.random() * 0.2;\n      d.userData.converge = true;\n      scene.add(d);\n      debris.push(d);\n    }\n  };\n\n  p.update = (dt, input, world, mods) => {\n    p.time += dt;\n    if (p.invuln > 0) p.invuln -= dt;\n\n    // debris always animates\n    for (let i = debris.length - 1; i >= 0; i--) {\n      const d = debris[i];\n      d.userData.life -= dt;\n      if (d.userData.converge) {\n        d.position.lerp(d.userData.target, Math.min(1, dt * 9));\n      } else {\n        d.userData.vel.y += GRAVITY * 0.6 * dt;\n        d.position.addScaledVector(d.userData.vel, dt);\n        d.rotation.x += dt * 5; d.rotation.z += dt * 4;\n      }\n      d.material.opacity = Math.max(0, Math.min(1, d.userData.life * 2));\n      if (d.userData.life <= 0) {\n        scene.remove(d);\n        d.material.dispose();\n        debris.splice(i, 1);\n      }\n    }\n\n    if (p.state !== 'alive' || p.frozen) {\n      if (p.state === 'dead') p.deadTimer += dt;\n      return;\n    }\n\n    const wasGround = body.onGround;\n    const prevVy = body.vel.y;\n\n    // ---- horizontal input (camera-relative) ----\n    const mx = input.moveX, mz = input.moveZ;\n    const moving = Math.abs(mx) > 0.01 || Math.abs(mz) > 0.01;\n    const onGlaze = body.groundMat === MAT.GLAZE || body.groundMat === MAT.GLAZE_PALE;\n    const accel = body.onGround ? (onGlaze ? GLAZE_ACCEL : ACCEL) : AIR_ACCEL;\n    const fric = body.onGround ? (onGlaze ? GLAZE_FRICTION : FRICTION) : 2;\n\n    if (moving) {\n      // input can never push speed past MAX_SPEED, but it never erases\n      // overspeed either (wind gusts / mover fling decay separately below)\n      const prevH = Math.hypot(body.vel.x, body.vel.z);\n      body.vel.x += mx * accel * dt;\n      body.vel.z += mz * accel * dt;\n      const cap = Math.max(MAX_SPEED, prevH);\n      const hsp = Math.hypot(body.vel.x, body.vel.z);\n      if (hsp > cap) {\n        const s = cap / hsp;\n        body.vel.x *= s; body.vel.z *= s;\n      }\n      p.facing = Math.atan2(mx, mz);\n    } else {\n      const sp = Math.hypot(body.vel.x, body.vel.z);\n      if (sp > 0) {\n        const drop = Math.min(sp, fric * dt);\n        const s = (sp - drop) / sp;\n        body.vel.x *= s; body.vel.z *= s;\n      }\n    }\n\n    // wind gusts (The Bellows)\n    body.vel.x += (mods.windX || 0) * dt;\n    body.vel.z += (mods.windZ || 0) * dt;\n\n    // overspeed decays: fast on the ground, slowly in the air\n    {\n      const hsp = Math.hypot(body.vel.x, body.vel.z);\n      if (hsp > MAX_SPEED) {\n        const decay = body.onGround ? 30 : 4;\n        const s = Math.max(MAX_SPEED, hsp - decay * dt) / hsp;\n        body.vel.x *= s; body.vel.z *= s;\n      }\n    }\n\n    // ---- jumping: coyote + buffer + variable height ----\n    p.coyote = body.onGround ? COYOTE : Math.max(0, p.coyote - dt);\n    p.buffer = input.jumpPressed ? BUFFER : Math.max(0, p.buffer - dt);\n\n    if (p.buffer > 0 && p.coyote > 0) {\n      body.vel.y = JUMP_VEL;\n      p.coyote = 0;\n      p.buffer = 0;\n      p.jumpCutDone = false;\n      p.squashVel = 14; // stretch up\n      audio.jump();\n      // jumping off a mover inherits a bit of its velocity\n      if (body.groundMover) {\n        body.vel.x += body.groundMover.vel[0] * 0.9;\n        body.vel.z += body.groundMover.vel[2] * 0.9;\n        if (body.groundMover.vel[1] > 0) body.vel.y += body.groundMover.vel[1] * 0.6;\n      }\n    }\n    if (!input.jumpHeld && body.vel.y > 0 && !p.jumpCutDone) {\n      body.vel.y *= JUMP_CUT;\n      p.jumpCutDone = true;\n    }\n\n    // gravity\n    body.vel.y += GRAVITY * dt;\n    if (body.vel.y < MAX_FALL) body.vel.y = MAX_FALL;\n\n    // ride mover\n    if (body.groundMover) {\n      body.pos.x += body.groundMover.vel[0] * dt;\n      body.pos.y += body.groundMover.vel[1] * dt;\n      body.pos.z += body.groundMover.vel[2] * dt;\n    }\n\n    moveBody(world, body, dt);\n\n    // landing\n    if (!wasGround && body.onGround) {\n      const impact = -prevVy;\n      p.squashVel = -Math.min(18, impact * 1.4); // squash down\n      audio.land(impact);\n      if (p.onLanded) p.onLanded(impact);\n    }\n\n    // ---- squash & stretch spring ----\n    p.squashVel += (1 - p.squash) * 180 * dt;\n    p.squashVel *= Math.exp(-10 * dt);\n    p.squash += p.squashVel * dt * 0.06;\n    p.squash = Math.max(0.55, Math.min(1.45, p.squash));\n\n    // idle breathing sway\n    const breathe = body.onGround && !moving ? Math.sin(p.time * 2.1) * 0.02 : 0;\n    const sy = p.squash + breathe;\n    const sxz = 1 + (1 - sy) * 0.7;\n    squashRig.scale.set(sxz, sy, sxz);\n\n    // ---- scuttle cycle: Clawd keeps his face and pincers to the\n    // camera (paper-doll style, crabs walk sideways anyway). Legs swing\n    // ALONG the direction of travel, the body leans into the run, and\n    // a gentle yaw tilt gives him 3D presence without hiding the face.\n    const hspeed = Math.hypot(body.vel.x, body.vel.z);\n    let dx = 0, dz = 0; // travel direction, unit\n    if (hspeed > 0.3) { dx = body.vel.x / hspeed; dz = body.vel.z / hspeed; }\n\n    if (body.onGround && moving && hspeed > 0.4) {\n      p.walkPhase += dt * (7 + hspeed * 1.8);\n      const s = Math.sin(p.walkPhase);\n      legL.rotation.x = s * 0.85 * dz;\n      legL.rotation.z = s * 0.85 * dx;\n      legR.rotation.x = -s * 0.85 * dz;\n      legR.rotation.z = -s * 0.85 * dx;\n      bodyGroup.position.y = 2 * VOX + Math.abs(Math.cos(p.walkPhase)) * 0.05;\n      const lean = Math.min(0.15, hspeed * 0.02);\n      bodyGroup.rotation.z = -dx * lean + s * 0.04;           // lean + waddle\n      bodyGroup.rotation.x = dz * lean;\n    } else if (!body.onGround) {\n      // airborne pose: legs trail behind the jump; pitch follows the arc\n      const f = Math.min(1, dt * 9);\n      legL.rotation.x += (-0.5 * dz - legL.rotation.x) * f;\n      legL.rotation.z += (-0.5 * dx - legL.rotation.z) * f;\n      legR.rotation.x += (0.65 * dz - legR.rotation.x) * f;\n      legR.rotation.z += (0.65 * dx - legR.rotation.z) * f;\n      const arc = Math.max(-0.16, Math.min(0.22, -body.vel.y * 0.014));\n      bodyGroup.rotation.x += (arc * dz - bodyGroup.rotation.x) * f;\n      bodyGroup.rotation.z += (-arc * dx - bodyGroup.rotation.z) * f;\n    } else {\n      // settle back to standing\n      const f = Math.min(1, dt * 12);\n      legL.rotation.x *= 1 - f; legL.rotation.z *= 1 - f;\n      legR.rotation.x *= 1 - f; legR.rotation.z *= 1 - f;\n      bodyGroup.rotation.x *= 1 - f;\n      bodyGroup.rotation.z *= 1 - f;\n      bodyGroup.position.y += (2 * VOX - bodyGroup.position.y) * f;\n    }\n\n    // gentle yaw toward the run — never more than a tilt, so the\n    // spark's face stays turned to the camera like the 2D mark\n    const targetYaw = dx * 0.38;\n    rig.rotation.y += (targetYaw - rig.rotation.y) * Math.min(1, dt * 8);\n\n    // hurt flicker\n    rig.visible = p.invuln <= 0 || Math.floor(p.time * 14) % 2 === 0;\n\n    root.position.set(body.pos.x, body.pos.y, body.pos.z);\n  };\n\n  return p;\n}\n"},{"name":"save.js","content":"// KILN — progress serialisation. A plain state object round-trips through\n// a copyable save code (no localStorage). Format: KILN1.<base64 json>.<checksum>\n\nexport function freshProgress() {\n  return {\n    unlocked: 1,                       // highest level reachable (1..8)\n    completed: [false, false, false, false, false, false, false, false],\n    // drops[levelIndex][dropIndex] — found flags, 5 per level\n    drops: Array.from({ length: 8 }, () => [false, false, false, false, false]),\n    bossDown: false,\n    settings: { volume: 0.8, camSens: 1.0, screenShake: true },\n  };\n}\n\nexport function totalDrops(progress, mainOnly) {\n  let n = 0;\n  const upto = mainOnly ? 7 : 8;\n  for (let l = 0; l < upto; l++)\n    for (const f of progress.drops[l]) if (f) n++;\n  return n;\n}\n\nexport function completionPercent(progress) {\n  let done = 0, total = 7 + 35; // 7 levels + 35 main drops\n  for (let i = 0; i < 7; i++) if (progress.completed[i]) done++;\n  done += totalDrops(progress, true);\n  return Math.round((done / total) * 100);\n}\n\nfunction checksum(str) {\n  let h = 5381;\n  for (let i = 0; i < str.length; i++) h = ((h << 5) + h + str.charCodeAt(i)) >>> 0;\n  return h.toString(36);\n}\n\nexport function encodeSave(progress) {\n  const json = JSON.stringify(progress);\n  const b64 = btoa(unescape(encodeURIComponent(json)));\n  return 'KILN1.' + b64 + '.' + checksum(b64);\n}\n\n// returns {ok, progress?, error?}\nexport function decodeSave(code) {\n  try {\n    const parts = String(code).trim().split('.');\n    if (parts.length !== 3 || parts[0] !== 'KILN1') {\n      return { ok: false, error: 'Not a KILN save code.' };\n    }\n    if (checksum(parts[1]) !== parts[2]) {\n      return { ok: false, error: 'Save code is damaged (checksum mismatch).' };\n    }\n    const raw = JSON.parse(decodeURIComponent(escape(atob(parts[1]))));\n    // merge over a fresh object so missing fields never crash the game\n    const base = freshProgress();\n    const p = {\n      unlocked: Math.max(1, Math.min(8, raw.unlocked | 0)),\n      completed: base.completed.map((_, i) => !!(raw.completed && raw.completed[i])),\n      drops: base.drops.map((row, l) =>\n        row.map((_, d) => !!(raw.drops && raw.drops[l] && raw.drops[l][d]))\n      ),\n      bossDown: !!raw.bossDown,\n      settings: {\n        volume: typeof raw.settings?.volume === 'number' ? Math.max(0, Math.min(1, raw.settings.volume)) : base.settings.volume,\n        camSens: typeof raw.settings?.camSens === 'number' ? Math.max(0.4, Math.min(2, raw.settings.camSens)) : base.settings.camSens,\n        screenShake: raw.settings ? !!raw.settings.screenShake : true,\n      },\n    };\n    return { ok: true, progress: p };\n  } catch (e) {\n    return { ok: false, error: 'Could not read that code.' };\n  }\n}\n"},{"name":"ui.js","content":"// KILN — menus, HUD, toasts, fades. All DOM, all diegetic where possible:\n// health is cracks on the golem itself; drops fill a voxel pot icon.\n\nimport { CSS } from './palette.js';\nimport { totalDrops, completionPercent } from './save.js';\n\nconst LEVEL_NAMES = [\n  'The Cooling Racks', 'Glaze Falls', 'The Bellows', 'Cracked Shelves',\n  'The Firing Chamber', 'Shard Hollow', 'The Kilnheart', \"The Potter's Wheel\",\n];\n\nfunction el(tag, cls, text) {\n  const e = document.createElement(tag);\n  if (cls) e.className = cls;\n  if (text !== undefined) e.textContent = text;\n  return e;\n}\n\nexport function createUI(rootEl, deps) {\n  // deps: { audio, getProgress, onStartLevel, onResume, onRestart, onQuit,\n  //         onSettingsChanged, getSaveCode, loadSaveCode }\n  const root = rootEl;\n\n  // ---------- fader ----------\n  const fader = el('div');\n  fader.id = 'fader';\n  root.appendChild(fader);\n\n  // ---------- HUD ----------\n  const hud = el('div');\n  hud.id = 'hud';\n  hud.hidden = true;\n  root.appendChild(hud);\n\n  const levelName = el('div', 'hud-level-name', '');\n  hud.appendChild(levelName);\n\n  const potWrap = el('div', 'hud-pot');\n  const potRow = el('div', 'hud-pot__row');\n  const potCanvas = document.createElement('canvas');\n  potCanvas.width = 52; potCanvas.height = 50;\n  const potCount = el('div', 'hud-pot__count', '0 / 5');\n  potRow.appendChild(potCanvas);\n  potRow.appendChild(potCount);\n  potWrap.appendChild(potRow);\n  potWrap.appendChild(el('div', 'hud-pot__label', 'glaze drops'));\n  hud.appendChild(potWrap);\n\n  const glyphs = el('div', 'hud-glyph', '');\n  hud.appendChild(glyphs);\n\n  const bossBar = el('div', 'boss-bar');\n  bossBar.hidden = true;\n  const bossLabel = el('div', 'boss-bar__label', 'The Kilnheart');\n  const bossTrack = el('div', 'boss-bar__track');\n  const bossFill = el('div', 'boss-bar__fill');\n  bossTrack.appendChild(bossFill);\n  bossBar.appendChild(bossLabel);\n  bossBar.appendChild(bossTrack);\n  hud.appendChild(bossBar);\n\n  const toastEl = el('div', 'toast', '');\n  hud.appendChild(toastEl);\n  let toastTimer = null;\n\n  // a little voxel amphora (a tiny kilnheart!) that fills with blue\n  // glaze as drops are found — clearly a pot: flared rim, neck,\n  // two handles, round belly, foot.\n  function drawPot(found, total) {\n    const c = potCanvas.getContext('2d');\n    const P = 4; // \"voxel\" pixel size\n    c.clearRect(0, 0, potCanvas.width, potCanvas.height);\n    // rows: {x, w, kind} in voxel units, 13 cols x 12 rows\n    const rows = [\n      { x: 3, w: 7, kind: 'rim' },              // flared rim\n      { x: 5, w: 3, kind: 'fill' },             // neck\n      { x: 5, w: 3, kind: 'fill', handle: true },\n      { x: 4, w: 5, kind: 'fill', handle: true },\n      { x: 3, w: 7, kind: 'fill', handle: true },\n      { x: 2, w: 9, kind: 'fill' },             // belly\n      { x: 2, w: 9, kind: 'fill' },\n      { x: 2, w: 9, kind: 'fill' },\n      { x: 3, w: 7, kind: 'fill' },\n      { x: 3, w: 7, kind: 'fill' },\n      { x: 4, w: 5, kind: 'fill' },             // taper\n      { x: 3, w: 7, kind: 'foot' },             // foot\n    ];\n    const fillable = rows.filter((r) => r.kind === 'fill').length; // 10\n    const fillCount = Math.round((found / total) * fillable);\n    let fillIdx = 0; // counts fill rows from the top\n    rows.forEach((row, r) => {\n      let color = CSS.TERRA; // rim & foot: fired clay\n      if (row.kind === 'fill') {\n        fillIdx++;\n        const fromBottom = fillable - fillIdx + 1;\n        color = fromBottom <= fillCount ? CSS.GLAZE : CSS.TERRA_DEEP;\n      }\n      for (let x = 0; x < row.w; x++) {\n        c.fillStyle = color;\n        c.fillRect((row.x + x) * P, r * P, P - 0.5, P - 0.5);\n      }\n      // amphora handles, one voxel out from each side\n      if (row.handle) {\n        c.fillStyle = CSS.TERRA;\n        c.fillRect(2 * P, r * P, P - 0.5, P - 0.5);\n        c.fillRect(10 * P, r * P, P - 0.5, P - 0.5);\n      }\n    });\n    // a pale glint on the topmost glaze row\n    if (fillCount > 0) {\n      const topFillRow = rows.findIndex((row, r) => {\n        if (row.kind !== 'fill') return false;\n        const idx = rows.slice(0, r + 1).filter((q) => q.kind === 'fill').length;\n        return fillable - idx + 1 === fillCount;\n      });\n      if (topFillRow >= 0) {\n        c.fillStyle = CSS.GLAZE_PALE;\n        c.fillRect((rows[topFillRow].x + 1) * P, topFillRow * P, P - 0.5, P - 0.5);\n      }\n    }\n  }\n  drawPot(0, 5);\n\n  let lastDropCount = 0;\n  let potPulseTimer = null;\n\n  // ---------- screens ----------\n  function makeScreen(dim) {\n    const s = el('div', 'screen' + (dim ? ' screen--dim' : ''));\n    s.hidden = true;\n    root.appendChild(s);\n    return s;\n  }\n\n  const titleScreen = makeScreen(false);\n  const levelScreen = makeScreen(true);\n  const settingsScreen = makeScreen(true);\n  const creditsScreen = makeScreen(true);\n  const pauseScreen = makeScreen(true);\n  const saveScreen = makeScreen(true);\n  const endingScreen = makeScreen(true);\n\n  const allScreens = [titleScreen, levelScreen, settingsScreen, creditsScreen, pauseScreen, saveScreen, endingScreen];\n  function showOnly(screen) {\n    for (const s of allScreens) s.hidden = s !== screen;\n    if (screen) {\n      const first = screen.querySelector('button:not(:disabled)');\n      if (first) first.focus();\n    }\n  }\n\n  function menuBtn(label, fn) {\n    const b = el('button', 'menu-btn', label);\n    b.addEventListener('click', () => { deps.audio.uiSelect(); fn(); });\n    b.addEventListener('mouseenter', () => deps.audio.uiMove());\n    return b;\n  }\n\n  // ---------- title ----------\n  titleScreen.appendChild(el('h1', 'kiln-title', 'KILN'));\n  titleScreen.appendChild(el('div', 'kiln-subtitle', 'clawd — a little crab in a cold dark kiln'));\n  const titleMenu = el('div', 'menu-list');\n  titleScreen.appendChild(titleMenu);\n  const beginBtn = menuBtn('Begin', () => deps.onStartLevel(nextLevelIndex()));\n  titleMenu.appendChild(beginBtn);\n  titleMenu.appendChild(menuBtn('Level Select', () => { rebuildLevelSelect('title'); showOnly(levelScreen); }));\n  titleMenu.appendChild(menuBtn('Save Code', () => openSave('title')));\n  titleMenu.appendChild(menuBtn('Settings', () => openSettings('title')));\n  titleMenu.appendChild(menuBtn('Credits', () => { showOnly(creditsScreen); }));\n\n  function nextLevelIndex() {\n    const p = deps.getProgress();\n    for (let i = 0; i < 7; i++) if (!p.completed[i]) return i;\n    return 6; // everything done — revisit the Kilnheart\n  }\n\n  // ---------- level select ----------\n  let levelBackTarget = 'title';\n  function rebuildLevelSelect(backTo) {\n    levelBackTarget = backTo;\n    levelScreen.innerHTML = '';\n    const p = deps.getProgress();\n    levelScreen.appendChild(el('h2', 'screen-heading', 'Levels'));\n    levelScreen.appendChild(el('div', 'kiln-subtitle', 'kiln complete: ' + completionPercent(p) + '%'));\n    const grid = el('div', 'level-grid');\n    levelScreen.appendChild(grid);\n    const drops35 = totalDrops(p, true);\n    for (let i = 0; i < 8; i++) {\n      const card = el('button', 'level-card');\n      const locked = i === 7 ? drops35 < 35 : i + 1 > p.unlocked;\n      card.disabled = locked;\n      card.appendChild(el('div', 'level-card__num', i === 7 ? 'Bonus' : 'Level ' + (i + 1)));\n      card.appendChild(el('div', 'level-card__name', locked && i === 7 ? '? ? ?' : LEVEL_NAMES[i]));\n      const meta = el('div', 'level-card__meta');\n      const found = p.drops[i].filter(Boolean).length;\n      const st = el('span', p.completed[i] ? 'done' : '', p.completed[i] ? 'fired ✓' : locked ? 'locked' : 'unfired');\n      const dr = el('span', found === 5 ? 'drops-full' : '', locked && i === 7 ? drops35 + '/35 drops' : found + '/5 drops');\n      meta.appendChild(st); meta.appendChild(dr);\n      card.appendChild(meta);\n      if (!locked) {\n        card.addEventListener('click', () => { deps.audio.uiSelect(); deps.onStartLevel(i); });\n        card.addEventListener('mouseenter', () => deps.audio.uiMove());\n      }\n      grid.appendChild(card);\n    }\n    const back = menuBtn('Back', () => backFromSub(levelBackTarget));\n    levelScreen.appendChild(back);\n  }\n\n  function backFromSub(target) {\n    if (target === 'pause') showOnly(pauseScreen);\n    else showOnly(titleScreen);\n  }\n\n  // ---------- settings ----------\n  let settingsBackTarget = 'title';\n  function openSettings(backTo) {\n    settingsBackTarget = backTo;\n    rebuildSettings();\n    showOnly(settingsScreen);\n  }\n  function rebuildSettings() {\n    settingsScreen.innerHTML = '';\n    const s = deps.getProgress().settings;\n    settingsScreen.appendChild(el('h2', 'screen-heading', 'Settings'));\n    const list = el('div', 'settings-list');\n    settingsScreen.appendChild(list);\n\n    const volRow = el('div', 'setting-row');\n    const volLabel = el('label', '', 'Volume');\n    volLabel.htmlFor = 'set-vol';\n    const vol = document.createElement('input');\n    vol.type = 'range'; vol.min = '0'; vol.max = '1'; vol.step = '0.05';\n    vol.value = String(s.volume);\n    vol.id = 'set-vol';\n    vol.addEventListener('input', () => { s.volume = parseFloat(vol.value); deps.onSettingsChanged(); });\n    volRow.appendChild(volLabel); volRow.appendChild(vol);\n    list.appendChild(volRow);\n\n    const sensRow = el('div', 'setting-row');\n    const sensLabel = el('label', '', 'Camera sensitivity');\n    sensLabel.htmlFor = 'set-sens';\n    const sens = document.createElement('input');\n    sens.type = 'range'; sens.min = '0.4'; sens.max = '2'; sens.step = '0.1';\n    sens.value = String(s.camSens);\n    sens.id = 'set-sens';\n    sens.addEventListener('input', () => { s.camSens = parseFloat(sens.value); deps.onSettingsChanged(); });\n    sensRow.appendChild(sensLabel); sensRow.appendChild(sens);\n    list.appendChild(sensRow);\n\n    const shakeRow = el('div', 'setting-row');\n    shakeRow.appendChild(el('label', '', 'Screen shake'));\n    const shakeBtn = el('button', 'toggle-btn' + (s.screenShake ? ' on' : ''), s.screenShake ? 'On' : 'Off');\n    shakeBtn.addEventListener('click', () => {\n      s.screenShake = !s.screenShake;\n      shakeBtn.textContent = s.screenShake ? 'On' : 'Off';\n      shakeBtn.classList.toggle('on', s.screenShake);\n      deps.audio.uiSelect();\n      deps.onSettingsChanged();\n    });\n    shakeRow.appendChild(shakeBtn);\n    list.appendChild(shakeRow);\n\n    settingsScreen.appendChild(menuBtn('Back', () => backFromSub(settingsBackTarget)));\n  }\n\n  // ---------- save code ----------\n  let saveBackTarget = 'title';\n  function openSave(backTo) {\n    saveBackTarget = backTo;\n    saveScreen.innerHTML = '';\n    saveScreen.appendChild(el('h2', 'screen-heading', 'Save Code'));\n    const box = el('div', 'save-box');\n    saveScreen.appendChild(box);\n\n    box.appendChild(el('div', 'save-hint', 'Your progress, as a code. Copy it somewhere safe.'));\n    const out = document.createElement('textarea');\n    out.className = 'save-code-area';\n    out.readOnly = true;\n    out.value = deps.getSaveCode();\n    out.addEventListener('focus', () => out.select());\n    box.appendChild(out);\n    const copyBtn = menuBtn('Copy code', () => {\n      out.select();\n      if (navigator.clipboard) navigator.clipboard.writeText(out.value);\n      feedback.textContent = 'Copied.';\n    });\n    box.appendChild(copyBtn);\n\n    box.appendChild(el('div', 'save-hint', 'Paste a code below to restore progress.'));\n    const inp = document.createElement('textarea');\n    inp.className = 'save-code-area';\n    inp.placeholder = 'KILN1.…';\n    box.appendChild(inp);\n    const feedback = el('div', 'save-feedback', '');\n    const loadBtn = menuBtn('Load code', () => {\n      const res = deps.loadSaveCode(inp.value);\n      feedback.textContent = res.ok ? 'Progress restored.' : res.error;\n      if (res.ok) out.value = deps.getSaveCode();\n    });\n    box.appendChild(loadBtn);\n    box.appendChild(feedback);\n\n    saveScreen.appendChild(menuBtn('Back', () => backFromSub(saveBackTarget)));\n    showOnly(saveScreen);\n  }\n\n  // ---------- credits ----------\n  function buildCredits(intoEl, isEnding) {\n    intoEl.innerHTML = '';\n    const roll = el('div', 'credits-roll');\n    if (isEnding) {\n      intoEl.appendChild(el('h2', 'screen-heading', 'Fired'));\n      roll.appendChild(el('p', '', 'The Kilnheart is broken. Heat floods the chamber —'));\n      roll.appendChild(el('p', '', 'and for the first time, the little crab is warm.'));\n      roll.appendChild(el('p', '', 'His shell glows. His pincers stop trembling.'));\n      roll.appendChild(el('p', '', 'Clawd scuttles out of the kiln, fired and unafraid.'));\n    } else {\n      intoEl.appendChild(el('h2', 'screen-heading', 'Credits'));\n    }\n    roll.appendChild(el('h3', '', 'Starring'));\n    roll.appendChild(el('p', '', \"clawd — anthropic's little crab, climbing the cold dark kiln\"));\n    roll.appendChild(el('h3', '', 'Made with'));\n    roll.appendChild(el('p', '', 'hand-placed voxels · three.js · the Web Audio API'));\n    roll.appendChild(el('h3', '', 'Palette'));\n    roll.appendChild(el('p', 'small', 'soot · ash · terracotta · deep terracotta · glaze · pale glaze · ember · bone'));\n    roll.appendChild(el('h3', '', 'For'));\n    roll.appendChild(el('p', '', 'everyone who checks behind the spawn point'));\n    intoEl.appendChild(roll);\n    intoEl.appendChild(menuBtn(isEnding ? 'Return to the kiln' : 'Back', () => {\n      deps.onQuit();\n    }));\n  }\n  buildCredits(creditsScreen, false);\n  // credits \"Back\" goes to title:\n  creditsScreen.lastChild.onclick = null;\n  creditsScreen.lastChild.addEventListener('click', () => showOnly(titleScreen));\n\n  // ---------- pause ----------\n  pauseScreen.appendChild(el('h2', 'screen-heading', 'Paused'));\n  const pauseMenu = el('div', 'menu-list');\n  pauseScreen.appendChild(pauseMenu);\n  pauseMenu.appendChild(menuBtn('Resume', () => deps.onResume()));\n  pauseMenu.appendChild(menuBtn('Restart Level', () => deps.onRestart()));\n  pauseMenu.appendChild(menuBtn('Save Code', () => openSave('pause')));\n  pauseMenu.appendChild(menuBtn('Settings', () => openSettings('pause')));\n  pauseMenu.appendChild(menuBtn('Quit to Title', () => deps.onQuit()));\n\n  // ---------- public api ----------\n  const ui = {\n    LEVEL_NAMES,\n\n    showTitle() { hud.hidden = true; showOnly(titleScreen); },\n    hideAll() { showOnly(null); },\n    showPause() { showOnly(pauseScreen); },\n    showLevelSelectFromPause() { rebuildLevelSelect('pause'); showOnly(levelScreen); },\n    showEnding() { buildCredits(endingScreen, true); showOnly(endingScreen); },\n\n    showHUD(name) {\n      hud.hidden = false;\n      levelName.textContent = name;\n      bossBar.hidden = true;\n      toastEl.classList.remove('show');\n    },\n    hideHUD() { hud.hidden = true; },\n\n    setDrops(found, total) {\n      potCount.textContent = found + ' / ' + total;\n      drawPot(found, total);\n    },\n\n    setGlyphs(g) {\n      glyphs.textContent = 'Move ' + g.move + '   ·   Jump ' + g.jump + '   ·   Pause ' + g.pause;\n    },\n\n    showBossBar(show) {\n      bossBar.hidden = !show;\n      if (show) bossFill.classList.remove('p2', 'p3');\n    },\n    setBossHp(frac) { bossFill.style.width = Math.max(0, frac * 100) + '%'; },\n    setBossPhase(ph) {\n      bossFill.classList.toggle('p2', ph === 2);\n      bossFill.classList.toggle('p3', ph === 3);\n    },\n\n    toast(msg, ms = 2200) {\n      toastEl.textContent = msg;\n      toastEl.classList.add('show');\n      clearTimeout(toastTimer);\n      toastTimer = setTimeout(() => toastEl.classList.remove('show'), ms);\n    },\n\n    fade(dark) { fader.classList.toggle('dark', dark); },\n\n    refreshTitle() {\n      beginBtn.textContent = deps.getProgress().completed.some(Boolean) ? 'Continue' : 'Begin';\n    },\n  };\n\n  return ui;\n}\n"},{"name":"voxels.js","content":"// KILN — voxel field storage, face-culled mesh building, baked vertex AO.\n// Every mesh in the game is built here: merged static level geometry\n// (one draw call per material) and small character models.\n\nimport * as THREE from 'three';\nimport { TERRA, TERRA_DEEP, ASH, GLAZE, GLAZE_PALE, EMBER, BONE, SOOT, MAT } from './palette.js';\n\n// ---------- materials (shared singletons) ----------\n\nconst MAT_DEFS = {\n  [MAT.TERRA]: { color: TERRA, shiny: false },\n  [MAT.TERRA_DEEP]: { color: TERRA_DEEP, shiny: false },\n  [MAT.ASH]: { color: ASH, shiny: false },\n  [MAT.GLAZE]: { color: GLAZE, shiny: true },\n  [MAT.GLAZE_PALE]: { color: GLAZE_PALE, shiny: true },\n  [MAT.BONE]: { color: BONE, shiny: false },\n  [MAT.EMBER]: { color: EMBER, shiny: false, emissive: true },\n  [MAT.SOOT]: { color: SOOT, shiny: false },\n};\n\nlet materialCache = null;\n\nexport function getMaterials() {\n  if (materialCache) return materialCache;\n  materialCache = {};\n  for (const key of Object.keys(MAT_DEFS)) {\n    const def = MAT_DEFS[key];\n    if (def.emissive) {\n      materialCache[key] = new THREE.MeshLambertMaterial({\n        vertexColors: true,\n        emissive: def.color,\n        emissiveIntensity: 0.85,\n      });\n    } else if (def.shiny) {\n      // glazed ceramic: tight specular highlight — this contrast IS the art style\n      materialCache[key] = new THREE.MeshPhongMaterial({\n        vertexColors: true,\n        specular: new THREE.Color(GLAZE_PALE),\n        shininess: 72,\n      });\n    } else {\n      // unfired / matte clay\n      materialCache[key] = new THREE.MeshLambertMaterial({ vertexColors: true });\n    }\n  }\n  return materialCache;\n}\n\n// ---------- voxel field ----------\n\nexport function createField() {\n  const cells = new Map(); // \"x,y,z\" -> mat key\n  return {\n    cells,\n    set(x, y, z, mat) { cells.set(x + ',' + y + ',' + z, mat); },\n    unset(x, y, z) { cells.delete(x + ',' + y + ',' + z); },\n    get(x, y, z) { return cells.get(x + ',' + y + ',' + z); },\n    has(x, y, z) { return cells.has(x + ',' + y + ',' + z); },\n    box(x, y, z, w, h, d, mat) {\n      for (let i = 0; i < w; i++)\n        for (let j = 0; j < h; j++)\n          for (let k = 0; k < d; k++)\n            cells.set((x + i) + ',' + (y + j) + ',' + (z + k), mat);\n    },\n    unbox(x, y, z, w, h, d) {\n      for (let i = 0; i < w; i++)\n        for (let j = 0; j < h; j++)\n          for (let k = 0; k < d; k++)\n            cells.delete((x + i) + ',' + (y + j) + ',' + (z + k));\n    },\n  };\n}\n\n// ---------- face tables ----------\n\nconst FACES = [\n  { dir: [-1, 0, 0], corners: [[0, 1, 0], [0, 0, 0], [0, 1, 1], [0, 0, 1]], shade: 0.80 },\n  { dir: [1, 0, 0], corners: [[1, 1, 1], [1, 0, 1], [1, 1, 0], [1, 0, 0]], shade: 0.80 },\n  { dir: [0, -1, 0], corners: [[1, 0, 1], [0, 0, 1], [1, 0, 0], [0, 0, 0]], shade: 0.55 },\n  { dir: [0, 1, 0], corners: [[0, 1, 1], [1, 1, 1], [0, 1, 0], [1, 1, 0]], shade: 1.0 },\n  { dir: [0, 0, -1], corners: [[1, 0, 0], [0, 0, 0], [1, 1, 0], [0, 1, 0]], shade: 0.72 },\n  { dir: [0, 0, 1], corners: [[0, 0, 1], [1, 0, 1], [0, 1, 1], [1, 1, 1]], shade: 0.88 },\n];\n\n// deterministic per-voxel brightness jitter — handmade clay feel, no two tiles identical\nfunction jitter(x, y, z) {\n  let h = (x * 374761393 + y * 668265263 + z * 2147483647) | 0;\n  h = (h ^ (h >> 13)) * 1274126177;\n  h = h ^ (h >> 16);\n  return 0.94 + ((h & 255) / 255) * 0.1; // 0.94 .. 1.04\n}\n\n// vertex AO: darkens concave corners so light catches edges — fakes a bevel\nfunction vertexAO(solidAt, vx, vy, vz, corner, dir) {\n  const na = dir[0] !== 0 ? 0 : dir[1] !== 0 ? 1 : 2; // normal axis\n  const axes = [0, 1, 2].filter((a) => a !== na);\n  const b = [vx + dir[0], vy + dir[1], vz + dir[2]];\n  const t1 = [0, 0, 0]; t1[axes[0]] = corner[axes[0]] === 1 ? 1 : -1;\n  const t2 = [0, 0, 0]; t2[axes[1]] = corner[axes[1]] === 1 ? 1 : -1;\n  const s1 = solidAt(b[0] + t1[0], b[1] + t1[1], b[2] + t1[2]) ? 1 : 0;\n  const s2 = solidAt(b[0] + t2[0], b[1] + t2[1], b[2] + t2[2]) ? 1 : 0;\n  const c = solidAt(b[0] + t1[0] + t2[0], b[1] + t1[1] + t2[1], b[2] + t1[2] + t2[2]) ? 1 : 0;\n  const occ = s1 && s2 ? 3 : s1 + s2 + c;\n  return 1 - occ * 0.18;\n}\n\n// ---------- merged mesh building ----------\n\n// Build one merged Mesh per material from a field. Returns a THREE.Group.\n// voxelSize: world units per voxel. offset: world position of voxel (0,0,0) corner.\nexport function buildFieldMesh(field, { voxelSize = 1, offset = [0, 0, 0] } = {}) {\n  const mats = getMaterials();\n  const buckets = new Map(); // mat -> {pos:[],nrm:[],col:[],idx:[]}\n  const solidAt = (x, y, z) => field.has(x, y, z);\n  const tmpColor = new THREE.Color();\n\n  for (const [key, mat] of field.cells) {\n    const [x, y, z] = key.split(',').map(Number);\n    let bucket = buckets.get(mat);\n    if (!bucket) {\n      bucket = { pos: [], nrm: [], col: [], idx: [] };\n      buckets.set(mat, bucket);\n    }\n    const jit = jitter(x, y, z);\n    tmpColor.setHex(MAT_DEFS[mat] ? MAT_DEFS[mat].color : TERRA);\n\n    for (const face of FACES) {\n      const nx = x + face.dir[0], ny = y + face.dir[1], nz = z + face.dir[2];\n      if (solidAt(nx, ny, nz)) continue; // culled — neighbour covers it\n      const start = bucket.pos.length / 3;\n      for (const corner of face.corners) {\n        bucket.pos.push(\n          (x + corner[0]) * voxelSize + offset[0],\n          (y + corner[1]) * voxelSize + offset[1],\n          (z + corner[2]) * voxelSize + offset[2]\n        );\n        bucket.nrm.push(face.dir[0], face.dir[1], face.dir[2]);\n        const ao = vertexAO(solidAt, x, y, z, corner, face.dir);\n        const v = face.shade * ao * jit;\n        bucket.col.push(tmpColor.r * v, tmpColor.g * v, tmpColor.b * v);\n      }\n      bucket.idx.push(start, start + 1, start + 2, start + 2, start + 1, start + 3);\n    }\n  }\n\n  const group = new THREE.Group();\n  for (const [mat, b] of buckets) {\n    if (b.idx.length === 0) continue;\n    const geo = new THREE.BufferGeometry();\n    geo.setAttribute('position', new THREE.Float32BufferAttribute(b.pos, 3));\n    geo.setAttribute('normal', new THREE.Float32BufferAttribute(b.nrm, 3));\n    geo.setAttribute('color', new THREE.Float32BufferAttribute(b.col, 3));\n    geo.setIndex(b.idx);\n    const mesh = new THREE.Mesh(geo, mats[mat]);\n    mesh.castShadow = true;\n    mesh.receiveShadow = true;\n    group.add(mesh);\n  }\n  return group;\n}\n\n// ---------- small character / prop models ----------\n\n// voxels: array of [x, y, z, matKey]. Builds a merged group with AO,\n// origin at the model's bottom-centre so it sits on the ground naturally.\nexport function buildModelMesh(voxels, { voxelSize = 0.14 } = {}) {\n  const field = createField();\n  let minX = Infinity, maxX = -Infinity, minY = Infinity, minZ = Infinity, maxZ = -Infinity;\n  for (const [x, y, z, m] of voxels) {\n    field.set(x, y, z, m);\n    if (x < minX) minX = x; if (x + 1 > maxX) maxX = x + 1;\n    if (y < minY) minY = y;\n    if (z < minZ) minZ = z; if (z + 1 > maxZ) maxZ = z + 1;\n  }\n  const cx = (minX + maxX) / 2, cz = (minZ + maxZ) / 2;\n  const group = buildFieldMesh(field, {\n    voxelSize,\n    offset: [-cx * voxelSize, -minY * voxelSize, -cz * voxelSize],\n  });\n  return group;\n}\n\n// A single loose voxel cube (for shatter debris, dissolving effects).\nlet debrisGeo = null;\nexport function makeDebrisVoxel(matKey, size = 0.14) {\n  if (!debrisGeo) debrisGeo = new THREE.BoxGeometry(1, 1, 1);\n  const def = MAT_DEFS[matKey] || MAT_DEFS[MAT.BONE];\n  const m = new THREE.Mesh(\n    debrisGeo,\n    new THREE.MeshLambertMaterial({ color: def.color, transparent: true })\n  );\n  m.scale.setScalar(size);\n  m.castShadow = true;\n  return m;\n}\n"}],"folders":[{"folder":"levels","files":[{"name":"bonus.js","content":"// KILN — Bonus: The Potter's Wheel\n// Unlocked by finding all 35 glaze drops. A great wheel still turning\n// in the dark: ride the orbiting platforms up the spindle.\n\nexport const bonusLevel = {\n  id: 8,\n  name: \"The Potter's Wheel\",\n  spawn: [5, 1, 3],\n  killY: -6,\n  boxes: [\n    [-8, 0, -8, 16, 1, 16, 'terra'],     // the wheel bed\n    [-2, 0, -2, 4, 13, 4, 'terraDeep'],  // the spindle — summit is its top\n    // a rim of glaze tiles marking the wheel's edge\n    [-8, 1, -8, 2, 1, 2, 'glaze'],\n    [6, 1, -8, 2, 1, 2, 'glaze'],\n    [-8, 1, 6, 2, 1, 2, 'glaze'],\n    [6, 1, 6, 2, 1, 2, 'glaze'],\n    // goal arch on the summit\n    [-1, 13, -2, 1, 3, 1, 'glazePale'],\n    [-1, 13, 1, 1, 3, 1, 'glazePale'],\n    [-1, 16, -2, 1, 1, 4, 'glazePale'],\n  ],\n  decor: [\n    // back and side walls only — the +z side stays open for the camera\n    [-14, -4, -14, 28, 1, 28, 'soot'],\n    [-14, 0, -14, 28, 22, 2, 'soot'],\n    [-16, 0, -14, 2, 22, 28, 'soot'],\n    [14, 0, -14, 2, 22, 28, 'soot'],\n  ],\n  // the drops sit on the orbit path — collect them as you ride\n  drops: [\n    [6, 4.2, 0],\n    [0, 6.4, 6],\n    [-6, 8.6, 0],\n    [0, 10.8, -6],\n    [0, 14.6, 0],        // crowning the spindle\n  ],\n  checkpoints: [\n    [0, 13, 0],\n  ],\n  goal: [-0.5, 13, 0],\n  movers: [\n    // six platforms orbiting the spindle, each a step higher\n    { orbit: { center: [0, 2.0, 0], radius: 6, speed: 0.55, phase: 0 }, size: [3, 0.8, 3], mat: 'glaze' },\n    { orbit: { center: [0, 4.0, 0], radius: 6, speed: 0.55, phase: 1.05 }, size: [3, 0.8, 3], mat: 'ash' },\n    { orbit: { center: [0, 6.0, 0], radius: 6, speed: 0.55, phase: 2.1 }, size: [3, 0.8, 3], mat: 'glaze' },\n    { orbit: { center: [0, 8.0, 0], radius: 6, speed: 0.55, phase: 3.15 }, size: [3, 0.8, 3], mat: 'ash' },\n    { orbit: { center: [0, 10.0, 0], radius: 6, speed: 0.55, phase: 4.2 }, size: [3, 0.8, 3], mat: 'glaze' },\n    { orbit: { center: [0, 12.0, 0], radius: 4, speed: 0.55, phase: 5.25 }, size: [3, 0.8, 3], mat: 'glazePale' },\n  ],\n  wind: [],\n  crumble: [],\n  heat: null,\n  crabs: [],\n};\n"},{"name":"level1.js","content":"// KILN — Level 1: The Cooling Racks\n// Teaches movement, jumping, coyote time — purely through layout.\n// Gaps grow from 2 to 3 voxels; rises appear only after flat jumps are learned.\n//\n// LEVEL DATA FORMAT (shared by all levels):\n//   boxes:  [x, y, z, w, h, d, mat]  solid voxel boxes (mat from palette MAT keys)\n//   decor:  same shape, non-solid (backdrops, trims)\n//   drops:  [x, y, z]                5 hidden glaze drops (float coords ok)\n//   checkpoints: [x, y, z]           lantern feet positions\n//   goal:   [x, y, z]                kiln-arch exit trigger\n//   movers: {path:[[..],[..]], size, speed, mat, dwell} or {orbit:{center,radius,speed,phase}, size, mat}\n//   wind:   {box:[x,y,z,w,h,d], dir:[x,z], strength, period, duty, offset}\n//   crumble:[x, y, z, w, h, d]       ash shelves that collapse 0.6s after touch\n//   heat:   {startY, delay, speed, maxY}\n//   crabs:  {a:[x,z], b:[x,z], y, speed}\n//   boss:   true (level 7 only)\n\nexport const level1 = {\n  id: 1,\n  name: 'The Cooling Racks',\n  spawn: [3, 2, 0],\n  killY: -8,\n  boxes: [\n    // main path\n    [0, 0, -3, 8, 2, 6, 'terra'],        // start slab\n    [10, 0, -3, 6, 2, 6, 'terra'],       // first gap: 2\n    [19, 0, -3, 6, 2, 6, 'terra'],       // gap: 3\n    [27, 1, -3, 5, 2, 6, 'terra'],       // rise 1\n    [34, 2, -2, 4, 2, 5, 'terra'],       // rise 1 again\n    [40, 3, -2, 3, 1, 4, 'ash'],         // the cooling racks — thin ash shelves\n    [45, 3, -2, 3, 1, 4, 'ash'],\n    [50, 4, -2, 3, 1, 4, 'ash'],\n    [55, 0, -4, 10, 6, 8, 'terra'],      // final block\n    // secret side shelf (drop 3)\n    [21, 1, 4, 2, 1, 2, 'ash'],\n    // under-rack floor + stair back up (drop 4 route)\n    [39, 0, -2, 13, 1, 4, 'terraDeep'],\n    [36, 1, -2, 2, 2, 4, 'ash'],\n    // goal arch\n    [58, 6, -2, 1, 4, 1, 'terraDeep'],\n    [58, 6, 1, 1, 4, 1, 'terraDeep'],\n    [58, 10, -2, 1, 1, 4, 'terraDeep'],\n    // a few fired pots for set dressing (solid, small)\n    [1, 2, -2, 1, 1, 1, 'glaze'],\n    [6, 2, 2, 1, 2, 1, 'glaze'],\n    [12, 2, -2, 1, 1, 1, 'terraDeep'],\n    [62, 6, -3, 1, 2, 1, 'glaze'],\n    [63, 6, 2, 1, 1, 1, 'glazePale'],\n  ],\n  decor: [\n    // kiln brick backdrop behind the racks\n    [-2, -2, -8, 70, 14, 2, 'soot'],\n    [-2, -2, -6, 2, 14, 14, 'terraDeep'],\n    // ash floor far below (catches the eye when you fall)\n    [-2, -6, -8, 72, 1, 18, 'soot'],\n  ],\n  drops: [\n    [12, 3, 0],          // hop for it\n    [26, 3.6, 0],        // in the air over the rise gap\n    [22, 3, 5],          // side shelf secret\n    [45, 2, 0],          // under the racks\n    [61, 7.6, -1],       // high above the final block\n  ],\n  checkpoints: [\n    [22, 2, 0],\n    [51.5, 5, 0],\n  ],\n  goal: [60, 6, 0],\n  movers: [],\n  wind: [],\n  crumble: [],\n  heat: null,\n  crabs: [],\n};\n"},{"name":"level2.js","content":"// KILN — Level 2: Glaze Falls\n// Introduces moving platforms and slippery glazed surfaces.\n// Glaze has almost no friction — momentum carries you.\n\nexport const level2 = {\n  id: 2,\n  name: 'Glaze Falls',\n  spawn: [2, 2, 0],\n  killY: -8,\n  boxes: [\n    [0, 0, -3, 6, 2, 6, 'terra'],        // start\n    [8, 0, -3, 8, 2, 6, 'glaze'],        // first glaze runway — feel the slide\n    [19, 0, -3, 4, 2, 6, 'glaze'],       // gap 3, land sliding\n    [33, 1, -3, 4, 2, 6, 'terra'],       // after the first mover\n    [39, 1, -2, 3, 1, 4, 'glaze'],       // glaze steps down\n    [44, 1, -2, 3, 1, 4, 'glaze'],\n    [53, 5, -3, 6, 2, 6, 'terra'],       // high landing after the elevator\n    [61, 4, -2, 10, 1, 4, 'glaze'],      // the falls — long slick slide\n    [73, 3, -3, 6, 3, 6, 'terra'],       // final\n    // secret shelf behind the mover platform (drop 3)\n    [34, 0, 4, 2, 1, 2, 'ash'],\n    // goal arch\n    [75, 6, -2, 1, 4, 1, 'terraDeep'],\n    [75, 6, 1, 1, 4, 1, 'terraDeep'],\n    [75, 10, -2, 1, 1, 4, 'terraDeep'],\n    // dressing\n    [9, 2, -2, 1, 1, 1, 'glazePale'],\n    [21, 2, 2, 1, 2, 1, 'glaze'],\n    [55, 7, 2, 1, 1, 1, 'glazePale'],\n    [77, 6, 2, 1, 2, 1, 'glaze'],\n  ],\n  decor: [\n    // glaze waterfall backdrop — frozen blue drips down the kiln wall\n    [-2, -2, -8, 82, 16, 2, 'soot'],\n    [14, 2, -6.9, 2, 10, 1, 'glaze'],\n    [15, -2, -6.9, 1, 6, 1, 'glaze'],\n    [48, 4, -6.9, 2, 10, 1, 'glaze'],\n    [49, -2, -6.9, 1, 8, 1, 'glazePale'],\n    [66, 3, -6.9, 1, 12, 1, 'glaze'],\n    [-2, -6, -8, 84, 1, 18, 'soot'],\n  ],\n  drops: [\n    [11, 3, 0],\n    [28, 3.2, -0.5],     // riding the first mover\n    [35, 2, 5],          // the shelf behind\n    [45.5, 3, 0],\n    [50, 7.5, 0],        // top of the elevator ride\n  ],\n  checkpoints: [\n    [35, 3, 0],\n    [55, 7, 0],\n  ],\n  goal: [76, 6, 0],\n  movers: [\n    { path: [[25, 1.2, -1], [30, 1.2, -1]], size: [2, 0.8, 2], speed: 2.2, mat: 'ash', dwell: 0.6 },\n    { path: [[49, 2, -1], [49, 6, -1]], size: [2, 0.8, 2], speed: 1.7, mat: 'ash', dwell: 0.8 },\n  ],\n  wind: [],\n  crumble: [],\n  heat: null,\n  crabs: [],\n};\n"},{"name":"level3.js","content":"// KILN — Level 3: The Bellows\n// Wind gusts on a timed cycle. Tailwinds carry you over gaps too wide\n// to jump; a crosswind bridge punishes bad timing. Listen for the gust.\n\nexport const level3 = {\n  id: 3,\n  name: 'The Bellows',\n  spawn: [2, 2, 0],\n  killY: -8,\n  boxes: [\n    [0, 0, -3, 6, 2, 6, 'terra'],        // start\n    [14, 0, -3, 5, 2, 6, 'terra'],       // across the first bellows gap (8 wide!)\n    [21, 1, -1, 12, 1, 2, 'ash'],        // narrow crosswind bridge\n    [25, 2, -1, 1, 2, 2, 'terraDeep'],   // shelter blocks on the bridge\n    [29, 2, -1, 1, 2, 2, 'terraDeep'],\n    [35, 0, -3, 5, 2, 6, 'terra'],       // rest stop\n    [44, 1, -2, 2, 1, 3, 'ash'],         // gusting island hops (5 apart)\n    [51, 1, -2, 2, 1, 3, 'ash'],\n    [58, 1, -2, 2, 1, 3, 'ash'],\n    [63, 0, -3, 7, 3, 6, 'terra'],       // final\n    // secret shelf behind spawn (drop 5)\n    [-4, 0, -1, 2, 1, 2, 'ash'],\n    // secret side shelf off the rest stop (drop 3)\n    [36, 1, 4, 2, 1, 2, 'ash'],\n    // goal arch\n    [66, 3, -2, 1, 4, 1, 'terraDeep'],\n    [66, 3, 1, 1, 4, 1, 'terraDeep'],\n    [66, 7, -2, 1, 1, 4, 'terraDeep'],\n    // the bellows themselves — great terracotta pipes on the wall\n    [7, 2, -6, 2, 2, 2, 'terraDeep'],\n    [40, 2, -6, 2, 2, 2, 'terraDeep'],\n  ],\n  decor: [\n    [-6, -2, -8, 80, 16, 2, 'soot'],\n    [7, 4, -7.5, 2, 6, 1, 'terra'],      // bellows pipes rising up the wall\n    [40, 4, -7.5, 2, 6, 1, 'terra'],\n    [-6, -6, -8, 82, 1, 18, 'soot'],\n  ],\n  drops: [\n    [9, 3.5, 0],         // mid-glide over the bellows gap\n    [27, 3.5, 0],        // above the crosswind bridge\n    [37, 3, 5],          // side shelf\n    [54.5, 3.5, -0.5],   // between gusting islands\n    [-3, 2, 0],          // behind the spawn — look back\n  ],\n  checkpoints: [\n    [16, 2, 0],\n    [37, 2, 0],\n  ],\n  goal: [67, 3, 0],\n  movers: [],\n  wind: [\n    // tailwind over the first gap\n    { box: [5, 0, -3, 9, 7, 6], dir: [1, 0], strength: 30, period: 4.0, duty: 0.45, offset: 0 },\n    // crosswind on the bridge — pushes you off the +z edge\n    { box: [21, 1, -3, 12, 6, 6], dir: [0, 1], strength: 34, period: 5.0, duty: 0.42, offset: 1.2 },\n    // tailwind across the island hops\n    { box: [43, 1, -3, 20, 7, 6], dir: [1, 0], strength: 30, period: 3.6, duty: 0.45, offset: 0.5 },\n  ],\n  crumble: [],\n  heat: null,\n  crabs: [],\n};\n"},{"name":"level4.js","content":"// KILN — Level 4: Cracked Shelves\n// Ash shelving collapses voxel-by-voxel 0.6s after you touch it.\n// Keep moving. Shelves re-form a few seconds later.\n\nexport const level4 = {\n  id: 4,\n  name: 'Cracked Shelves',\n  spawn: [2, 2, 0],\n  killY: -8,\n  boxes: [\n    [0, 0, -3, 6, 2, 6, 'terra'],        // start\n    [23, 0, -3, 5, 2, 6, 'terra'],       // mid rest\n    [42, 3, -3, 5, 3, 6, 'terra'],       // high rest after the crumble stairs\n    [64, 3, -3, 6, 3, 6, 'terra'],       // final\n    // goal arch\n    [67, 6, -2, 1, 4, 1, 'terraDeep'],\n    [67, 6, 1, 1, 4, 1, 'terraDeep'],\n    [67, 10, -2, 1, 1, 4, 'terraDeep'],\n    // dressing — broken pots on the rests\n    [24, 2, -2, 1, 1, 1, 'terraDeep'],\n    [45, 6, 2, 1, 2, 1, 'glaze'],\n    [68, 6, 2, 1, 1, 1, 'glazePale'],\n  ],\n  decor: [\n    [-2, -2, -8, 76, 16, 2, 'soot'],\n    // collapsed shelf wreckage on the back wall\n    [10, 0, -6.9, 4, 1, 1, 'ash'],\n    [12, 1, -6.9, 3, 1, 1, 'ash'],\n    [33, 2, -6.9, 5, 1, 1, 'ash'],\n    [52, 4, -6.9, 4, 1, 1, 'ash'],\n    [-2, -6, -8, 78, 1, 18, 'soot'],\n  ],\n  drops: [\n    [14.5, 3.2, 0],      // grab mid-hop on the first chain\n    [14, 3.2, 4],        // on the side crumble shelf\n    [35, 5.2, 0],        // above the crumble stairs\n    [56, 7.2, 0],        // above the middle of the long bridge\n    [68, 7.6, -2],       // tucked behind the goal arch\n  ],\n  checkpoints: [\n    [25, 2, 0],\n    [44, 6, 0],\n  ],\n  goal: [66, 6, 0],\n  movers: [],\n  wind: [],\n  crumble: [\n    // first chain — flat hops\n    [8, 1, -1, 3, 1, 2],\n    [13, 1, -1, 3, 1, 2],\n    [18, 1, -1, 3, 1, 2],\n    // side shelf hiding drop 2\n    [13, 1, 3, 2, 1, 2],\n    // stairs up\n    [30, 2, -1, 2, 1, 2],\n    [34, 3, -1, 2, 1, 2],\n    [38, 4, -1, 2, 1, 2],\n    // the long bridge\n    [49, 5, -1, 3, 1, 2],\n    [54, 5, -1, 3, 1, 2],\n    [59, 5, -1, 3, 1, 2],\n  ],\n  heat: null,\n  crabs: [],\n};\n"},{"name":"level5.js","content":"// KILN — Level 5: The Firing Chamber\n// A vertical shaft. The kiln is lit — heat rises from below.\n// Climb the spiral of shelves before the glow catches you.\n// The heat stops just short of the summit: the top is safe.\n\nexport const level5 = {\n  id: 5,\n  name: 'The Firing Chamber',\n  spawn: [5, 2, 0],\n  killY: -8,\n  boxes: [\n    [0, 0, -5, 10, 2, 10, 'terra'],      // chamber floor\n    // the climb — rises of 2, always within reach of the last shelf\n    [7, 3, -4, 3, 1, 3, 'ash'],          // p1 top 4\n    [3, 5, -5, 3, 1, 3, 'ash'],          // p2 top 6\n    [0, 7, -1, 3, 1, 3, 'ash'],          // p3 top 8\n    [4, 9, 2, 3, 1, 3, 'ash'],           // p4 top 10\n    [8, 11, 1, 3, 1, 3, 'ash'],          // p5 top 12\n    [5, 13, -2, 3, 1, 3, 'ash'],         // p6 top 14 (lantern here)\n    [1, 15, -4, 3, 1, 3, 'ash'],         // p7 top 16\n    [6, 17, 0, 3, 1, 3, 'ash'],          // p8 top 18\n    [2, 19, 3, 3, 1, 3, 'ash'],          // p9 top 20\n    [7, 21, 3, 3, 1, 3, 'ash'],          // p10 top 22\n    [8, 22, 4, 2, 1, 2, 'ash'],          // little ledge by p10 (drop 5)\n    [0, 23, -5, 9, 1, 9, 'terra'],       // summit\n    // side pocket shelf for drop 2 (reach down from p3, climb back)\n    [0, 5, 3, 2, 1, 2, 'ash'],\n    // goal arch on the summit\n    [3, 24, -3, 1, 4, 1, 'terraDeep'],\n    [3, 24, 0, 1, 4, 1, 'terraDeep'],\n    [3, 28, -3, 1, 1, 4, 'terraDeep'],\n  ],\n  decor: [\n    // shaft walls — soot brick with ember cracks low down\n    // (back and both sides only; the +z side stays open for the camera)\n    [-4, -2, -8, 18, 30, 2, 'soot'],\n    [-4, -2, -6, 2, 30, 12, 'soot'],\n    [12, -2, -6, 2, 30, 12, 'soot'],\n    [1, -1, -6.5, 2, 1, 1, 'ember'],     // glowing coals below\n    [6, -1, -6.5, 3, 1, 1, 'ember'],\n    [11, 0, -6.5, 2, 1, 1, 'ember'],\n    [-2, -4, -8, 20, 1, 16, 'ember'],    // the firebed itself\n  ],\n  drops: [\n    [8.5, 5.5, -2.5],    // above p1\n    [1, 7, 4],           // the side pocket\n    [1.5, 9.8, 0.5],     // hop from p3\n    [6.5, 15.5, -1],     // above p6\n    [8.5, 23.8, 4.5],    // jump from p10's little ledge\n  ],\n  checkpoints: [\n    [6, 14, -1],         // on p6 — respawning resets the heat\n  ],\n  goal: [3.5, 24, -1.5],\n  movers: [],\n  wind: [],\n  crumble: [],\n  heat: { startY: -2, delay: 5, speed: 0.62, maxY: 21 },\n  crabs: [],\n};\n"},{"name":"level6.js","content":"// KILN — Level 6: Shard Hollow\n// Pot-shard crabs skitter on patrol routes. Stomp them or slip past.\n// Touching one from the side cracks you.\n\nexport const level6 = {\n  id: 6,\n  name: 'Shard Hollow',\n  spawn: [2, 2, 0],\n  killY: -8,\n  boxes: [\n    [0, 0, -3, 6, 2, 6, 'terra'],        // start\n    [8, 0, -5, 12, 2, 10, 'terra'],      // room one — two crabs\n    [22, 1, -1, 6, 1, 2, 'ash'],         // narrow bridge, one crab\n    [30, 0, -6, 14, 2, 12, 'terra'],     // room two — three crabs\n    [36, 2, -2, 3, 1, 3, 'ash'],         // raised shelf in room two\n    [42, 2, -5, 2, 3, 2, 'terraDeep'],   // pillar to hide behind\n    [46, 1, -2, 2, 2, 4, 'ash'],         // exit stairs\n    [50, 2, -2, 2, 2, 4, 'ash'],\n    [54, 3, -3, 8, 2, 6, 'terra'],       // high path — last crab\n    // side alcove off room two (drop 5)\n    [33, 0, 8, 2, 1, 2, 'ash'],\n    // goal arch\n    [58, 5, -2, 1, 4, 1, 'terraDeep'],\n    [58, 5, 1, 1, 4, 1, 'terraDeep'],\n    [58, 9, -2, 1, 1, 4, 'terraDeep'],\n    // shattered pots everywhere — this is where they crawled from\n    [10, 2, -4, 1, 1, 1, 'terraDeep'],\n    [17, 2, 3, 1, 1, 1, 'terraDeep'],\n    [32, 2, 4, 1, 1, 1, 'terraDeep'],\n    [40, 2, -1, 1, 2, 1, 'glaze'],\n  ],\n  decor: [\n    [-2, -2, -9, 68, 16, 2, 'soot'],\n    // heaps of shards on the back wall\n    [12, 0, -7.9, 3, 2, 1, 'terraDeep'],\n    [13, 2, -7.9, 1, 1, 1, 'ash'],\n    [35, 0, -7.9, 4, 2, 1, 'terraDeep'],\n    [36, 2, -7.9, 2, 1, 1, 'ash'],\n    [-2, -6, -9, 70, 1, 20, 'soot'],\n  ],\n  drops: [\n    [12, 3.5, -3],       // room one, off the main line\n    [25, 3.3, 0],        // above the bridge (jump over the crab!)\n    [37.5, 4.2, -0.5],   // on the raised shelf\n    [43, 2.8, -4.5],     // hidden behind the pillar\n    [34, 2, 9],          // the side alcove\n  ],\n  checkpoints: [\n    [32, 2, 0],\n    [55, 5, 0],\n  ],\n  goal: [60, 5, 0],\n  movers: [],\n  wind: [],\n  crumble: [],\n  heat: null,\n  crabs: [\n    { a: [9, -3], b: [18, -3], y: 2, speed: 2.2 },\n    { a: [18, 2], b: [10, 2], y: 2, speed: 2.6 },\n    { a: [22.5, 0], b: [27, 0], y: 2, speed: 2.0 },\n    { a: [31, -4], b: [43, -4], y: 2, speed: 2.4 },\n    { a: [43, 3], b: [31, 3], y: 2, speed: 2.8 },\n    { a: [37, -5], b: [37, 4], y: 2, speed: 2.2 },\n    { a: [54.5, 0], b: [61, 0], y: 5, speed: 3.0 },\n  ],\n};\n"},{"name":"level7.js","content":"// KILN — Level 7: The Kilnheart\n// Everything you have learned, in one run: glaze, movers, wind,\n// crumbling shelves, crabs — and then the Kilnheart itself,\n// a colossal cracked amphora that lobs molten glaze in three phases.\n\nexport const level7 = {\n  id: 7,\n  name: 'The Kilnheart',\n  spawn: [2, 2, 0],\n  killY: -8,\n  boxes: [\n    [0, 0, -3, 6, 2, 6, 'terra'],        // start\n    [8, 0, -3, 8, 2, 6, 'glaze'],        // glaze runway\n    [27, 0, -3, 5, 2, 6, 'terra'],       // after the mover\n    [40, 0, -3, 5, 2, 6, 'terra'],       // after the wind gap\n    [59, 3, -3, 6, 3, 6, 'terra'],       // high rest after crumble stairs\n    [67, 5, -1, 10, 1, 2, 'ash'],        // crab gauntlet bridge\n    [79, 3, -4, 6, 3, 8, 'terra'],       // pre-boss landing\n    // ---- boss arena ----\n    [87, 0, -10, 22, 4, 20, 'terra'],    // the arena floor\n    [96, 4, -2, 4, 4, 4, 'terraDeep'],   // the amphora's pedestal (inside the boss)\n    [91, 4, -6, 2, 4, 2, 'terraDeep'],   // cover pillars\n    [103, 4, -6, 2, 4, 2, 'terraDeep'],\n    [91, 4, 4, 2, 4, 2, 'terraDeep'],\n    [103, 4, 4, 2, 4, 2, 'terraDeep'],\n    // molten glaze pools at the arena rim (visual warning — stay central)\n    [88, 4, -9, 3, 1, 2, 'ember'],\n    [105, 4, 7, 3, 1, 2, 'ember'],\n  ],\n  decor: [\n    [-2, -2, -9, 90, 18, 2, 'soot'],\n    [-2, -6, -9, 92, 1, 22, 'soot'],\n    // the kilnheart chamber — a great soot dome behind the arena\n    [85, -2, -14, 26, 1, 28, 'ember'],   // firebed under the arena\n    [85, 4, -13, 26, 18, 2, 'soot'],     // back wall of the chamber\n    [107, 4, -13, 2, 18, 26, 'soot'],    // far end wall\n    // NOTE: no wall on the +z side — that's where the camera lives.\n  ],\n  drops: [\n    [11, 3.3, 0],        // on the glaze\n    [21, 3.4, -0.5],     // riding the mover\n    [35, 3.6, 0],        // mid-air in the wind gap\n    [53, 5.8, 0],        // above the crumble stairs\n    [72, 7.4, 0],        // above the crab bridge\n  ],\n  checkpoints: [\n    [29, 2, 0],\n    [61, 6, 0],\n    [81, 6, 0],          // right before the boss\n  ],\n  goal: null,            // beating the Kilnheart ends the level\n  movers: [\n    { path: [[18, 1.2, -1], [23, 1.2, -1]], size: [2, 0.8, 2], speed: 2.2, mat: 'ash', dwell: 0.6 },\n  ],\n  wind: [\n    { box: [31, 0, -3, 10, 7, 6], dir: [1, 0], strength: 30, period: 4.0, duty: 0.45, offset: 0 },\n  ],\n  crumble: [\n    [47, 2, -1, 2, 1, 2],\n    [51, 3, -1, 2, 1, 2],\n    [55, 4, -1, 2, 1, 2],\n  ],\n  heat: null,\n  crabs: [\n    { a: [67.5, 0], b: [76, 0], y: 6, speed: 2.6 },\n    { a: [76, 0], b: [67.5, 0], y: 6, speed: 3.2 },\n  ],\n  boss: {\n    pos: [98, 4, 0],     // the amphora stands at the arena's heart\n    arena: [87, 4, -10, 22, 10, 20],\n  },\n};\n"}],"folders":[]}]},{"folder":"other","files":[{"name":"main.sketch","content":""}],"folders":[]}]},"variants":null,"createdAt":"2026-07-06T12:17:13.437Z","updatedAt":"2026-07-06T12:21:55.688Z"}}