{"project":{"id":"UfNcovi","userId":"davidyarham@gmail.com","username":null,"userPicture":null,"name":"Clawd - A little crab in a cold dark kiln","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, maximum-scale=1.0, user-scalable=no, viewport-fit=cover\">\n<title>KILN — a voxel platformer starring Clawd, Anthropic's crab 🦀</title>\n<meta name=\"description\" content=\"Help Clawd, Anthropic's little crab, climb a cold dark kiln — 9 hand-placed voxel levels, 45 glaze drops, one Kilnheart. A tiny game built with Claude.\">\n<meta property=\"og:title\" content=\"KILN — a voxel platformer starring Clawd 🦀\">\n<meta property=\"og:description\" content=\"Anthropic's little crab vs. a cold dark kiln. 9 voxel levels, 45 glaze drops, one boss. Built with Claude.\">\n<meta property=\"og:type\" content=\"website\">\n<meta name=\"twitter:card\" content=\"summary\">\n<meta name=\"twitter:title\" content=\"KILN — a voxel platformer starring Clawd 🦀\">\n<meta name=\"twitter:description\" content=\"Anthropic's little crab vs. a cold dark kiln. 9 voxel levels. Built with Claude.\">\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    \"three/addons/\": \"https://unpkg.com/three@0.160.0/examples/jsm/\"\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  /* belt & braces with the viewport meta: iOS ignores user-scalable=no,\n     but touch-action reliably kills double-tap / pinch zoom */\n  touch-action: manipulation;\n  overscroll-behavior: none;\n}\n\n#game-canvas {\n  position: fixed;\n  inset: 0;\n  width: 100%;\n  height: 100%;\n  display: block;\n  touch-action: none;\n}\n\n/* soft vignette over the render — draws the eye to the centre of the kiln */\nbody::before {\n  content: '';\n  position: fixed;\n  inset: 0;\n  pointer-events: none;\n  background: radial-gradient(ellipse at 50% 45%, transparent 62%, rgba(12, 8, 6, 0.34) 100%);\n  z-index: 1;\n}\n\n#ui-root {\n  position: fixed;\n  inset: 0;\n  pointer-events: none;\n  user-select: none;\n  z-index: 2;\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/* best clear time + ember medal on fired levels */\n.level-card__time {\n  font-size: 0.78rem;\n  letter-spacing: 0.06em;\n  font-variant-numeric: tabular-nums;\n  color: var(--c-ash);\n}\n.level-card__time.medal-gold { color: var(--c-ember); }\n.level-card__time.medal-silver { color: var(--c-bone); }\n.level-card__time.medal-bronze { color: var(--c-terra); }\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/* small hint line under the settings list (locked shells, etc.) */\n.settings-hint {\n  font-size: 0.74rem;\n  letter-spacing: 0.1em;\n  color: var(--c-ash);\n  opacity: 0.75;\n  max-width: 340px;\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/* the drop compass — a quiet arrow under the pot, pointing at the hunt */\n.hud-compass {\n  margin: 6px auto 0;\n  width: 18px;\n  text-align: center;\n  font-size: 0.72rem;\n  color: var(--c-glaze-pale);\n  opacity: 0.75;\n  text-shadow: 0 0 6px rgba(61, 125, 196, 0.55);\n  transition: transform 0.12s linear;\n  will-change: transform;\n}\n\n/* the speedrun clock, whispering under the level name */\n.hud-time {\n  position: absolute;\n  top: 42px;\n  left: 26px;\n  font-size: 0.78rem;\n  letter-spacing: 0.18em;\n  font-variant-numeric: tabular-nums;\n  color: rgba(138, 127, 118, 0.75);\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\n/* ---------- touch controls (shown by screen size, no JS detection) ---------- */\n.touch-controls { display: none; }\n@media (max-width: 900px) {\n  .touch-controls { display: block; }\n}\n\n.touch-joy {\n  position: fixed;\n  left: max(22px, env(safe-area-inset-left));\n  bottom: max(22px, env(safe-area-inset-bottom));\n  width: 132px;\n  height: 132px;\n  border-radius: 50%;\n  background: rgba(26, 18, 16, 0.5);\n  border: 2px solid rgba(232, 222, 208, 0.28);\n  touch-action: none;\n  user-select: none;\n  -webkit-user-select: none;\n  -webkit-tap-highlight-color: transparent;\n  z-index: 40;\n}\n.touch-joy__knob {\n  position: absolute;\n  left: 50%;\n  top: 50%;\n  width: 58px;\n  height: 58px;\n  margin: -29px 0 0 -29px;\n  border-radius: 50%;\n  background: rgba(232, 161, 58, 0.75);\n  border: 2px solid rgba(232, 222, 208, 0.5);\n  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.45);\n  pointer-events: none;\n  will-change: transform;\n}\n\n.touch-jump {\n  position: fixed;\n  right: max(26px, env(safe-area-inset-right));\n  bottom: max(34px, env(safe-area-inset-bottom));\n  width: 92px;\n  height: 92px;\n  border-radius: 50%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  background: rgba(26, 18, 16, 0.5);\n  border: 2px solid rgba(232, 161, 58, 0.55);\n  color: var(--c-ember);\n  font-size: 0.75rem;\n  letter-spacing: 0.22em;\n  touch-action: none;\n  user-select: none;\n  -webkit-user-select: none;\n  -webkit-tap-highlight-color: transparent;\n  z-index: 40;\n}\n.touch-jump--down {\n  background: rgba(232, 161, 58, 0.35);\n  border-color: var(--c-ember);\n}\n\n/* camera swing buttons — sit above the jump button, thumb-reachable */\n.touch-cam {\n  position: fixed;\n  bottom: max(148px, calc(env(safe-area-inset-bottom) + 114px));\n  width: 56px;\n  height: 56px;\n  border-radius: 50%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  background: rgba(26, 18, 16, 0.5);\n  border: 2px solid rgba(232, 222, 208, 0.28);\n  color: var(--c-bone);\n  font-size: 1.3rem;\n  touch-action: none;\n  user-select: none;\n  -webkit-user-select: none;\n  -webkit-tap-highlight-color: transparent;\n  z-index: 40;\n}\n.touch-cam--left { right: max(112px, calc(env(safe-area-inset-right) + 86px)); }\n.touch-cam--right { right: max(26px, env(safe-area-inset-right)); }\n.touch-cam--down {\n  background: rgba(232, 161, 58, 0.35);\n  border-color: var(--c-ember);\n}\n\n.touch-pause {\n  position: fixed;\n  top: max(14px, env(safe-area-inset-top));\n  right: max(14px, env(safe-area-inset-right));\n  width: 44px;\n  height: 44px;\n  border-radius: 50%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  background: rgba(26, 18, 16, 0.5);\n  border: 2px solid rgba(232, 222, 208, 0.28);\n  color: var(--c-bone);\n  font-size: 0.7rem;\n  letter-spacing: 0.1em;\n  touch-action: none;\n  user-select: none;\n  -webkit-user-select: none;\n  -webkit-tap-highlight-color: transparent;\n  z-index: 40;\n}\n.touch-pause:active {\n  background: rgba(232, 161, 58, 0.35);\n  border-color: var(--c-ember);\n}\n\n/* ---------- community touches ---------- */\n.title-footer {\n  margin-top: 30px;\n  font-size: 0.72rem;\n  letter-spacing: 0.18em;\n  text-transform: uppercase;\n  color: var(--c-ash);\n  opacity: 0.75;\n}\n\n.pause-quip {\n  margin-top: 6px;\n  letter-spacing: 0.2em;\n  font-size: 0.8rem;\n  color: var(--c-ember);\n  text-transform: none;\n}\n\n/* ---------- gamepad focus ring — menus are driven by stick/d-pad too ---------- */\n.menu-btn:focus,\n.level-card:focus,\n.toggle-btn:focus,\n.setting-row input:focus,\n.save-code-area:focus {\n  outline: 2px solid var(--c-ember);\n  outline-offset: 3px;\n}\n\n/* ---------- level intro card — shown during the fly-in swoop ---------- */\n.intro-card {\n  position: fixed;\n  inset: 0;\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  justify-content: center;\n  gap: 10px;\n  text-align: center;\n  pointer-events: none;\n  opacity: 0;\n  z-index: 30;\n}\n\n.intro-card__kicker {\n  font-size: 0.78rem;\n  letter-spacing: 0.5em;\n  text-transform: uppercase;\n  color: var(--c-ember);\n}\n\n.intro-card__name {\n  font-size: clamp(1.8rem, 6vw, 3.4rem);\n  letter-spacing: 0.14em;\n  color: var(--c-bone);\n  text-shadow: 0 3px 30px rgba(0, 0, 0, 0.75);\n}\n\n.intro-card__rule {\n  width: 130px;\n  height: 2px;\n  background: var(--c-ember);\n  transform: scaleX(0);\n  box-shadow: 0 0 12px rgba(224, 122, 63, 0.5);\n}\n\n/* the whispered line under the name — lore on the way in, the run's\n   time · medal · drops on the way out */\n.intro-card__lore {\n  font-size: 0.88rem;\n  letter-spacing: 0.16em;\n  color: var(--c-ash);\n  white-space: pre-line;\n  font-variant-numeric: tabular-nums;\n  text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8);\n}\n\n.intro-card.show {\n  animation: intro-card 2.6s ease forwards;\n}\n.intro-card.show .intro-card__kicker {\n  animation: intro-kicker 2.6s ease forwards;\n}\n.intro-card.show .intro-card__rule {\n  animation: intro-rule 2.6s ease forwards;\n}\n\n@keyframes intro-card {\n  0%   { opacity: 0; transform: translateY(14px); }\n  14%  { opacity: 1; transform: translateY(0); }\n  72%  { opacity: 1; }\n  100% { opacity: 0; transform: translateY(-6px); }\n}\n\n@keyframes intro-kicker {\n  0%   { letter-spacing: 0.2em; opacity: 0; }\n  20%  { opacity: 1; }\n  60%  { letter-spacing: 0.5em; }\n  100% { letter-spacing: 0.5em; opacity: 1; }\n}\n\n@keyframes intro-rule {\n  0%   { transform: scaleX(0); }\n  16%  { transform: scaleX(0); }\n  45%  { transform: scaleX(1); }\n  100% { transform: scaleX(1); }\n}\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// A generative score with a MOOD PER LEVEL: each chamber has its own\n// root note, scale colour and melody density. Three voices — a deep\n// kiln drone, slow pad swells, and a sparse ceramic kalimba that\n// improvises phrases through a feedback echo. Plus ceramic tink SFX\n// and a deep rumble under the boss fight. Music ducks while paused.\n// Starts on first user input (autoplay policy).\n\n// One scene per level (index 0..9), plus the title. All roots live in\n// the A-minor family so walking between chambers never sounds sour.\nconst SCENES = {\n  title: { root: 220.0, scale: [0, 3, 5, 7, 10], mel: 2, density: 0.30, drone: 0.030, name: 'title' },\n  0: { root: 220.0, scale: [0, 3, 5, 7, 10], mel: 2, density: 0.40, drone: 0.030 },  // Cooling Racks — home key\n  1: { root: 261.63, scale: [0, 2, 4, 7, 9], mel: 2, density: 0.45, drone: 0.028 },  // Glaze Falls — bright, liquid\n  2: { root: 293.66, scale: [0, 2, 5, 7, 10], mel: 2, density: 0.38, drone: 0.030 }, // The Bellows — open, airy\n  3: { root: 220.0, scale: [0, 3, 5, 7, 10], mel: 1, density: 0.30, drone: 0.034 },  // Cracked Shelves — low, wary\n  4: { root: 164.81, scale: [0, 1, 5, 7, 10], mel: 2, density: 0.34, drone: 0.040 }, // Firing Chamber — phrygian heat\n  5: { root: 196.0, scale: [0, 3, 5, 7, 10], mel: 2, density: 0.36, drone: 0.034 },  // Shard Hollow — prowling\n  6: { root: 110.0, scale: [0, 1, 3, 7, 8], mel: 2, density: 0.22, drone: 0.052 },   // The Kilnheart — dread\n  7: { root: 261.63, scale: [0, 2, 4, 7, 9], mel: 4, density: 0.55, drone: 0.026 },  // Glassworks — high glass bells\n  8: { root: 146.83, scale: [0, 2, 3, 7, 10], mel: 2, density: 0.42, drone: 0.038 }, // The Chimney — climbing tension\n  9: { root: 440.0, scale: [0, 2, 4, 7, 9], mel: 2, density: 0.60, drone: 0.022 },   // Potter's Wheel — music box\n};\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  // generative score state\n  let scene = SCENES.title;\n  let echo = null;         // feedback delay bus for the kalimba\n  let drone = null;        // { o1, o2, g } — the kiln's standing tone\n  let melTimer = null;\n  let phraseBusyUntil = 0; // ctx-time until which a phrase is still sounding\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    // echo bus — the kiln is a big brick room; the kalimba rings in it\n    const dl = ctx.createDelay(1.5);\n    dl.delayTime.value = 0.44;\n    const fb = ctx.createGain();\n    fb.gain.value = 0.34;\n    const damp = ctx.createBiquadFilter();\n    damp.type = 'lowpass';\n    damp.frequency.value = 1700;\n    dl.connect(damp); damp.connect(fb); fb.connect(dl);\n    dl.connect(musicGain);\n    echo = dl;\n    return true;\n  }\n\n  // scale degree -> frequency (degrees wrap into higher octaves)\n  function degHz(deg, octMul) {\n    const n = scene.scale.length;\n    const oct = Math.floor(deg / n);\n    const semi = scene.scale[((deg % n) + n) % n];\n    return scene.root * Math.pow(2, semi / 12 + oct) * (octMul || 1);\n  }\n\n  // ---- kalimba: a warm pluck — sine fundamental + soft 3rd partial,\n  // fast attack, long ceramic decay, sent into the echo ----\n  function pluck(freq, when, gain, dur) {\n    if (!ctx) return;\n    const t = when;\n    const g = ctx.createGain();\n    g.gain.setValueAtTime(0.0001, t);\n    g.gain.exponentialRampToValueAtTime(gain, t + 0.012);\n    g.gain.exponentialRampToValueAtTime(0.001, t + dur);\n    const o = ctx.createOscillator();\n    o.type = 'sine'; o.frequency.value = freq;\n    const o2 = ctx.createOscillator();\n    o2.type = 'sine'; o2.frequency.value = freq * 3.01;\n    const g2 = ctx.createGain();\n    g2.gain.setValueAtTime(0.0001, t);\n    g2.gain.exponentialRampToValueAtTime(gain * 0.18, t + 0.008);\n    g2.gain.exponentialRampToValueAtTime(0.001, t + dur * 0.35);\n    o.connect(g); o2.connect(g2); g2.connect(g);\n    g.connect(musicGain);\n    const send = ctx.createGain();\n    send.gain.value = 0.55;\n    g.connect(send); send.connect(echo);\n    o.start(t); o2.start(t);\n    o.stop(t + dur + 0.1); o2.stop(t + dur + 0.1);\n  }\n\n  // improvise a short phrase: a random walk over the scene's scale,\n  // 2-5 notes, human-ish timing, ending low more often than high\n  let melDeg = 2;\n  function playPhrase() {\n    if (!ctx || ctx.state !== 'running') return;\n    const t0 = ctx.currentTime + 0.05;\n    if (t0 < phraseBusyUntil) return;\n    const n = 2 + Math.floor(Math.random() * 4);\n    let t = t0;\n    for (let i = 0; i < n; i++) {\n      melDeg += [-2, -1, -1, 1, 1, 2, 3][Math.floor(Math.random() * 7)];\n      melDeg = Math.max(0, Math.min(9, melDeg));\n      const last = i === n - 1;\n      if (last && Math.random() < 0.5) melDeg = Math.max(0, melDeg - 3); // settle\n      pluck(degHz(melDeg, scene.mel), t, 0.055 + Math.random() * 0.03, last ? 2.4 : 1.4);\n      t += 0.30 + Math.random() * 0.26 + (last ? 0 : (Math.random() < 0.2 ? 0.3 : 0));\n    }\n    phraseBusyUntil = t + 1.2;\n  }\n\n  // ---- drone: the kiln's standing tone, follows the scene root ----\n  function ensureDrone() {\n    if (!ctx || drone) return;\n    const o1 = ctx.createOscillator();\n    const o2 = ctx.createOscillator();\n    o1.type = 'sine'; o2.type = 'triangle';\n    o1.frequency.value = scene.root / 4;\n    o2.frequency.value = (scene.root / 4) * 1.004;\n    const lp = ctx.createBiquadFilter();\n    lp.type = 'lowpass'; lp.frequency.value = 260;\n    const g = ctx.createGain();\n    g.gain.value = 0.0001;\n    g.gain.linearRampToValueAtTime(scene.drone, ctx.currentTime + 3);\n    o1.connect(lp); o2.connect(lp); lp.connect(g); g.connect(musicGain);\n    o1.start(); o2.start();\n    drone = { o1, o2, g };\n  }\n\n  function applyScene() {\n    if (!ctx) return;\n    if (drone) {\n      const t = ctx.currentTime;\n      drone.o1.frequency.linearRampToValueAtTime(scene.root / 4, t + 2.5);\n      drone.o2.frequency.linearRampToValueAtTime((scene.root / 4) * 1.004, t + 2.5);\n      drone.g.gain.linearRampToValueAtTime(scene.drone, t + 2.5);\n    }\n    melDeg = 2;\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 swells in the scene's scale ----\n  function padNote() {\n    if (!ctx) return;\n    const t = ctx.currentTime;\n    const f = degHz(Math.floor(Math.random() * 6)) / 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 score\n    start() {\n      if (!ensure()) return;\n      if (ctx.state === 'suspended') ctx.resume();\n      if (started) return;\n      started = true;\n      ensureDrone();\n      padNote();\n      padTimer = setInterval(() => {\n        if (ctx.state === 'running') { padNote(); if (Math.random() < 0.4) padNote(); }\n      }, 2600);\n      // the kalimba considers a phrase every beat-and-a-bit; the scene's\n      // density decides how often it actually plays one\n      melTimer = setInterval(() => {\n        if (ctx.state === 'running' && Math.random() < scene.density) playPhrase();\n      }, 1700);\n    },\n\n    // switch the score's mood — call with a level index or 'title'\n    setScene(key) {\n      scene = SCENES[key] || SCENES.title;\n      applyScene();\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    step() { tinkAt(600 + Math.random() * 200, 0.028, 0.045, 1500); }, // scuttle tick\n    clack() { // idle pincer clacks — two quick ceramic snaps\n      tinkAt(520, 0.13, 0.08, 2300);\n      setTimeout(() => tinkAt(620, 0.11, 0.07, 2500), 95);\n    },\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    // the big moments chime IN THE LEVEL'S KEY — the score and the SFX\n    // are one instrument\n    reassemble() { chimeSeq([degHz(2, 2), degHz(4, 2), degHz(6, 2)], 0.07, 0.14, 0.5); },\n    checkpoint() { chimeSeq([degHz(3, 2), degHz(5, 2), degHz(7, 2)], 0.09, 0.18, 0.8); },\n    goal() { chimeSeq([degHz(0, 2), degHz(2, 2), degHz(4, 2), degHz(7, 2)], 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    // level-intro fanfare: a rising pentatonic motif over a slow kiln swell,\n    // timed to the camera fly-in (~2.6s)\n    intro() {\n      if (!ctx) return;\n      chimeSeq([220, 293.66, 392, 523.25, 659.25], 0.18, 0.15, 1.5);\n      const t = ctx.currentTime;\n      // warm sub swell underneath\n      const o = ctx.createOscillator();\n      o.type = 'sine'; o.frequency.value = 55;\n      const g = ctx.createGain();\n      g.gain.setValueAtTime(0.0001, t);\n      g.gain.exponentialRampToValueAtTime(0.2, t + 1.1);\n      g.gain.exponentialRampToValueAtTime(0.0001, t + 2.6);\n      o.connect(g); g.connect(musicGain);\n      o.start(t); o.stop(t + 2.7);\n      // soft air rush as the camera sweeps in\n      const n = ctx.createBufferSource();\n      n.buffer = noiseBuf; n.playbackRate.value = 0.6;\n      const lp = ctx.createBiquadFilter();\n      lp.type = 'lowpass';\n      lp.frequency.setValueAtTime(1400, t);\n      lp.frequency.exponentialRampToValueAtTime(180, t + 2.2);\n      const ng = ctx.createGain();\n      ng.gain.setValueAtTime(0.0001, t);\n      ng.gain.exponentialRampToValueAtTime(0.09, t + 0.5);\n      ng.gain.exponentialRampToValueAtTime(0.0001, t + 2.3);\n      n.connect(lp); lp.connect(ng); ng.connect(musicGain);\n      n.start(t); n.stop(t + 2.4);\n    },\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 — SM64-style orbit camera. The camera circles the player on a\n// yaw the PLAYER controls (Q/E, right stick, touch buttons), with\n// smooth-follow, look-ahead in the movement direction, and subtle\n// screen shake on heavy landings (respecting the settings toggle).\n// Movement input is mapped through this yaw, so \"up\" on the stick is\n// always \"away from the camera\" — the world is a playfield, not a strip.\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  let yaw = 0;     // orbit angle: 0 = camera due south of the player (+z)\n  let yawVel = 0;  // smoothed rotation speed — the swing eases in and out\n  let manualHold = 0; // seconds until the auto-camera resumes after manual input\n\n  const cam = {\n    get yaw() { return yaw; },\n    setYaw(a) { yaw = a; yawVel = 0; },\n\n    // instantly snap to a position (level start, respawn)\n    snap(px, py, pz, newYaw) {\n      if (newYaw !== undefined) { yaw = newYaw; yawVel = 0; }\n      curTarget.set(px, py + 1, pz);\n      cur.set(px + Math.sin(yaw) * 10.5, py + 7.2, pz + Math.cos(yaw) * 10.5);\n      lookAhead.set(0, 0, 0);\n    },\n\n    // dir in -1..1 from input; eased so the orbit starts and stops softly\n    rotate(dir, dt) {\n      const targetVel = dir * 2.4 * (settings.camSens || 1);\n      yawVel += (targetVel - yawVel) * Math.min(1, dt * 9);\n      yaw += yawVel * dt;\n      if (dir) manualHold = 1.5; // player is steering — auto-camera stands down\n    },\n\n    onShake: null, // cb(amount) — main wires gamepad rumble here\n\n    addShake(amount) {\n      if (!settings.screenShake) return; // the toggle covers rumble too\n      shake = Math.min(1.2, shake + amount);\n      if (cam.onShake) cam.onShake(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.28, -2.2, 2.2),\n        0,\n        THREE.MathUtils.clamp(b.vel.z * 0.28, -2.2, 2.2)\n      );\n      lookAhead.lerp(targetLA, 1 - Math.exp(-3 * dt));\n\n      const bossFraming = boss && boss.active && !boss.dead;\n\n      // auto-camera: when the player isn't steering the view, gently swing\n      // round to sit behind the direction of travel (SM64 Lakitu style).\n      // Manual input (Q/E, right stick, touch) always wins and pauses this.\n      manualHold = Math.max(0, manualHold - dt);\n      const speed = Math.hypot(b.vel.x, b.vel.z);\n      if (settings.autoCam === true && manualHold <= 0 && speed > 2.5 && !bossFraming) {\n        const behind = Math.atan2(-b.vel.x, -b.vel.z);\n        let d = behind - yaw;\n        d = Math.atan2(Math.sin(d), Math.cos(d)); // shortest way round\n        // don't whip when Clawd runs straight at the camera; ease harder\n        // the faster he moves so slow shuffles barely nudge the view\n        if (Math.abs(d) < 2.4) {\n          const strength = 1.6 * Math.min(1, (speed - 2.5) / 5);\n          yaw += d * (1 - Math.exp(-strength * dt));\n        }\n      }\n\n      // during the boss fight, pull back and frame both combatants\n      let dist = 10.5, height = 7.2;\n      if (bossFraming) { dist = 14; height = 9.5; }\n\n      const want = new THREE.Vector3(\n        b.pos.x + lookAhead.x + Math.sin(yaw) * dist,\n        b.pos.y + height,\n        b.pos.z + lookAhead.z + Math.cos(yaw) * 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), gamepad, and touch input,\n// tracking the last-used device so the UI can show the right glyphs.\n// The gamepad also drives the menus: d-pad / left stick to move focus,\n// Ⓐ to select, Ⓑ to go back.\n\nimport { createTouch } from './touch.js';\n\nexport function createInput() {\n  const keys = new Set();\n  const touch = createTouch();\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    camX: 0,           // camera orbit input (-1..1): Q/E, right stick, touch\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    // gamepad-only menu navigation, edge-triggered (keyboard/mouse\n    // already drive the menus natively via focus + click)\n    menu: { up: false, down: false, left: false, right: false, confirm: false, back: false },\n  };\n\n  let jumpWasDown = false;\n  let pauseWasDown = false;\n  let confirmWasDown = false;\n  let navWasX = 0;\n  let navWasZ = 0;\n  let gpConfirmWas = false;\n  let gpBackWas = 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  // hat-switch directions: up, up-right, right, down-right, down, down-left, left, up-left\n  const HAT_DIRS = [[0, -1], [1, -1], [1, 0], [1, 1], [0, 1], [-1, 1], [-1, 0], [-1, -1]];\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 btn = (i) => { const b = pad.buttons[i]; return !!b && (b.pressed || b.value > 0.5); };\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      // d-pad: standard-mapping buttons 12-15…\n      let dpadX = (btn(15) ? 1 : 0) - (btn(14) ? 1 : 0);\n      let dpadZ = (btn(13) ? 1 : 0) - (btn(12) ? 1 : 0);\n      // …or a hat switch on axes[9] (common on non-standard mappings).\n      // The hat idles OUTSIDE -1..1; values within -1..1 encode 8 directions.\n      if (!dpadX && !dpadZ && pad.axes.length > 9) {\n        const v = pad.axes[9];\n        if (typeof v === 'number' && v >= -1.001 && v <= 1.001) {\n          const d = HAT_DIRS[Math.round((v + 1) / (2 / 7))];\n          if (d) { dpadX = d[0]; dpadZ = d[1]; }\n        }\n      }\n      const rawCam = -(pad.axes[2] || 0);                   // right stick X → orbit camera (reversed)\n      const cam = Math.abs(rawCam) > dead ? rawCam : 0;\n      const jump = btn(0);   // A / Cross\n      const back = btn(1);   // B / Circle\n      const pause = btn(9);  // Start\n      const confirm = jump;\n      if (gx || gz || dpadX || dpadZ || jump || pause || cam || back) state.device = 'gamepad';\n      return { x: gx || dpadX, z: gz || dpadZ, dpadX, dpadZ, cam, jump, pause, confirm, back };\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    // Q/E swing the camera around Clawd (SM64 C-buttons)\n    const kCam = (keys.has('KeyE') ? 1 : 0) - (keys.has('KeyQ') ? 1 : 0);\n\n    const gp = pollGamepad();\n    let x = kx, z = kz, cam = kCam, jump = kJump, pause = kPause, confirm = kConfirm;\n    if (gp && (gp.x || gp.z || gp.cam || gp.jump || gp.pause)) {\n      x = gp.x || kx; z = gp.z || kz;\n      cam = gp.cam || kCam;\n      jump = gp.jump || kJump;\n      pause = gp.pause || kPause;\n      confirm = gp.confirm || kConfirm;\n    }\n\n    // menu navigation edges — gamepad only: d-pad directly, or the\n    // left stick past a threshold (so slow drift never scrolls a menu)\n    const nx = gp ? (gp.dpadX || (gp.x > 0.5 ? 1 : gp.x < -0.5 ? -1 : 0)) : 0;\n    const nz = gp ? (gp.dpadZ || (gp.z > 0.5 ? 1 : gp.z < -0.5 ? -1 : 0)) : 0;\n    state.menu.up = nz === -1 && navWasZ !== -1;\n    state.menu.down = nz === 1 && navWasZ !== 1;\n    state.menu.left = nx === -1 && navWasX !== -1;\n    state.menu.right = nx === 1 && navWasX !== 1;\n    state.menu.confirm = !!(gp && gp.confirm) && !gpConfirmWas;\n    state.menu.back = !!(gp && gp.back) && !gpBackWas;\n    navWasX = nx; navWasZ = nz;\n    gpConfirmWas = !!(gp && gp.confirm);\n    gpBackWas = !!(gp && gp.back);\n\n    // touch joystick + jump/pause/camera buttons override everything while in use\n    if (touch.active || touch.jump || touch.pause || touch.cam) {\n      state.device = 'touch';\n      x = touch.x || x;\n      z = touch.z || z;\n      jump = touch.jump || jump;\n      confirm = touch.jump || confirm;\n    }\n    cam = touch.cam || cam;\n    pause = touch.pause || pause;\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.camX = Math.max(-1, Math.min(1, cam));\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  // gamepad rumble — fire-and-forget; quietly does nothing without a pad.\n  // A short throttle stops rapid events (spark landings) from stacking\n  // into one long buzz.\n  let rumbleUntil = 0;\n  state.rumble = (mag, ms) => {\n    const now = performance.now();\n    if (now < rumbleUntil - 40) return;\n    const pads = navigator.getGamepads ? navigator.getGamepads() : [];\n    for (const pad of pads) {\n      if (!pad || !pad.connected) continue;\n      const act = pad.vibrationActuator;\n      if (act && act.playEffect) {\n        rumbleUntil = now + ms;\n        act.playEffect('dual-rumble', {\n          duration: ms,\n          strongMagnitude: Math.min(1, mag),\n          weakMagnitude: Math.min(1, mag * 0.55),\n        });\n      }\n      return;\n    }\n    // no gamepad — on touch devices, big hits tap the phone's vibration motor\n    if (state.device === 'touch' && navigator.vibrate && mag >= 0.35) {\n      rumbleUntil = now + ms;\n      navigator.vibrate(Math.min(180, Math.round(ms * mag)));\n    }\n  };\n\n  state.glyphs = () =>\n    state.device === 'gamepad'\n      ? { move: 'Left Stick', jump: 'Ⓐ', cam: 'Right Stick', pause: 'Start' }\n      : state.device === 'touch'\n        ? { move: 'Joystick', jump: 'Jump button', cam: '⟲ ⟳', pause: '❚❚' }\n        : { move: 'WASD / Arrows', jump: 'Space', cam: 'Q / E', 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 { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';\nimport { RenderPass } from 'three/addons/postprocessing/RenderPass.js';\nimport { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';\nimport { OutputPass } from 'three/addons/postprocessing/OutputPass.js';\nimport { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js';\nimport { SOOT, ASH, EMBER, GLAZE_PALE, MAT } from './palette.js';\nimport { createField, buildFieldMesh, buildModelMesh, makeDebrisVoxel, getMaterials, emberTime } from './voxels.js';\nimport { createWorld, moveBody, distSq } from './physics.js';\nimport { createPlayer, makeGhostMesh } from './player.js';\nimport { createInput } from './input.js';\nimport { createGameCamera } from './camera.js';\nimport { createAudio } from './audio.js';\nimport { createUI, fmtTime, medalFor } 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 { level8 } from './levels/level8.js';\nimport { level9 } from './levels/level9.js';\nimport { bonusLevel } from './levels/bonus.js';\n\nconst LEVELS = [level1, level2, level3, level4, level5, level6, level7, level8, level9, 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, 40, 120);\n\n// a vast gradient vault — soot-black above, a faint ember smoulder low on the\n// horizon, so the dark beyond the stage has depth instead of reading as a void\nfunction makeKilnDome() {\n  const R = 230;\n  const geo = new THREE.SphereGeometry(R, 48, 32);\n  const mat = new THREE.ShaderMaterial({\n    side: THREE.BackSide, depthWrite: false, fog: false,\n    uniforms: { uTime: { value: 0 } },\n    vertexShader: /* glsl */`\n      varying vec3 vDir;\n      void main() {\n        vDir = normalize(position);\n        gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);\n      }`,\n    fragmentShader: /* glsl */`\n      uniform float uTime;\n      varying vec3 vDir;\n      float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }\n      float noise(vec2 p) {\n        vec2 i = floor(p), f = fract(p);\n        vec2 u = f * f * (3.0 - 2.0 * f);\n        return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x),\n                   mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y);\n      }\n      float fbm(vec2 p) {\n        float s = 0.0, a = 0.5;\n        for (int i = 0; i < 4; i++) { s += a * noise(p); p *= 2.03; a *= 0.5; }\n        return s;\n      }\n      void main() {\n        float h = vDir.y * 0.5 + 0.5;\n        vec3 top  = vec3(0.043, 0.027, 0.020);\n        vec3 mid  = vec3(0.125, 0.075, 0.063);\n        vec3 glow = vec3(0.255, 0.125, 0.071);\n        vec3 col = h > 0.5 ? mix(mid, top, (h - 0.5) * 2.0) : mix(glow, mid, h * 2.0);\n        // slow kiln smoke, rising and curling around the vault\n        float ang = atan(vDir.z, vDir.x);\n        vec2 sp = vec2(ang * 2.2, vDir.y * 3.0 - uTime * 0.045);\n        float smoke = fbm(sp + fbm(sp * 1.7 + uTime * 0.03));\n        col *= 0.82 + smoke * 0.42;\n        // ember flecks smouldering low on the horizon, breathing in and out\n        float horiz = smoothstep(0.28, 0.0, abs(vDir.y + 0.05));\n        float flecks = pow(noise(vec2(ang * 14.0, vDir.y * 30.0)), 6.0);\n        col += vec3(0.91, 0.42, 0.13) * flecks * horiz * (0.5 + 0.5 * sin(uTime * 1.3 + ang * 20.0));\n        col += vec3(0.30, 0.13, 0.05) * horiz * smoke * 0.35;\n        gl_FragColor = vec4(col, 1.0);\n      }`,\n  });\n  const mesh = new THREE.Mesh(geo, mat);\n  mesh.renderOrder = -10;\n  return mesh;\n}\nconst kilnDome = makeKilnDome();\nscene.add(kilnDome);\n\nconst camera = new THREE.PerspectiveCamera(52, window.innerWidth / window.innerHeight, 0.1, 300);\n\n// post-processing: subtle bloom lifts embers, lanterns and glaze highlights\nconst composer = new EffectComposer(renderer);\ncomposer.addPass(new RenderPass(scene, camera));\nconst bloomPass = new UnrealBloomPass(\n  new THREE.Vector2(window.innerWidth, window.innerHeight),\n  0.62,  // strength — a warm halo, not a smear\n  0.42,  // radius — tight, so voxel edges stay crisp\n  0.78   // threshold — only genuinely bright things bloom\n);\ncomposer.addPass(bloomPass);\n\n// tilt-shift depth of field — the kiln reads like a hand-built miniature.\n// Blur grows away from a horizontal focus band: strong on the title diorama,\n// a whisper during play so platforming stays razor sharp.\nfunction makeTiltShiftPass(dx, dy) {\n  return new ShaderPass({\n    uniforms: {\n      tDiffuse: { value: null },\n      uTexel: { value: new THREE.Vector2(1 / window.innerWidth, 1 / window.innerHeight) },\n      uDir: { value: new THREE.Vector2(dx, dy) },\n      uAmount: { value: 0 },\n      uFocus: { value: 0.44 },\n      uBand: { value: 0.16 },\n    },\n    vertexShader: /* glsl */`\n      varying vec2 vUv;\n      void main() {\n        vUv = uv;\n        gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);\n      }`,\n    fragmentShader: /* glsl */`\n      uniform sampler2D tDiffuse;\n      uniform vec2 uTexel;\n      uniform vec2 uDir;\n      uniform float uAmount;\n      uniform float uFocus;\n      uniform float uBand;\n      varying vec2 vUv;\n      void main() {\n        float coc = smoothstep(uBand, 0.5, abs(vUv.y - uFocus)) * uAmount;\n        vec2 stp = uDir * uTexel * coc * 7.0;\n        vec3 col = texture2D(tDiffuse, vUv).rgb * 0.227027;\n        col += (texture2D(tDiffuse, vUv + stp * 1.3846).rgb + texture2D(tDiffuse, vUv - stp * 1.3846).rgb) * 0.316216;\n        col += (texture2D(tDiffuse, vUv + stp * 3.2308).rgb + texture2D(tDiffuse, vUv - stp * 3.2308).rgb) * 0.070270;\n        gl_FragColor = vec4(col, 1.0);\n      }`,\n  });\n}\nconst tiltH = makeTiltShiftPass(1, 0);\nconst tiltV = makeTiltShiftPass(0, 1);\ncomposer.addPass(tiltH);\ncomposer.addPass(tiltV);\nlet tiltAmount = 0;\n\ncomposer.addPass(new OutputPass());\n\n// final film grade: vignette, warm shadow lift, fine grain, corner chromatic aberration\nconst gradePass = new ShaderPass({\n  uniforms: {\n    tDiffuse: { value: null },\n    uTime: { value: 0 },\n    uTexel: { value: new THREE.Vector2(1 / window.innerWidth, 1 / window.innerHeight) },\n  },\n  vertexShader: /* glsl */`\n    varying vec2 vUv;\n    void main() {\n      vUv = uv;\n      gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);\n    }`,\n  fragmentShader: /* glsl */`\n    uniform sampler2D tDiffuse;\n    uniform float uTime;\n    uniform vec2 uTexel;\n    varying vec2 vUv;\n    float hash(vec2 p) {\n      return fract(sin(dot(p, vec2(127.1, 311.7)) + mod(uTime, 100.0) * 13.7) * 43758.5453);\n    }\n    void main() {\n      vec2 d = vUv - 0.5;\n      float r2 = dot(d, d);\n      // heat shimmer — the bottom of the frame wavers like air over live coals\n      float heat = smoothstep(0.38, 0.0, vUv.y);\n      vec2 uv = vUv;\n      uv.x += sin(vUv.y * 52.0 + uTime * 2.3) * 0.0010 * heat;\n      uv.y += sin(vUv.x * 44.0 + uTime * 1.8) * 0.0007 * heat;\n      // chromatic aberration — grows toward the corners, invisible centre-screen\n      vec2 ca = d * (r2 * 0.007);\n      vec3 col;\n      col.r = texture2D(tDiffuse, uv + ca).r;\n      col.g = texture2D(tDiffuse, uv).g;\n      col.b = texture2D(tDiffuse, uv - ca).b;\n      // clarity — unsharp-mask micro-contrast keeps voxel edges chiselled\n      // under the bloom and aberration instead of going soft\n      vec3 blur = ( texture2D(tDiffuse, uv + vec2(uTexel.x, 0.0)).rgb\n                  + texture2D(tDiffuse, uv - vec2(uTexel.x, 0.0)).rgb\n                  + texture2D(tDiffuse, uv + vec2(0.0, uTexel.y)).rgb\n                  + texture2D(tDiffuse, uv - vec2(0.0, uTexel.y)).rgb ) * 0.25;\n      col = max(col + (col - blur) * 0.38, 0.0);\n      // anamorphic ember flare — the hottest points smear into horizontal streaks\n      vec3 streak = vec3(0.0);\n      for (int i = 1; i <= 6; i++) {\n        float o = float(i) * 0.011;\n        float w = 1.0 - float(i) / 7.0;\n        streak += max(texture2D(tDiffuse, uv + vec2(o, 0.0)).rgb - vec3(0.8), 0.0) * w;\n        streak += max(texture2D(tDiffuse, uv - vec2(o, 0.0)).rgb - vec3(0.8), 0.0) * w;\n      }\n      col += streak * vec3(1.0, 0.5, 0.22) * 0.10;\n      // warm grade: nudge shadows toward ember, cool the blues a touch\n      col = pow(col, vec3(0.985, 1.0, 1.03));\n      // gentle filmic S-curve — deeper soot, hotter embers, midtones intact\n      col = mix(col, col * col * (3.0 - 2.0 * col), 0.32);\n      // saturation lift — fired clay reads richer, glaze reads bluer\n      float lum = dot(col, vec3(0.299, 0.587, 0.114));\n      col = mix(vec3(lum), col, 1.12);\n      // split-tone: soot shadows sink toward cool blue-teal, embers stay hot\n      col += (1.0 - smoothstep(0.0, 0.5, lum)) * vec3(-0.012, 0.002, 0.022);\n      // vignette — pulls the eye into the kiln's heart\n      float vig = 1.0 - smoothstep(0.15, 0.62, r2);\n      col *= mix(0.66, 1.0, vig);\n      // fine film grain — hides banding in the soot-dark gradients\n      col += (hash(vUv * 731.7) - 0.5) * 0.018;\n      gl_FragColor = vec4(col, 1.0);\n    }`,\n});\ncomposer.addPass(gradePass);\n\n// ---------- resolution scaling ----------\n// The post chain (bloom + 2 tilt-shift blurs + grade) is fill-rate bound, so\n// huge monitors get a pixel budget: never push more device pixels through the\n// composer than a ~1440p screen, whatever the window size. On top of that,\n// resScale adapts at runtime to hold 60fps (see frame()).\nconst MAX_RENDER_PIXELS = 2.6e6;\nconst RES_SCALE_MIN = 0.6;\nlet resScale = 1; // dynamic quality knob, RES_SCALE_MIN..1\n\nfunction applyRenderSize() {\n  const w = window.innerWidth, h = window.innerHeight;\n  const budgetPR = Math.sqrt(MAX_RENDER_PIXELS / (w * h));\n  const pr = Math.max(0.5, Math.min(window.devicePixelRatio, 2, budgetPR) * resScale);\n  camera.aspect = w / h;\n  camera.updateProjectionMatrix();\n  renderer.setPixelRatio(pr);\n  renderer.setSize(w, h);\n  composer.setPixelRatio(pr);\n  composer.setSize(w, h);\n  // uTexel stays in CSS pixels so blur/clarity radii look the same at any scale\n  tiltH.uniforms.uTexel.value.set(1 / w, 1 / h);\n  tiltV.uniforms.uTexel.value.set(1 / w, 1 / h);\n  gradePass.uniforms.uTexel.value.set(1 / w, 1 / h);\n}\napplyRenderSize();\n\nwindow.addEventListener('resize', applyRenderSize);\n\n// warm key light — low sun through a crack in the kiln wall\nconst keyLight = new THREE.DirectionalLight(0xffc98a, 1.62);\nkeyLight.castShadow = true;\nkeyLight.shadow.mapSize.set(4096, 4096);\nkeyLight.shadow.bias = -0.0018;\nkeyLight.shadow.normalBias = 0.02; // kills voxel-face shadow acne\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.55)); // leaner flat fill — shadows keep their depth\n// cool rim light from the far side — separates ceramic edges from the soot dark\nconst rimLight = new THREE.DirectionalLight(GLAZE_PALE, 0.35);\nrimLight.position.set(26, 20, -22);\nscene.add(rimLight);\n\n// ---------- persistent state ----------\nconst settings = { volume: 0.8, camSens: 1.0, screenShake: true, autoCam: false, ghost: 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// impactful moments buzz the gamepad too (gated inside addShake by the\n// screen-shake setting, so one toggle rules all the kick)\ngameCam.onShake = (a) => input.rumble(Math.min(1, a * 0.9), 90 + a * 260);\n\n// a warm glow that follows Clawd — keeps him readable in the dark kiln\nconst clawdGlow = new THREE.PointLight(EMBER, 0.75, 7);\nscene.add(clawdGlow);\n\n// shared ember material — its emissive pulse is animated per-frame in frame()\nconst emberMat = getMaterials()[MAT.EMBER];\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 hadAllDrops = false; // all 45 main-level drops — opens the Potter's Wheel\nlet hadAllFired = false; // all 9 main chambers fired — earns the Porcelain shell\n\n// speedrun clock: starts when control begins, pauses with the game,\n// keeps running through deaths, stops at the goal (or the boss's fall)\nlet levelTime = 0;\nlet timing = false;\nlet fovCur = 52; // speed-kick field of view, eased every frame\n\n// per-chamber atmosphere: each level grades the key light and the fog so\n// no two chambers feel lit by the same fire\nconst MOODS = [\n  { key: 0xffc98a, ki: 1.62, fog: [40, 120] },  // Cooling Racks — the familiar warmth\n  { key: 0xdfe8ff, ki: 1.50, fog: [42, 130] },  // Glaze Falls — cold blue pour\n  { key: 0xffd9a0, ki: 1.55, fog: [45, 140] },  // The Bellows — open, airy\n  { key: 0xe8b988, ki: 1.35, fog: [34, 100] },  // Cracked Shelves — dust and ruin\n  { key: 0xffb066, ki: 1.75, fog: [30, 95] },   // Firing Chamber — close heat\n  { key: 0xd8c8b0, ki: 1.30, fog: [32, 95] },   // Shard Hollow — cold prowl\n  { key: 0xff9d55, ki: 1.50, fog: [36, 110] },  // The Kilnheart — blood-warm dread\n  { key: 0xeaf2ff, ki: 1.72, fog: [46, 150] },  // Glassworks — brilliant vitrified light\n  { key: 0xffa550, ki: 1.80, fog: [30, 100] },  // The Chimney — furnace draught\n  { key: 0xcfd8f0, ki: 1.35, fog: [36, 110] },  // Potter's Wheel — moonlit music box\n];\n\n// title attract mode: the diorama slowly tours the chambers you've unlocked\nlet dioramaCenter = [0, 2, -4];\nlet dioramaRadius = 19;\nlet attractIdx = 0;\nlet attractSwapAt = 18;\n\nfunction attractPool() {\n  // every unlocked chamber except the boss arena (its heart is a spoiler);\n  // the wheel joins the tour once it's earned\n  const pool = [];\n  for (const i of [0, 1, 2, 3, 4, 5, 7, 8]) if (i + 1 <= progress.unlocked) pool.push(i);\n  if (totalDrops(progress, true) >= 45) pool.push(9);\n  return pool;\n}\n\nfunction finalizeTime() {\n  timing = false;\n  const t = Math.round(levelTime * 100) / 100;\n  const prev = progress.times[levelIdx];\n  const isBest = prev == null || t < prev;\n  if (isBest) progress.times[levelIdx] = t;\n  // a best run becomes this level's ember ghost for the rest of the session\n  // (with its lantern split times, so the next run can race the clock too)\n  if (isBest && ghostRec && ghostRec.frames.length >= 9) {\n    const b = player.body;\n    ghostRec.frames.push(b.pos.x, b.pos.y, b.pos.z);\n    sessionGhosts[levelIdx] = { frames: ghostRec.frames, splits: ghostRec.splits };\n  }\n  ghostRec = null;\n  return { t, isBest, hadPrev: prev != null };\n}\n\n// ---------- the ember ghost: race the shimmer of your best run ----------\nconst GHOST_DT = 0.08;           // seconds per recorded frame\nconst sessionGhosts = {};        // levelIdx -> flat [x,y,z, x,y,z, ...]\nlet ghostRec = null;             // { frames: [], acc: 0 } — this attempt\nlet ghostRun = null;             // { mesh, mat, frames } — the replay\n\nfunction spawnGhost(frames) {\n  const { group, mat } = makeGhostMesh();\n  group.position.set(frames[0], frames[1], frames[2]);\n  levelRoot.add(group);\n  ghostRun = { mesh: group, mat, frames };\n}\n\nfunction updateGhost(dt) {\n  if (!ghostRun) return;\n  const f = ghostRun.frames;\n  const n = f.length / 3;\n  if (n < 2) return;\n  const ft = levelTime / GHOST_DT;\n  const i = Math.max(0, Math.min(n - 2, Math.floor(ft)));\n  const u = Math.max(0, Math.min(1, ft - i));\n  ghostRun.mesh.position.set(\n    f[i * 3] + (f[i * 3 + 3] - f[i * 3]) * u,\n    f[i * 3 + 1] + (f[i * 3 + 4] - f[i * 3 + 1]) * u,\n    f[i * 3 + 2] + (f[i * 3 + 5] - f[i * 3 + 2]) * u\n  );\n  ghostRun.mesh.rotation.y = gameCam.yaw; // the ghost faces the camera like Clawd does\n  // shimmer while racing, gutter out once its run is done\n  const over = ft > n - 1.5;\n  const want = over ? 0 : 0.24 + Math.sin(elapsed * 6) * 0.07;\n  ghostRun.mat.opacity += (want - ghostRun.mat.opacity) * Math.min(1, dt * 4);\n}\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// ---------- soft round particle sprite (shared) ----------\nfunction makeSoftDot() {\n  const c = document.createElement('canvas');\n  c.width = c.height = 64;\n  const g = c.getContext('2d');\n  const grad = g.createRadialGradient(32, 32, 0, 32, 32, 32);\n  grad.addColorStop(0, 'rgba(255,255,255,1)');\n  grad.addColorStop(0.4, 'rgba(255,255,255,0.65)');\n  grad.addColorStop(1, 'rgba(255,255,255,0)');\n  g.fillStyle = grad;\n  g.fillRect(0, 0, 64, 64);\n  return new THREE.CanvasTexture(c);\n}\nconst softDot = makeSoftDot();\n\n// additive halo sprite — fakes light scattering in kiln haze around real lights\nfunction makeGlowSprite(color, scale, opacity) {\n  const s = new THREE.Sprite(new THREE.SpriteMaterial({\n    map: softDot, color, transparent: true, opacity,\n    blending: THREE.AdditiveBlending, depthWrite: false,\n  }));\n  s.scale.setScalar(scale);\n  return s;\n}\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.1, transparent: true, opacity: 0.6, sizeAttenuation: true,\n  map: softDot, depthWrite: false, // soft round 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// ---------- near-camera dust: big soft motes close to the lens ----------\n// a sparse foreground layer — sells the depth between camera and stage\nconst DUST_COUNT = 90;\nconst dustGeo = new THREE.BufferGeometry();\nconst dustPos = new Float32Array(DUST_COUNT * 3);\nfor (let i = 0; i < DUST_COUNT; i++) {\n  dustPos[i * 3] = (Math.random() - 0.5) * 24;\n  dustPos[i * 3 + 1] = Math.random() * 16 - 3;\n  dustPos[i * 3 + 2] = (Math.random() - 0.5) * 24;\n}\ndustGeo.setAttribute('position', new THREE.BufferAttribute(dustPos, 3));\nconst dustPts = new THREE.Points(dustGeo, new THREE.PointsMaterial({\n  color: 0xcbb89a, size: 0.3, transparent: true, opacity: 0.22,\n  sizeAttenuation: true, map: softDot, depthWrite: false,\n}));\nscene.add(dustPts);\n\nfunction updateDust(dt) {\n  const p = dustGeo.attributes.position.array;\n  const cx = camera.position.x, cy = camera.position.y, cz = camera.position.z;\n  for (let i = 0; i < DUST_COUNT; i++) {\n    p[i * 3] += Math.sin(elapsed * 0.4 + i * 1.3) * 0.2 * dt;\n    p[i * 3 + 1] -= (0.12 + (i % 4) * 0.03) * dt;\n    p[i * 3 + 2] += Math.cos(elapsed * 0.35 + i * 0.9) * 0.18 * dt;\n    if (p[i * 3] < cx - 12) p[i * 3] += 24; if (p[i * 3] > cx + 12) p[i * 3] -= 24;\n    if (p[i * 3 + 1] < cy - 9) p[i * 3 + 1] += 19;\n    if (p[i * 3 + 2] < cz - 12) p[i * 3 + 2] += 24; if (p[i * 3 + 2] > cz + 12) p[i * 3 + 2] -= 24;\n  }\n  dustGeo.attributes.position.needsUpdate = true;\n}\n\n// ---------- ember wisps: heat shimmer above lava-glaze pools ----------\nlet emberPts = null;\nlet emberHomes = null;\nlet emberLights = [];\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  // a few flickering firelights over the biggest pools — real light, not just emissive glow\n  const byArea = pools.slice().sort((a, b) => b[3] * b[5] - a[3] * a[5]);\n  for (let i = 0; i < Math.min(3, byArea.length); i++) {\n    const b = byArea[i];\n    const l = new THREE.PointLight(EMBER, 1.1, 13, 1.6);\n    l.position.set(b[0] + b[3] / 2, b[1] + b[4] + 1.4, b[2] + b[5] / 2);\n    l.add(makeGlowSprite(EMBER, 6, 0.2)); // haze halo over the pool\n    levelRoot.add(l);\n    emberLights.push(l);\n  }\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.16, transparent: true, opacity: 0.85, depthWrite: false,\n    map: softDot, blending: THREE.AdditiveBlending, // glowing sparks, not squares\n  }));\n  levelRoot.add(emberPts);\n}\nfunction updateEmberWisps(dt) {\n  // firelight flicker — two incommensurate sines read as flame, not metronome\n  for (let i = 0; i < emberLights.length; i++) {\n    emberLights[i].intensity =\n      1.05 + Math.sin(elapsed * 9 + i * 2.7) * 0.18 + Math.sin(elapsed * 23 + i * 5.3) * 0.09;\n  }\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// ---------- god-ray shafts: dusty light through cracks in the kiln wall ----------\nlet shafts = [];\nfunction makeShaftTexture() {\n  const c = document.createElement('canvas');\n  c.width = 64; c.height = 256;\n  const g = c.getContext('2d');\n  const h = g.createLinearGradient(0, 0, 64, 0);\n  h.addColorStop(0, 'rgba(255,255,255,0)');\n  h.addColorStop(0.5, 'rgba(255,255,255,1)');\n  h.addColorStop(1, 'rgba(255,255,255,0)');\n  g.fillStyle = h;\n  g.fillRect(0, 0, 64, 256);\n  const v = g.createLinearGradient(0, 0, 0, 256);\n  v.addColorStop(0, 'rgba(255,255,255,1)');\n  v.addColorStop(0.85, 'rgba(255,255,255,0.25)');\n  v.addColorStop(1, 'rgba(255,255,255,0)');\n  g.globalCompositeOperation = 'destination-in';\n  g.fillStyle = v;\n  g.fillRect(0, 0, 64, 256);\n  return new THREE.CanvasTexture(c);\n}\nconst shaftTex = makeShaftTexture();\n\nfunction buildLightShafts(ext) {\n  const spanX = ext.maxX - ext.minX;\n  const midZ = (ext.minZ + ext.maxZ) / 2;\n  const topY = ext.maxY + 12;\n  for (let i = 0; i < 4; i++) {\n    const w = 2.4 + (i % 3) * 1.5;\n    const geo = new THREE.PlaneGeometry(w, 36);\n    const mat = new THREE.MeshBasicMaterial({\n      map: shaftTex, color: 0xffc98a, transparent: true, opacity: 0.05,\n      blending: THREE.AdditiveBlending, depthWrite: false,\n      side: THREE.DoubleSide, fog: false,\n    });\n    const mesh = new THREE.Mesh(geo, mat);\n    mesh.position.set(\n      ext.minX + spanX * ((i + 0.5) / 4) + (i % 2 ? 2.5 : -2),\n      topY - 18,\n      midZ - 5 + (i % 3) * 4\n    );\n    mesh.rotation.z = 0.69; // slanted to match the key light through the wall crack\n    mesh.rotation.y = (i - 1.5) * 0.1;\n    levelRoot.add(mesh);\n    shafts.push({ mesh, base: 0.045 + (i % 2) * 0.025, phase: i * 2.4 });\n  }\n}\n\n// ---------- kiln mist: smouldering haze in the abyss below the stage ----------\n// Two vast animated fog sheets under the platforms — the drop beneath the\n// level reads as a glowing, breathing gulf of heat instead of flat black,\n// and every pit / ledge suddenly has visible depth.\nlet mists = [];\nfunction makeMistPlane(w, d, opacity, seed) {\n  const geo = new THREE.PlaneGeometry(w, d);\n  const mat = new THREE.ShaderMaterial({\n    transparent: true, depthWrite: false, fog: false,\n    uniforms: {\n      uTime: { value: 0 },\n      uSeed: { value: seed },\n      uOpacity: { value: opacity },\n    },\n    vertexShader: /* glsl */`\n      varying vec2 vUv;\n      void main() {\n        vUv = uv;\n        gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);\n      }`,\n    fragmentShader: /* glsl */`\n      uniform float uTime;\n      uniform float uSeed;\n      uniform float uOpacity;\n      varying vec2 vUv;\n      float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7)) + uSeed) * 43758.5453); }\n      float noise(vec2 p) {\n        vec2 i = floor(p), f = fract(p);\n        vec2 u = f * f * (3.0 - 2.0 * f);\n        return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x),\n                   mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y);\n      }\n      float fbm(vec2 p) {\n        float s = 0.0, a = 0.5;\n        for (int i = 0; i < 4; i++) { s += a * noise(p); p *= 2.07; a *= 0.5; }\n        return s;\n      }\n      void main() {\n        vec2 q = vUv * 9.0;\n        float n = fbm(q + vec2(uTime * 0.05, uTime * 0.033) + fbm(q * 1.6 - uTime * 0.02));\n        // soft radial falloff — the sheet has no visible edges\n        vec2 dc = vUv - 0.5;\n        float edge = smoothstep(0.5, 0.18, length(dc));\n        float a = smoothstep(0.35, 0.85, n) * edge * uOpacity;\n        // smoulder: mist glows faintly ember where it churns thickest\n        vec3 col = mix(vec3(0.10, 0.065, 0.05), vec3(0.55, 0.26, 0.09), smoothstep(0.45, 0.95, n));\n        gl_FragColor = vec4(col, a);\n      }`,\n  });\n  const mesh = new THREE.Mesh(geo, mat);\n  mesh.rotation.x = -Math.PI / 2;\n  return mesh;\n}\nfunction buildKilnMist(ext) {\n  const w = ext.maxX - ext.minX + 60, d = ext.maxZ - ext.minZ + 60;\n  const cx = (ext.minX + ext.maxX) / 2, cz = (ext.minZ + ext.maxZ) / 2;\n  const layers = [\n    { y: ext.minY - 3.5, op: 0.5, seed: 3.7, drift: 0.7 },   // deep, brighter smoulder\n    { y: ext.minY - 1.2, op: 0.32, seed: 11.9, drift: -0.45 }, // shallow, counter-drifting veil\n  ];\n  for (const L of layers) {\n    const m = makeMistPlane(w, d, L.op, L.seed);\n    m.position.set(cx, L.y, cz);\n    levelRoot.add(m);\n    mists.push({ mesh: m, drift: L.drift });\n  }\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.add(makeGlowSprite(EMBER, 3.2, 0.28)); // warm halo around the lit lantern\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  emberLights = []; shafts = []; mists = [];\n  lanternLight.visible = false;\n  ghostRun = null; ghostRec = null; // ghost mesh lives in levelRoot — already disposed\n}\n\nfunction loadLevel(idx, opts = {}) {\n  unloadLevel();\n  levelIdx = idx;\n  levelDef = LEVELS[idx];\n  audio.setScene(opts.diorama ? 'title' : idx); // each chamber has its own key\n  // …and its own light\n  const mood = MOODS[idx] || MOODS[0];\n  keyLight.color.setHex(mood.key);\n  keyLight.intensity = mood.ki;\n  scene.fog.near = mood.fog[0];\n  scene.fog.far = mood.fog[1];\n  levelTime = 0;\n  timing = false;\n  fovCur = 52;\n  if (camera.fov !== 52) { camera.fov = 52; camera.updateProjectionMatrix(); }\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  // ember wisps + firelights over lava-glaze pools\n  buildEmberWisps(levelDef);\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). pos is a COPY — magnetism moves it\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.slice(), 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    const goalHalo = makeGlowSprite(EMBER, 2.4, 0.32);\n    goalHalo.position.y = 0.35;\n    goalMarker.add(goalHalo);\n    // real firelight at the goal — the finish reads from across the level\n    const goalLight = new THREE.PointLight(EMBER, 0.85, 7, 1.8);\n    goalLight.position.y = 0.6;\n    goalMarker.add(goalLight);\n    levelRoot.add(goalMarker);\n  }\n\n  // wind\n  windZones = levelDef.wind.map((w) => {\n    // long bright streaks along the wind direction — far easier to read\n    // than the old faint dots\n    const N = 90;\n    const geo = new THREE.BufferGeometry();\n    const pos = new Float32Array(N * 6);\n    const speeds = new Float32Array(N);\n    for (let i = 0; i < N; i++) {\n      const x = w.box[0] + Math.random() * w.box[3];\n      const y = w.box[1] + Math.random() * w.box[4];\n      const z = w.box[2] + Math.random() * w.box[5];\n      const len = 0.8 + Math.random() * 0.9; // varied streak lengths\n      pos[i * 6] = x; pos[i * 6 + 1] = y; pos[i * 6 + 2] = z;\n      pos[i * 6 + 3] = x + w.dir[0] * len;\n      pos[i * 6 + 4] = y;\n      pos[i * 6 + 5] = z + w.dir[1] * len;\n      speeds[i] = 11 + Math.random() * 9; // varied speeds give depth\n    }\n    geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));\n    const pts = new THREE.LineSegments(geo, new THREE.LineBasicMaterial({\n      color: 0xf5eede, transparent: true, opacity: 0.85,\n      blending: THREE.AdditiveBlending, depthWrite: false,\n    }));\n    pts.visible = false;\n    levelRoot.add(pts);\n    return { def: w, pts, speeds, 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: 1.3,\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        finalizeTime(); // the clock stops the instant the Kilnheart breaks\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  // the title diorama orbits this level's own centre at a fitting distance\n  dioramaCenter = [cx, 2, cz];\n  dioramaRadius = Math.max(16, Math.min(26, Math.max(ext.maxX - ext.minX, ext.maxZ - ext.minZ) * 0.42));\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  // slanted god-ray shafts through cracks in the kiln wall\n  buildLightShafts(ext);\n  // smouldering mist in the abyss below the platforms\n  buildKilnMist(ext);\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[0], levelDef.spawn[1], levelDef.spawn[2], 0); // fresh level → face the designed view\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// ---------- spark bursts: landings, lanterns, drops, goals ----------\nconst SPARK_N = 160;\nconst sparkGeo = new THREE.BufferGeometry();\nconst sparkPos = new Float32Array(SPARK_N * 3);\nconst sparkVel = new Float32Array(SPARK_N * 3);\nconst sparkLife = new Float32Array(SPARK_N);\nfor (let i = 0; i < SPARK_N; i++) sparkPos[i * 3 + 1] = -9999;\nsparkGeo.setAttribute('position', new THREE.BufferAttribute(sparkPos, 3));\nconst sparkPts = new THREE.Points(sparkGeo, new THREE.PointsMaterial({\n  color: EMBER, size: 0.16, transparent: true, opacity: 0.95,\n  map: softDot, blending: THREE.AdditiveBlending, depthWrite: false, sizeAttenuation: true,\n}));\nscene.add(sparkPts);\nlet sparkCursor = 0;\n\nfunction burstSparks(x, y, z, n, speed = 3.2) {\n  for (let k = 0; k < n; k++) {\n    const i = sparkCursor;\n    sparkCursor = (sparkCursor + 1) % SPARK_N;\n    const a = Math.random() * Math.PI * 2;\n    const r = 0.4 + Math.random() * 0.6;\n    sparkPos[i * 3] = x; sparkPos[i * 3 + 1] = y + 0.15; sparkPos[i * 3 + 2] = z;\n    sparkVel[i * 3] = Math.cos(a) * speed * r;\n    sparkVel[i * 3 + 1] = (1.6 + Math.random() * 2.4) * (speed / 3.2);\n    sparkVel[i * 3 + 2] = Math.sin(a) * speed * r;\n    sparkLife[i] = 0.45 + Math.random() * 0.5;\n  }\n}\n\nfunction updateSparks(dt) {\n  let any = false;\n  for (let i = 0; i < SPARK_N; i++) {\n    if (sparkLife[i] <= 0) continue;\n    any = true;\n    sparkLife[i] -= dt;\n    sparkVel[i * 3 + 1] -= 8.5 * dt;\n    sparkPos[i * 3] += sparkVel[i * 3] * dt;\n    sparkPos[i * 3 + 1] += sparkVel[i * 3 + 1] * dt;\n    sparkPos[i * 3 + 2] += sparkVel[i * 3 + 2] * dt;\n    if (sparkLife[i] <= 0) sparkPos[i * 3 + 1] = -9999;\n  }\n  if (any) sparkGeo.attributes.position.needsUpdate = true;\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  if (levelDef.goal) burstSparks(levelDef.goal[0], levelDef.goal[1] + 0.6, levelDef.goal[2], 32, 3.6);\n  progress.completed[levelIdx] = true;\n  if (levelIdx < 8) progress.unlocked = Math.max(progress.unlocked, levelIdx + 2);\n  const run = finalizeTime();\n  const medal = medalFor(levelIdx, run.t); // this run's medal, not the record's\n  const foundHere = progress.drops[levelIdx].filter(Boolean).length;\n  ui.showFiredCard(\n    levelDef.name,\n    fmtTime(run.t) + (run.isBest && run.hadPrev ? '  — new best!' : '') + (medal ? '   ·   ✦ ' + medal : ''),\n    foundHere + '/5 drops' + (foundHere === 5 ? ' — all found' : '')\n  );\n  // firing every main chamber earns the Porcelain shell\n  if (!hadAllFired && progress.completed.slice(0, 9).every(Boolean)) {\n    hadAllFired = true;\n    setTimeout(() => ui.toast(\"new shell fired: Porcelain\\nchange Clawd's shell in Settings\", 3200), 2600);\n  }\n  ui.fade(true);\n  setTimeout(() => {\n    ui.hideIntroCard();\n    if (levelIdx === 6 || levelIdx >= 8) {\n      // Kilnheart replays, the Chimney and the bonus wheel → back to title\n      goToTitle();\n    } else {\n      loadLevel(levelIdx + 1);\n      startIntro();\n    }\n    ui.fade(false);\n  }, 2100);\n}\n\nfunction goToTitle() {\n  mode = 'title';\n  titleT = 0;\n  attractIdx = 0;\n  attractSwapAt = 18;\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      startIntro();\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    startIntro();\n  },\n  onQuit: () => { ui.hideAll(); goToTitle(); },\n  onSettingsChanged: () => { audio.setVolume(settings.volume); },\n  onSkinChanged: (skin) => { progress.skin = skin; player.setSkin(skin); },\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      hadAllDrops = totalDrops(progress, true) >= 45;\n      hadAllFired = progress.completed.slice(0, 9).every(Boolean);\n      player.setSkin(progress.skin);\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      const sp = wz.speeds;\n      for (let i = 0; i < sp.length; i++) {\n        const dx = w.dir[0] * sp[i] * dt;\n        const dz = w.dir[1] * sp[i] * dt;\n        // move both ends of the streak, wrap the pair together\n        p[i * 6] += dx; p[i * 6 + 3] += dx;\n        p[i * 6 + 2] += dz; p[i * 6 + 5] += dz;\n        if (p[i * 6] > w.box[0] + w.box[3]) { p[i * 6] -= w.box[3]; p[i * 6 + 3] -= w.box[3]; }\n        if (p[i * 6] < w.box[0]) { p[i * 6] += w.box[3]; p[i * 6 + 3] += w.box[3]; }\n        if (p[i * 6 + 2] > w.box[2] + w.box[5]) { p[i * 6 + 2] -= w.box[5]; p[i * 6 + 5] -= w.box[5]; }\n        if (p[i * 6 + 2] < w.box[2]) { p[i * 6 + 2] += w.box[5]; p[i * 6 + 5] += 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  // orbit camera: Q/E, right stick, or touch buttons swing around Clawd\n  gameCam.rotate(input.camX, dt);\n\n  // input mapping: rotate raw stick axes into camera space (SM64-style) —\n  // pushing \"up\" always moves Clawd away from the camera, whatever the yaw\n  const cy = gameCam.yaw;\n  const cosY = Math.cos(cy), sinY = Math.sin(cy);\n  input.moveX = input.rawX * cosY + input.rawZ * sinY;\n  input.moveZ = -input.rawX * sinY + input.rawZ * cosY;\n  input.camYaw = cy;\n\n  player.update(dt, input, world, windMods);\n\n  // the speedrun clock ticks whenever the player is in control\n  if (timing) {\n    levelTime += dt;\n    ui.setTime(levelTime);\n    // record this attempt for the ghost (capped ~20 min, then it just stops)\n    if (ghostRec) {\n      ghostRec.acc += dt;\n      while (ghostRec.acc >= GHOST_DT && ghostRec.frames.length < 45000) {\n        ghostRec.acc -= GHOST_DT;\n        ghostRec.frames.push(b.pos.x, b.pos.y, b.pos.z);\n      }\n    }\n  }\n  updateGhost(dt);\n\n  // speed kick: wind flings and long falls stretch the field of view a\n  // touch, so going FAST feels fast. Eases back the moment it's over.\n  {\n    const hsp = Math.hypot(b.vel.x, b.vel.z);\n    const fall = Math.max(0, -b.vel.y - 17);\n    const targetFov = 52 + Math.min(8, Math.max(0, hsp - 7.6) * 1.5) + Math.min(4, fall * 0.22);\n    fovCur += (targetFov - fovCur) * Math.min(1, dt * 5);\n    if (Math.abs(fovCur - camera.fov) > 0.02) {\n      camera.fov = fovCur;\n      camera.updateProjectionMatrix();\n    }\n    // overspeed leaves an ember trail off Clawd's shell\n    if (hsp > 8.8 && player.state === 'alive' && Math.random() < dt * 26) {\n      burstSparks(b.pos.x, b.pos.y + 0.2, b.pos.z, 1, 1.3);\n    }\n  }\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    // molten glaze pools — standing on ember scalds Clawd and bounces him\n    // upward (SM64-lava style): one chance to steer back onto solid ground\n    if (b.onGround && b.groundMat === 'ember') {\n      if (!player.hit()) b.vel.y = 13;\n      gameCam.addShake(0.45);\n      burstSparks(b.pos.x, b.pos.y + 0.1, b.pos.z, 12, 2.6);\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      // magnetism: within reach, the drop leans toward Clawd and closes\n      // the last stretch itself — near-misses become catches\n      const mdx = b.pos.x - dr.pos[0];\n      const mdy = (b.pos.y + 0.5) - dr.pos[1];\n      const mdz = b.pos.z - dr.pos[2];\n      const md2 = mdx * mdx + mdy * mdy + mdz * mdz;\n      if (md2 < 7.5 && md2 > 0.0001) {\n        const pull = Math.min(1, dt * (11 / Math.max(0.7, Math.sqrt(md2))));\n        dr.pos[0] += mdx * pull;\n        dr.pos[1] += mdy * pull;\n        dr.pos[2] += mdz * pull;\n      }\n      dr.mesh.position.x = dr.pos[0];\n      dr.mesh.position.z = dr.pos[2];\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        burstSparks(dr.pos[0], dr.pos[1] + 0.3, dr.pos[2], 14, 2.0);\n        const foundHere = progress.drops[levelIdx].filter(Boolean).length;\n        ui.setDrops(foundHere, 5);\n        const tAll = totalDrops(progress, true);\n        if (tAll >= 45 && !hadAllDrops) {\n          hadAllDrops = true;\n          ui.toast(\"all 45 drops found\\nThe Potter's Wheel turns · the Glazed shell is fired (Settings)\", 3800);\n        } else if (foundHere === 5) {\n          ui.toast('context window full — 5/5 drops\\n*happy crab noises*', 2600);\n        } else {\n          ui.dropQuip();\n        }\n      }\n    }\n\n    // drop compass: once the hunt is down to the last drops (3+ found),\n    // the pot HUD points a quiet arrow at the nearest one\n    if (dropObjs.length > 0 && 5 - dropObjs.length >= 3) {\n      let near = null, nd = Infinity;\n      for (const dr of dropObjs) {\n        const d2 = distSq(b.pos.x, b.pos.y, b.pos.z, dr.pos[0], dr.pos[1], dr.pos[2]);\n        if (d2 < nd) { nd = d2; near = dr; }\n      }\n      const wx = near.pos[0] - b.pos.x, wz = near.pos[2] - b.pos.z;\n      const cosC = Math.cos(gameCam.yaw), sinC = Math.sin(gameCam.yaw);\n      const sx = wx * cosC - wz * sinC;   // screen-right\n      const sz = wx * sinC + wz * cosC;   // toward the camera\n      ui.setCompass(Math.atan2(sx, -sz), true);\n    } else {\n      ui.setCompass(0, false);\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        burstSparks(ln.pos[0], ln.pos[1] + 0.9, ln.pos[2], 22, 2.2);\n        // split vs your best run: lanterns double as timing gates\n        const cpIdx = lanterns.indexOf(ln);\n        if (ghostRec) ghostRec.splits[cpIdx] = levelTime;\n        const bestRun = sessionGhosts[levelIdx];\n        if (bestRun && bestRun.splits && bestRun.splits[cpIdx] != null) {\n          const d = levelTime - bestRun.splits[cpIdx];\n          ui.toast('lantern lit · ' + (d <= 0 ? '−' : '+') + Math.abs(d).toFixed(1) + 's vs best', 1800);\n        } else {\n          ui.toast('lantern lit · progress compacted', 1600);\n        }\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 dust rings: a shockwave puff on hard landings ----------\nconst RING_N = 5;\nconst ringGeo = new THREE.RingGeometry(0.55, 0.72, 26);\nconst rings = [];\nfor (let i = 0; i < RING_N; i++) {\n  const mesh = new THREE.Mesh(ringGeo, new THREE.MeshBasicMaterial({\n    color: 0xcbb89a, transparent: true, opacity: 0,\n    side: THREE.DoubleSide, depthWrite: false,\n  }));\n  mesh.rotation.x = -Math.PI / 2;\n  mesh.visible = false;\n  scene.add(mesh);\n  rings.push({ mesh, t: 1, str: 1 });\n}\nlet ringCursor = 0;\n\nfunction burstRing(x, y, z, strength) {\n  const r = rings[ringCursor];\n  ringCursor = (ringCursor + 1) % RING_N;\n  r.t = 0;\n  r.str = strength;\n  r.mesh.position.set(x, y + 0.06, z);\n  r.mesh.visible = true;\n}\n\nfunction updateRings(dt) {\n  for (const r of rings) {\n    if (r.t >= 1) continue;\n    r.t = Math.min(1, r.t + dt * 2.8);\n    const s = (0.4 + r.t * 2.2) * r.str;\n    r.mesh.scale.set(s, s, s);\n    r.mesh.material.opacity = (1 - r.t) * 0.4;\n    if (r.t >= 1) r.mesh.visible = false;\n  }\n}\n\n// landing shake\nplayer.onLanded = (impact) => {\n  if (impact > 15) gameCam.addShake(Math.min(0.6, (impact - 15) * 0.05));\n  if (impact > 9) {\n    const b = player.body;\n    burstSparks(b.pos.x, b.pos.y, b.pos.z, Math.min(18, Math.floor(impact * 0.9)), 2.4);\n    burstRing(b.pos.x, b.pos.y, b.pos.z, Math.min(1.6, 0.6 + impact * 0.045));\n  }\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    // the fallen heart relights the kiln — the afterglow chambers open\n    progress.unlocked = Math.max(progress.unlocked, 8);\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// ---------- level intro: a slow swoop down to Clawd ----------\nlet introT = 0;\nconst INTRO_DUR = 2.6;\n\nfunction startIntro() {\n  mode = 'intro';\n  introT = 0;\n  player.frozen = true;\n  audio.intro();\n  ui.showIntroCard(levelIdx === 9 ? 'Bonus' : 'Level ' + (levelIdx + 1), levelDef.name, levelIdx);\n}\n\nfunction endIntro() {\n  ui.hideIntroCard();\n  player.frozen = false;\n  gameCam.snap(levelDef.spawn[0], levelDef.spawn[1], levelDef.spawn[2], 0);\n  mode = 'playing';\n  timing = true; // the clock starts the moment control does\n  // start this attempt's recording, and summon the ghost of the best one\n  const b = player.body;\n  ghostRec = { frames: [b.pos.x, b.pos.y, b.pos.z], acc: 0, splits: [] };\n  if (sessionGhosts[levelIdx] && settings.ghost !== false) {\n    spawnGhost(sessionGhosts[levelIdx].frames);\n  }\n}\n\nfunction updateIntro(dt) {\n  introT += dt;\n  // any press skips straight to gameplay\n  if (input.jumpPressed || input.confirmPressed || input.pausePressed) { endIntro(); return; }\n  const u = Math.min(1, introT / INTRO_DUR);\n  const e = 1 - Math.pow(1 - u, 3); // ease-out — fast sweep, gentle settle\n  const [sx, sy, sz] = levelDef.spawn;\n  const ang = -1.45 * (1 - e);        // sweep round to the play angle…\n  const rad = 30 - (30 - 10.5) * e;   // …flying in from far out…\n  const hgt = 20 - (20 - 7.2) * e;    // …and high above the kiln floor\n  camera.position.set(sx + Math.sin(ang) * rad, sy + hgt, sz + Math.cos(ang) * rad);\n  camera.lookAt(sx, sy + 1.2 + (1 - e) * 2.2, sz);\n  // the kiln keeps breathing while the camera flies\n  for (const mv of movers) updateMover(mv, dt);\n  updateEmberWisps(dt);\n  updateDebris(dt);\n  if (u >= 1) endIntro();\n}\n\n// ---------- main loop ----------\nlet last = performance.now();\nlet audioKicked = false;\nlet lastDevice = '';\n\n// adaptive resolution: EMA of raw frame time; step resScale down when we\n// can't hold ~60fps, creep back up when there's headroom. Cooldown between\n// changes — every applyRenderSize() reallocates render targets (a one-frame\n// hitch), so it must fire rarely, not oscillate.\nlet frameMsAvg = 16.7;\nlet resCooldown = 0;\n\nfunction frame(now) {\n  requestAnimationFrame(frame);\n  const rawMs = now - last;\n  const dt = Math.min(0.033, rawMs / 1000);\n  last = now;\n  elapsed += dt;\n\n  if (rawMs > 0 && rawMs < 250) { // ignore tab-switch / load spikes\n    frameMsAvg += (rawMs - frameMsAvg) * 0.05;\n    resCooldown -= rawMs / 1000;\n    if (resCooldown <= 0) {\n      if (frameMsAvg > 17.5 && resScale > RES_SCALE_MIN) {\n        resScale = Math.max(RES_SCALE_MIN, resScale - 0.1);\n        applyRenderSize();\n        resCooldown = 1.5;\n      } else if (frameMsAvg < 14.5 && resScale < 1) {\n        resScale = Math.min(1, resScale + 0.05);\n        applyRenderSize();\n        resCooldown = 3; // climb back slowly so it can't ping-pong\n      }\n    }\n  }\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  // gamepad drives the menus (title, pause, settings, level select…)\n  if (mode !== 'playing' && mode !== 'intro') {\n    const m = input.menu;\n    if (m.up) ui.menuNav(0, -1);\n    if (m.down) ui.menuNav(0, 1);\n    if (m.left) ui.menuNav(-1, 0);\n    if (m.right) ui.menuNav(1, 0);\n    if (m.confirm) ui.menuConfirm();\n    if (m.back) ui.menuBack();\n    if (m.confirm || m.back) {\n      // a menu press mustn't leak into gameplay as a jump/pause this frame\n      input.jumpPressed = false;\n      input.confirmPressed = false;\n      input.pausePressed = false;\n    }\n  }\n\n  if (mode === 'playing') {\n    updatePlaying(dt);\n  } else if (mode === 'intro') {\n    updateIntro(dt);\n  } else if (mode === 'paused' && input.pausePressed) {\n    // Esc / pause button toggles back out of the pause menu\n    mode = 'playing';\n    ui.hideAll();\n    audio.duck(false);\n  } else if (mode === 'title') {\n    titleT += dt;\n    // slow orbit of the current diorama — and every ~15s the tour moves\n    // on to another chamber you've unlocked\n    gameCam.orbit(titleT, dioramaCenter[0], dioramaCenter[1], dioramaCenter[2], dioramaRadius);\n    for (const mv of movers) updateMover(mv, dt);\n    updateEmberWisps(dt);\n    if (titleT >= attractSwapAt) {\n      attractSwapAt = titleT + 15;\n      const pool = attractPool();\n      if (pool.length > 1) {\n        attractIdx = (attractIdx + 1) % pool.length;\n        const next = pool[attractIdx];\n        ui.fade(true);\n        setTimeout(() => {\n          if (mode === 'title') loadLevel(next, { diorama: true });\n          ui.fade(false);\n        }, 380);\n      }\n    }\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  updateDust(dt);\n  updateSparks(dt);\n  updateRings(dt);\n  // the smoke vault follows the camera so the dark always has depth\n  kilnDome.position.set(camera.position.x, camera.position.y * 0.35, camera.position.z);\n  kilnDome.material.uniforms.uTime.value = elapsed;\n  // god-ray shimmer + film grade clock\n  for (let i = 0; i < shafts.length; i++) {\n    const s = shafts[i];\n    s.mesh.material.opacity = s.base * (0.72 + 0.28 * Math.sin(elapsed * 0.45 + s.phase));\n  }\n  // smouldering mist sheets below the stage drift at their own pace\n  for (let i = 0; i < mists.length; i++) {\n    mists[i].mesh.material.uniforms.uTime.value = elapsed * mists[i].drift;\n  }\n  gradePass.uniforms.uTime.value = elapsed;\n  // molten flow clock — ember surfaces churn like live magma\n  emberTime.value = elapsed;\n  // tilt-shift: full miniature look on the title diorama, a whisper in play\n  const tiltTarget = mode === 'title' ? 1.3 : 0.45;\n  tiltAmount += (tiltTarget - tiltAmount) * Math.min(1, dt * 2.2);\n  tiltH.uniforms.uAmount.value = tiltAmount;\n  tiltV.uniforms.uAmount.value = tiltAmount;\n  // live coals — every ember voxel swells and dims like a breathing kiln\n  emberMat.emissiveIntensity = 1.3 + Math.sin(elapsed * 1.7) * 0.18 + Math.sin(elapsed * 4.3) * 0.1;\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.85 + Math.sin(elapsed * 7) * 0.1;\n  composer.render();\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  get resScale() { return resScale; },\n  get frameMsAvg() { return frameMsAvg; },\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\n// A solid whose top pokes no more than this above the feet is a \"lip\",\n// not a wall. The horizontal passes ignore it and let the vertical pass\n// snap the feet up onto it instead. Without this, riding a RISING\n// platform breaks: the platform moves after the player each frame, so\n// the feet end up a few hundredths embedded in it — and the X pass then\n// resolved that overlap sideways, teleporting the rider to the\n// platform's edge (the \"pushed off to the left\" bug on Glaze Falls).\nconst STEP_UP = 0.15;\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 (box.max[1] <= body.pos.y + STEP_UP) continue; // lip — Y pass steps up onto it\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 (box.max[1] <= body.pos.y + STEP_UP) continue; // lip — Y pass steps up onto it\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        // A lip the horizontal passes admitted is a ledge CORNER, not a\n        // ceiling. Without this, jumping while pressed against a platform\n        // ate the jump: the rising body clipped 0.15 into the ledge and\n        // the ceiling resolve slammed it a full body-height back down.\n        // Rise past it — once the feet clear the top, it becomes ground.\n        if (box.max[1] <= body.pos.y - dy + STEP_UP) continue;\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.\n// earned shells: the kiln re-fires Clawd in new finishes. Each remaps\n// only his clay colours — eyes, pupils, belly and mouth stay his own.\nconst SKIN_MAPS = {\n  terra: null,\n  glazed: { [MAT.TERRA]: MAT.GLAZE, [MAT.TERRA_DEEP]: MAT.GLAZE_PALE },\n  ember: { [MAT.TERRA]: MAT.EMBER, [MAT.TERRA_DEEP]: MAT.SOOT },\n  porcelain: { [MAT.TERRA]: MAT.BONE, [MAT.TERRA_DEEP]: MAT.ASH },\n};\n\nfunction clawdVoxels(crackLevel, skin) {\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  // shell finish first, cracks after — damage chars every finish the same\n  const map = SKIN_MAPS[skin];\n  if (map) for (const e of v) e[3] = map[e[3]] || e[3];\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\n// the ember ghost: a translucent Clawd that replays your best run.\n// One shared additive material — it reads as heat-haze, not a solid crab.\nexport function makeGhostMesh() {\n  const group = buildModelMesh(clawdVoxels(0), { voxelSize: VOX });\n  const mat = new THREE.MeshBasicMaterial({\n    color: 0xe8a13a, transparent: true, opacity: 0.3,\n    depthWrite: false, blending: THREE.AdditiveBlending,\n  });\n  group.traverse((o) => {\n    if (o.isMesh) { o.material = mat; o.castShadow = false; o.receiveShadow = false; }\n  });\n  group.renderOrder = 5;\n  return { group, mat };\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  // The whole rig rebuilds when Clawd earns and dons a new shell.\n  let skin = 'terra';\n  const bodyStates = [];\n  const bodyGroup = new THREE.Group(); // sits on top of the legs\n  bodyGroup.position.y = 2 * VOX;\n\n  function disposeModel(g) {\n    g.traverse((o) => { if (o.geometry) o.geometry.dispose(); });\n  }\n\n  function buildLegMesh() {\n    const legVox = [];\n    const map = SKIN_MAPS[skin];\n    const M = (m) => (map && map[m]) || m;\n    for (const x of [0, 1]) for (const z of [0, 1]) {\n      legVox.push([x, 1, z, M(MAT.TERRA)]);\n      legVox.push([x, 0, z, M(MAT.TERRA_DEEP)]); // little dark feet\n    }\n    const mesh = buildModelMesh(legVox, { voxelSize: VOX });\n    mesh.position.y = -2 * VOX;              // leg hangs down from the hip\n    return mesh;\n  }\n\n  function makeLeg(offX) {\n    const pivot = new THREE.Group();         // hip joint\n    pivot.position.set(offX, 2 * VOX, 0);\n    pivot.add(buildLegMesh());\n    return pivot;\n  }\n  const legL = makeLeg(-1.5 * VOX);\n  const legR = makeLeg(1.5 * VOX);\n\n  function rebuildRig(crackLevel) {\n    for (const m of bodyStates) { bodyGroup.remove(m); disposeModel(m); }\n    bodyStates.length = 0;\n    for (const lvl of [0, 1, 2]) {\n      const g = buildModelMesh(clawdVoxels(lvl, skin).filter((vx) => vx[1] >= 2), { voxelSize: VOX });\n      g.visible = lvl === Math.min(crackLevel, 2);\n      bodyStates.push(g);\n      bodyGroup.add(g);\n    }\n    for (const leg of [legL, legR]) {\n      const old = leg.children[0];\n      if (old) { leg.remove(old); disposeModel(old); }\n      leg.add(buildLegMesh());\n    }\n  }\n  rebuildRig(0);\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    stepSin: 0,       // last walk-cycle sine — footsteps fire on sign flips\n    idleT: 0,         // seconds standing still — Clawd gets restless\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  // don a different fired shell (earned in Settings)\n  p.setSkin = (name) => {\n    const next = SKIN_MAPS.hasOwnProperty(name) ? name : 'terra';\n    if (next === skin) return;\n    skin = next;\n    rebuildRig(p.cracks);\n  };\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), skin);\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). The rig\n    // tracks the ORBIT CAMERA's yaw, so wherever the player swings the\n    // view, the face turns to meet it. Legs swing ALONG the direction\n    // of travel (in camera space, since the rig itself is rotated),\n    // the body leans into the run, and a gentle yaw tilt gives him 3D\n    // presence without hiding the face.\n    const camYaw = input.camYaw || 0;\n    const cosC = Math.cos(camYaw), sinC = Math.sin(camYaw);\n    const hspeed = Math.hypot(body.vel.x, body.vel.z);\n    let dx = 0, dz = 0; // travel direction in CAMERA space, unit\n    if (hspeed > 0.3) {\n      const wx = body.vel.x / hspeed, wz = body.vel.z / hspeed;\n      dx = wx * cosC - wz * sinC;   // screen-right component\n      dz = wx * sinC + wz * cosC;   // toward-camera component\n    }\n\n    // idle emote: leave Clawd alone and he clacks his pincers with a\n    // happy little bounce, then settles again\n    if (body.onGround && !moving && hspeed < 0.4) {\n      p.idleT += dt;\n      if (p.idleT > 9) {\n        p.idleT = 3.5; // next clack sooner, but not immediately\n        p.squashVel = 11;\n        audio.clack();\n      }\n    } else {\n      p.idleT = 0;\n    }\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      // each leg-swing extreme is a tiny ceramic footstep\n      if ((s >= 0) !== (p.stepSin >= 0)) audio.step();\n      p.stepSin = s;\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    // face the camera (plus a gentle lean into the run) — wrap-aware\n    // so a full orbit never spins him the long way round\n    const targetYaw = camYaw + dx * 0.38;\n    let dYaw = targetYaw - rig.rotation.y;\n    dYaw = Math.atan2(Math.sin(dYaw), Math.cos(dYaw));\n    rig.rotation.y += dYaw * 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 main level reachable (1..9)\n    completed: Array.from({ length: 10 }, () => false),\n    // drops[levelIndex][dropIndex] — found flags, 5 per level (9 main + bonus)\n    drops: Array.from({ length: 10 }, () => [false, false, false, false, false]),\n    times: Array.from({ length: 10 }, () => null), // best clear time per level, seconds\n    skin: 'terra', // Clawd's chosen fired shell\n    bossDown: false,\n    settings: { volume: 0.8, camSens: 1.0, screenShake: true, autoCam: false, ghost: true },\n  };\n}\n\nexport function totalDrops(progress, mainOnly) {\n  let n = 0;\n  const upto = mainOnly ? 9 : 10;\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 = 9 + 45; // 9 levels + 45 main drops\n  for (let i = 0; i < 9; 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    // saves minted before the afterglow chambers (levels 8 & 9) had 8 rows,\n    // with the bonus wheel in row 7 — shift it to row 9 so nothing is lost\n    if (Array.isArray(raw.drops) && raw.drops.length === 8) {\n      raw.drops = [...raw.drops.slice(0, 7), [], [], raw.drops[7]];\n      if (Array.isArray(raw.completed) && raw.completed.length === 8) {\n        raw.completed = [...raw.completed.slice(0, 7), false, false, raw.completed[7]];\n      }\n      // beating the old finale already earned the way into the glassworks\n      if (raw.bossDown) raw.unlocked = Math.max(raw.unlocked | 0, 8);\n    }\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(9, 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      times: base.times.map((_, l) => {\n        const v = raw.times && raw.times[l];\n        return typeof v === 'number' && v > 0 ? v : null;\n      }),\n      skin: ['terra', 'glazed', 'ember', 'porcelain'].includes(raw.skin) ? raw.skin : 'terra',\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        autoCam: raw.settings ? raw.settings.autoCam === true : false,\n        ghost: raw.settings ? raw.settings.ghost !== false : 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":"touch.js","content":"// KILN — touch controls: a draggable joystick (move) and a jump button.\n// Hidden until the device is coarse-pointer or the player actually touches the screen.\n\nexport function createTouch() {\n  const state = { x: 0, z: 0, cam: 0, jump: false, pause: false, active: false };\n\n  const root = document.createElement('div');\n  root.className = 'touch-controls';\n  root.setAttribute('aria-hidden', 'true');\n\n  const joy = document.createElement('div');\n  joy.className = 'touch-joy';\n  const knob = document.createElement('div');\n  knob.className = 'touch-joy__knob';\n  joy.appendChild(knob);\n\n  const jumpBtn = document.createElement('div');\n  jumpBtn.className = 'touch-jump';\n  jumpBtn.textContent = 'JUMP';\n\n  const pauseBtn = document.createElement('div');\n  pauseBtn.className = 'touch-pause';\n  pauseBtn.textContent = '❚❚';\n\n  // camera swing buttons — hold to orbit the camera around Clawd\n  const camL = document.createElement('div');\n  camL.className = 'touch-cam touch-cam--left';\n  camL.textContent = '⟲';\n  const camR = document.createElement('div');\n  camR.className = 'touch-cam touch-cam--right';\n  camR.textContent = '⟳';\n\n  root.appendChild(joy);\n  root.appendChild(jumpBtn);\n  root.appendChild(pauseBtn);\n  root.appendChild(camL);\n  root.appendChild(camR);\n  document.body.appendChild(root);\n\n  // visibility is handled purely in CSS via a screen-size media query\n\n  // block double-tap zoom / scroll gestures that originate on the controls\n  for (const el of [joy, jumpBtn, pauseBtn, camL, camR]) {\n    el.addEventListener('touchstart', (e) => e.preventDefault(), { passive: false });\n  }\n\n  // ---------- joystick ----------\n  const RADIUS = 44;   // px of knob travel\n  const DEAD = 0.18;   // resting-thumb deadzone\n  let joyId = null;\n  let cx = 0, cy = 0;\n\n  function setKnob(dx, dy) {\n    knob.style.transform = `translate(${dx}px, ${dy}px)`;\n  }\n\n  function moveJoy(e) {\n    if (e.pointerId !== joyId) return;\n    let dx = e.clientX - cx, dy = e.clientY - cy;\n    const len = Math.hypot(dx, dy);\n    if (len > RADIUS) { dx = (dx / len) * RADIUS; dy = (dy / len) * RADIUS; }\n    setKnob(dx, dy);\n    const nx = dx / RADIUS, ny = dy / RADIUS;\n    state.x = Math.abs(nx) > DEAD ? nx : 0;\n    state.z = Math.abs(ny) > DEAD ? ny : 0;\n  }\n\n  function endJoy(e) {\n    if (e.pointerId !== joyId) return;\n    joyId = null;\n    state.x = 0; state.z = 0; state.active = false;\n    setKnob(0, 0);\n  }\n\n  joy.addEventListener('pointerdown', (e) => {\n    if (joyId !== null) return;\n    joyId = e.pointerId;\n    const r = joy.getBoundingClientRect();\n    cx = r.left + r.width / 2;\n    cy = r.top + r.height / 2;\n    state.active = true;\n    moveJoy(e);\n    // capture last + guarded: it must never break the drag state\n    try { joy.setPointerCapture(joyId); } catch (err) { /* untrackable pointer */ }\n  });\n  joy.addEventListener('pointermove', moveJoy);\n  joy.addEventListener('pointerup', endJoy);\n  joy.addEventListener('pointercancel', endJoy);\n\n  // ---------- jump button ----------\n  const jumpUp = () => {\n    state.jump = false;\n    jumpBtn.classList.remove('touch-jump--down');\n  };\n  jumpBtn.addEventListener('pointerdown', (e) => {\n    e.preventDefault();\n    state.jump = true;\n    jumpBtn.classList.add('touch-jump--down');\n  });\n  jumpBtn.addEventListener('pointerup', jumpUp);\n  jumpBtn.addEventListener('pointercancel', jumpUp);\n  jumpBtn.addEventListener('pointerleave', jumpUp);\n\n  // ---------- camera buttons ----------\n  function wireCam(btn, dir) {\n    const up = () => {\n      if (state.cam === dir) state.cam = 0;\n      btn.classList.remove('touch-cam--down');\n    };\n    btn.addEventListener('pointerdown', (e) => {\n      e.preventDefault();\n      state.cam = dir;\n      btn.classList.add('touch-cam--down');\n    });\n    btn.addEventListener('pointerup', up);\n    btn.addEventListener('pointercancel', up);\n    btn.addEventListener('pointerleave', up);\n  }\n  wireCam(camL, -1);\n  wireCam(camR, 1);\n\n  // ---------- pause button ----------\n  const pauseUp = () => { state.pause = false; };\n  pauseBtn.addEventListener('pointerdown', (e) => {\n    e.preventDefault();\n    state.pause = true;\n  });\n  pauseBtn.addEventListener('pointerup', pauseUp);\n  pauseBtn.addEventListener('pointercancel', pauseUp);\n  pauseBtn.addEventListener('pointerleave', pauseUp);\n\n  // safety: if the page loses focus mid-touch, release everything\n  window.addEventListener('blur', () => {\n    joyId = null;\n    state.x = 0; state.z = 0; state.cam = 0; state.active = false;\n    state.jump = false; state.pause = false;\n    setKnob(0, 0);\n  });\n\n  return state;\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',\n  'The Glassworks', 'The Chimney', \"The Potter's Wheel\",\n];\n\n// speedrun pars per level: [gold, silver] seconds. Any completion under\n// silver earns silver, under gold earns gold, otherwise bronze.\nconst PARS = [\n  [50, 90],    // Cooling Racks\n  [75, 120],   // Glaze Falls\n  [75, 120],   // The Bellows\n  [80, 130],   // Cracked Shelves\n  [65, 105],   // Firing Chamber\n  [80, 130],   // Shard Hollow\n  [150, 240],  // The Kilnheart\n  [90, 150],   // The Glassworks\n  [100, 165],  // The Chimney\n  [70, 115],   // Potter's Wheel\n];\n\nexport function medalFor(levelIdx, t) {\n  if (t == null) return null;\n  const [gold, silver] = PARS[levelIdx] || [60, 120];\n  return t <= gold ? 'gold' : t <= silver ? 'silver' : 'bronze';\n}\n\n// 83.46 -> \"1:23.4\"\nexport function fmtTime(s) {\n  const m = Math.floor(s / 60);\n  const sec = s - m * 60;\n  return m + ':' + (sec < 10 ? '0' : '') + sec.toFixed(1);\n}\n\n// one whispered line per chamber, shown under the intro card's name\nconst LEVEL_LORE = [\n  'where pots once cooled, something unfired stirs',\n  'the falls froze mid-pour — blue, and patient',\n  'the kiln still breathes. mind the gusts',\n  'these shelves held a thousand firings. they are tired',\n  'someone has lit the fire below. climb',\n  'the broken pots remember being whole',\n  'at the heart of every kiln: a heart',\n  'the first firing glassed the world beautiful',\n  'smoke knows the way out. follow it',\n  'the wheel turns for the one who gathered every drop',\n];\n\n// Clawd's earned shells — how the kiln rewards its crab\nconst SKIN_NAMES = { terra: 'Terracotta', glazed: 'Glazed', ember: 'Ember', porcelain: 'Porcelain' };\nfunction unlockedSkins(p) {\n  const list = ['terra'];\n  if (totalDrops(p, true) >= 45) list.push('glazed');\n  if (p.bossDown) list.push('ember');\n  if (p.completed.slice(0, 9).every(Boolean)) list.push('porcelain');\n  return list;\n}\n\n// little nods for the anthropic community — shown on drop pickups\nconst DROP_QUIPS = [\n  \"You're absolutely right!\",\n  '*clacks approvingly*',\n  'Great question! It was glaze.',\n  'Thinking… done. It was glaze.',\n  'context window +1 drop',\n  'ultrathink achieved',\n  'Certainly! One glaze drop.',\n  \"I'm Clawd, a crab made by Anthropic.\",\n  'This is exactly right. Collecting it.',\n  'Let me approach this drop step by step.',\n  'Now I see the issue — it was glaze.',\n  '✻ Glazing… (1s · ↑ one drop)',\n  'Found in a single tool call.',\n  'Accepted edit: +1 drop',\n  'The drop is now safely in context.',\n  'No hallucination — that drop was real.',\n  'one-shotted that pickup',\n  \"Here's what I found: glaze.\",\n  'Perfect — thanks, Clawd! 🦀',\n  'You are a small crab. It is 1 drop.',\n  'This is not just glaze — it is *your* glaze.',\n  'Retrying… success on attempt 1.',\n];\n\n// claude-code-style spinner words for the pause screen\nconst PAUSE_QUIPS = [\n  'Ultrathinking…', 'Compacting context…', 'Reticulating voxels…',\n  'Pondering…', 'Scuttling…', 'Clauding…', 'Consulting CLAWD.md…',\n  'Moulting…', 'Simmering…', 'Glazing…', 'Deliberating…', 'Marinating…',\n  'Crustaceaning…', 'Kneading clay…', 'Interleaving thoughts…',\n  'Escalating effort…', 'Awaiting human approval…', 'Booping…',\n  'Herding embers…', 'Spelunking…',\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  // ---------- level intro card ----------\n  const introCard = el('div', 'intro-card');\n  const introKicker = el('div', 'intro-card__kicker', '');\n  const introRule = el('div', 'intro-card__rule');\n  const introName = el('div', 'intro-card__name', '');\n  const introLore = el('div', 'intro-card__lore', '');\n  introCard.appendChild(introKicker);\n  introCard.appendChild(introRule);\n  introCard.appendChild(introName);\n  introCard.appendChild(introLore);\n  root.appendChild(introCard);\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  // the speedrun clock, quiet under the level name; only repaints when\n  // the visible tenth changes\n  const timeEl = el('div', 'hud-time', '');\n  hud.appendChild(timeEl);\n  let lastTimeStr = '';\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  // the drop compass — appears for the last stretch of the hunt\n  const compassEl = el('div', 'hud-compass', '▲');\n  compassEl.style.visibility = 'hidden';\n  potWrap.appendChild(compassEl);\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  let quipIdx = Math.floor(Math.random() * DROP_QUIPS.length);\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  let currentScreen = null; // the visible screen — gamepad nav targets this\n  function showOnly(screen) {\n    for (const s of allScreens) s.hidden = s !== screen;\n    currentScreen = screen || null;\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  titleScreen.appendChild(el('div', 'title-footer', 'a game for the anthropic community · built with claude 🦀'));\n\n  function nextLevelIndex() {\n    const p = deps.getProgress();\n    for (let i = 0; i < 9; i++) {\n      if (i + 1 > p.unlocked) break; // never begin a locked chamber\n      if (!p.completed[i]) return i;\n    }\n    return Math.min(8, Math.max(0, p.unlocked - 1)); // everything done — revisit the frontier\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 dropsMain = totalDrops(p, true);\n    for (let i = 0; i < 10; i++) {\n      const card = el('button', 'level-card');\n      const locked = i === 9 ? dropsMain < 45 : i + 1 > p.unlocked;\n      card.disabled = locked;\n      card.appendChild(el('div', 'level-card__num', i === 9 ? 'Bonus' : 'Level ' + (i + 1)));\n      card.appendChild(el('div', 'level-card__name', locked && i === 9 ? '? ? ?' : 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 === 9 ? dropsMain + '/45 drops' : found + '/5 drops');\n      meta.appendChild(st); meta.appendChild(dr);\n      card.appendChild(meta);\n      // best time + ember medal, once the level has been cleared\n      const bt = p.times && p.times[i];\n      if (bt != null && !locked) {\n        const medal = medalFor(i, bt);\n        card.appendChild(el('div', 'level-card__time medal-' + medal, '✦ ' + fmtTime(bt)));\n      }\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    const autoRow = el('div', 'setting-row');\n    autoRow.appendChild(el('label', '', 'Auto camera'));\n    const autoBtn = el('button', 'toggle-btn' + (s.autoCam === true ? ' on' : ''), s.autoCam === true ? 'On' : 'Off');\n    autoBtn.addEventListener('click', () => {\n      s.autoCam = s.autoCam !== true;\n      autoBtn.textContent = s.autoCam ? 'On' : 'Off';\n      autoBtn.classList.toggle('on', s.autoCam);\n      deps.audio.uiSelect();\n      deps.onSettingsChanged();\n    });\n    autoRow.appendChild(autoBtn);\n    list.appendChild(autoRow);\n\n    // the ember ghost — your session-best run, replayed as a racing shimmer\n    const ghostRow = el('div', 'setting-row');\n    ghostRow.appendChild(el('label', '', 'Race ghost'));\n    const ghostBtn = el('button', 'toggle-btn' + (s.ghost !== false ? ' on' : ''), s.ghost !== false ? 'On' : 'Off');\n    ghostBtn.addEventListener('click', () => {\n      s.ghost = s.ghost === false;\n      ghostBtn.textContent = s.ghost ? 'On' : 'Off';\n      ghostBtn.classList.toggle('on', s.ghost);\n      deps.audio.uiSelect();\n      deps.onSettingsChanged();\n    });\n    ghostRow.appendChild(ghostBtn);\n    list.appendChild(ghostRow);\n\n    // Clawd's fired shell — cycles through the finishes he has earned\n    const p0 = deps.getProgress();\n    const skinRow = el('div', 'setting-row');\n    skinRow.appendChild(el('label', '', \"Clawd's shell\"));\n    const skinsNow = unlockedSkins(p0);\n    const curSkin = skinsNow.includes(p0.skin) ? p0.skin : 'terra';\n    const skinBtn = el('button', 'toggle-btn on', SKIN_NAMES[curSkin] + (skinsNow.length > 1 ? ' ▸' : ''));\n    skinBtn.addEventListener('click', () => {\n      const p = deps.getProgress();\n      const list2 = unlockedSkins(p);\n      const next = list2[(list2.indexOf(list2.includes(p.skin) ? p.skin : 'terra') + 1) % list2.length];\n      p.skin = next;\n      skinBtn.textContent = SKIN_NAMES[next] + (list2.length > 1 ? ' ▸' : '');\n      deps.audio.uiSelect();\n      if (deps.onSkinChanged) deps.onSkinChanged(next);\n    });\n    skinRow.appendChild(skinBtn);\n    list.appendChild(skinRow);\n    const lockedShells = 4 - skinsNow.length;\n    list.appendChild(el('div', 'settings-hint', lockedShells > 0\n      ? lockedShells + (lockedShells === 1 ? ' shell' : ' shells') + ' still unfired — every drop, the Kilnheart, a full firing'\n      : 'every shell fired — the kiln has nothing left to teach'));\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, onBack) {\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      roll.appendChild(el('p', '', 'Behind him the glassworks glitter and the great flue glows —'));\n      roll.appendChild(el('p', '', 'two new chambers have opened. The climb goes on.'));\n      roll.appendChild(el('p', 'small', \"and the fall has fired him a new shell: EMBER. try it on in Settings.\"));\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 · claude'));\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    roll.appendChild(el('p', '', \"everyone who has ever typed 'ultrathink',\"));\n    roll.appendChild(el('p', '', 'and everyone who says thank you to their model — just in case'));\n    intoEl.appendChild(roll);\n    if (isEnding) {\n      const p = deps.getProgress();\n      const shareFeedback = el('div', 'save-feedback', '');\n      const shareBtn = menuBtn('Copy brag for the timeline', () => {\n        const golds = (p.times || []).filter((t, i) => medalFor(i, t) === 'gold').length;\n        const text = '🦀 I helped Clawd — Anthropic\\'s little crab — fight through a cold dark kiln and break the Kilnheart. '\n          + completionPercent(p) + '% fired, ' + totalDrops(p, true) + '/45 glaze drops'\n          + (golds ? ', ' + golds + ' gold ember' + (golds === 1 ? '' : 's') : '') + '.\\n'\n          + 'KILN: a tiny voxel platformer built with Claude. Play it here: ' + location.href;\n        if (navigator.clipboard) navigator.clipboard.writeText(text);\n        shareFeedback.textContent = 'Copied — go post it.';\n      });\n      intoEl.appendChild(shareBtn);\n      intoEl.appendChild(shareFeedback);\n    }\n    intoEl.appendChild(menuBtn(isEnding ? 'Return to the kiln' : 'Back', onBack));\n  }\n  // credits \"Back\" goes straight to the title screen\n  buildCredits(creditsScreen, false, () => showOnly(titleScreen));\n\n  // ---------- pause ----------\n  pauseScreen.appendChild(el('h2', 'screen-heading', 'Paused'));\n  const pauseQuip = el('div', 'kiln-subtitle pause-quip', PAUSE_QUIPS[0]);\n  pauseScreen.appendChild(pauseQuip);\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('Level Select', () => { rebuildLevelSelect('pause'); showOnly(levelScreen); }));\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  // ---------- gamepad menu navigation ----------\n  function focusableIn(screen) {\n    return Array.from(screen.querySelectorAll('button:not(:disabled), input:not([disabled]), textarea'))\n      .filter((e) => e.offsetWidth || e.offsetHeight);\n  }\n  // pick the nearest focusable in the pressed direction (works for lists AND the level grid)\n  function navPick(items, active, dx, dy) {\n    if (!active || !items.includes(active)) return items[0];\n    const r = active.getBoundingClientRect();\n    const cx = r.left + r.width / 2, cy = r.top + r.height / 2;\n    let best = null, bestScore = Infinity;\n    for (const it of items) {\n      if (it === active) continue;\n      const b = it.getBoundingClientRect();\n      const ox = b.left + b.width / 2 - cx, oy = b.top + b.height / 2 - cy;\n      const main = dx ? ox * dx : oy * dy;\n      if (main <= 1) continue; // must lie in the pressed direction\n      const cross = dx ? Math.abs(oy) : Math.abs(ox);\n      const score = main + cross * 2.5;\n      if (score < bestScore) { bestScore = score; best = it; }\n    }\n    if (!best) { // nothing that way — wrap sequentially\n      const idx = items.indexOf(active);\n      best = items[(idx + ((dx + dy) > 0 ? 1 : -1) + items.length) % items.length];\n    }\n    return best;\n  }\n\n  // ---------- public api ----------\n  const ui = {\n    LEVEL_NAMES,\n\n    showTitle() { hud.hidden = true; showOnly(titleScreen); },\n    hideAll() { showOnly(null); },\n    showPause() {\n      pauseQuip.textContent = PAUSE_QUIPS[Math.floor(Math.random() * PAUSE_QUIPS.length)];\n      showOnly(pauseScreen);\n    },\n\n    dropQuip() {\n      quipIdx = (quipIdx + 1) % DROP_QUIPS.length;\n      ui.toast(DROP_QUIPS[quipIdx], 1600);\n    },\n    showLevelSelectFromPause() { rebuildLevelSelect('pause'); showOnly(levelScreen); },\n    showEnding() { buildCredits(endingScreen, true, () => deps.onQuit()); showOnly(endingScreen); },\n\n    showHUD(name) {\n      hud.hidden = false;\n      levelName.textContent = name;\n      timeEl.textContent = '';\n      lastTimeStr = '';\n      bossBar.hidden = true;\n      toastEl.classList.remove('show');\n    },\n\n    setTime(s) {\n      const str = fmtTime(s);\n      if (str !== lastTimeStr) {\n        lastTimeStr = str;\n        timeEl.textContent = str;\n      }\n    },\n\n    setCompass(angleRad, visible) {\n      const vis = visible ? 'visible' : 'hidden';\n      if (compassEl.style.visibility !== vis) compassEl.style.visibility = vis;\n      if (visible) compassEl.style.transform = 'rotate(' + (angleRad * 180 / Math.PI).toFixed(1) + 'deg)';\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 + (g.cam ? '   ·   Camera ' + g.cam : '') + '   ·   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    // ---------- gamepad menu navigation ----------\n    menuNav(dx, dy) {\n      if (!currentScreen) return;\n      const items = focusableIn(currentScreen);\n      if (!items.length) return;\n      const active = document.activeElement;\n      // left/right on a focused slider adjusts it instead of moving focus\n      if (dx && active && active.tagName === 'INPUT' && active.type === 'range' && items.includes(active)) {\n        const step = parseFloat(active.step) || 0.1;\n        const v = parseFloat(active.value) + dx * step;\n        active.value = String(Math.min(parseFloat(active.max), Math.max(parseFloat(active.min), v)));\n        active.dispatchEvent(new Event('input'));\n        deps.audio.uiMove();\n        return;\n      }\n      const next = navPick(items, active, dx, dy);\n      if (next && next !== active) { next.focus(); deps.audio.uiMove(); }\n    },\n    menuConfirm() {\n      if (!currentScreen) return;\n      const active = document.activeElement;\n      if (active && currentScreen.contains(active)) {\n        if (active.tagName === 'BUTTON') active.click();\n      } else {\n        const items = focusableIn(currentScreen);\n        if (items[0]) items[0].focus();\n      }\n    },\n    menuBack() {\n      if (!currentScreen) return;\n      if (currentScreen === pauseScreen) { deps.onResume(); return; }\n      const backBtn = Array.from(currentScreen.querySelectorAll('button'))\n        .find((b) => /^(back|return to the kiln)$/i.test(b.textContent.trim()));\n      if (backBtn) backBtn.click();\n    },\n\n    // ---------- level intro card ----------\n    showIntroCard(kicker, name, levelIdx) {\n      introKicker.textContent = kicker;\n      introName.textContent = name;\n      introLore.textContent = levelIdx != null && LEVEL_LORE[levelIdx] ? LEVEL_LORE[levelIdx] : '';\n      introCard.classList.remove('show');\n      void introCard.offsetWidth; // restart the animation\n      introCard.classList.add('show');\n    },\n    // the same card celebrates the firing: time, medal, drops\n    showFiredCard(name, line1, line2) {\n      introKicker.textContent = 'fired ✓';\n      introName.textContent = name;\n      introLore.textContent = line1 + '\\n' + line2;\n      introCard.classList.remove('show');\n      void introCard.offsetWidth;\n      introCard.classList.add('show');\n    },\n    hideIntroCard() { introCard.classList.remove('show'); },\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;\nlet envMapCache = null;\n\n// shared clock for the molten-ember flow shader (main.js advances it each frame)\nexport const emberTime = { value: 0 };\n\n// tiny procedural cubemap — soot ceiling, terracotta walls, ember floor.\n// gives glazed ceramic something real to reflect.\nfunction makeKilnEnvMap() {\n  if (envMapCache) return envMapCache;\n  const makeFace = (stops) => {\n    const c = document.createElement('canvas');\n    c.width = c.height = 32;\n    const g = c.getContext('2d');\n    const grad = g.createLinearGradient(0, 0, 0, 32);\n    for (const [t, col] of stops) grad.addColorStop(t, col);\n    g.fillStyle = grad;\n    g.fillRect(0, 0, 32, 32);\n    return c;\n  };\n  const side = [[0, '#221813'], [0.55, '#3a2417'], [1, '#7a4526']];\n  const up = [[0, '#16100d'], [1, '#2a1c14']];\n  const down = [[0, '#9a5a2c'], [1, '#c9762f']];\n  const tex = new THREE.CubeTexture([\n    makeFace(side), makeFace(side), // +x  -x\n    makeFace(up), makeFace(down),   // +y  -y\n    makeFace(side), makeFace(side), // +z  -z\n  ]);\n  tex.needsUpdate = true;\n  envMapCache = tex;\n  return tex;\n}\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      const m = new THREE.MeshLambertMaterial({\n        vertexColors: true,\n        emissive: def.color,\n        emissiveIntensity: 1.35, // hot enough to bloom\n      });\n      // molten flow: the emissive surface churns like live magma — dark crust\n      // sliding over bright veins, keyed to world position so pools read as one\n      // continuous body of heat. Hottest veins whiten toward molten yellow.\n      m.onBeforeCompile = (shader) => {\n        shader.uniforms.uEmberTime = emberTime;\n        shader.vertexShader = shader.vertexShader\n          .replace('#include <common>', '#include <common>\\nvarying vec3 vEmberWorld;')\n          .replace('#include <project_vertex>',\n            '#include <project_vertex>\\nvEmberWorld = (modelMatrix * vec4(transformed, 1.0)).xyz;');\n        shader.fragmentShader = shader.fragmentShader\n          .replace('#include <common>', /* glsl */`#include <common>\n            uniform float uEmberTime;\n            varying vec3 vEmberWorld;\n            float eHash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }\n            float eNoise(vec2 p) {\n              vec2 i = floor(p), f = fract(p);\n              vec2 u = f * f * (3.0 - 2.0 * f);\n              return mix(mix(eHash(i), eHash(i + vec2(1.0, 0.0)), u.x),\n                         mix(eHash(i + vec2(0.0, 1.0)), eHash(i + vec2(1.0, 1.0)), u.x), u.y);\n            }`)\n          .replace('#include <emissivemap_fragment>', /* glsl */`#include <emissivemap_fragment>\n            {\n              vec2 q = vEmberWorld.xz * 0.85 + vEmberWorld.y * 0.35;\n              float flow = eNoise(q + vec2(uEmberTime * 0.32, -uEmberTime * 0.21));\n              flow += 0.55 * eNoise(q * 2.4 - vec2(uEmberTime * 0.47, uEmberTime * 0.29));\n              flow /= 1.55;\n              float hot = smoothstep(0.42, 0.88, flow);\n              totalEmissiveRadiance *= 0.55 + 0.95 * flow;\n              totalEmissiveRadiance += vec3(1.0, 0.72, 0.38) * hot * 0.85;\n            }`);\n      };\n      materialCache[key] = m;\n    } else if (def.shiny) {\n      // glazed ceramic: tight specular + a whisper of kiln reflection\n      materialCache[key] = new THREE.MeshPhongMaterial({\n        vertexColors: true,\n        specular: new THREE.Color(GLAZE_PALE),\n        shininess: 72,\n        envMap: makeKilnEnvMap(),\n        combine: THREE.MixOperation,\n        reflectivity: 0.22,\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  // ---- baked ember radiosity ----\n  // exposed molten cells paint warm bounce light onto nearby clay at build\n  // time — free at runtime, and it makes every pool feel like a real light\n  const emberSrc = [];\n  for (const [key, m] of field.cells) {\n    if (m !== MAT.EMBER) continue;\n    const [ex, ey, ez] = key.split(',').map(Number);\n    if (!field.has(ex, ey + 1, ez)) emberSrc.push([ex + 0.5, ey + 1, ez + 0.5]);\n  }\n  // thin very large pools — a sample of cells carries the light\n  const emberStep = emberSrc.length > 120 ? Math.ceil(emberSrc.length / 120) : 1;\n  const embers = emberStep > 1 ? emberSrc.filter((_, i) => i % emberStep === 0) : emberSrc;\n  const emberGain = Math.min(3.2, emberStep * 0.85);\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        // edge glint: convex edges catch the light — fakes a chamfered bevel\n        let glint = 1;\n        if (face.dir[1] === 1) {\n          const ex = corner[0] === 1 ? 1 : -1, ez = corner[2] === 1 ? 1 : -1;\n          if (!solidAt(x + ex, y, z) || !solidAt(x, y, z + ez)) glint = 1.1;\n        } else if (face.dir[1] === 0 && corner[1] === 1 && !solidAt(x, y + 1, z)) {\n          glint = 1.12; // top rim of a side face — bright chamfer line\n        }\n        const v = face.shade * ao * jit * glint;\n        // painted light temperature: down faces catch warm ember bounce off the\n        // kiln floor, up faces take the cooler high fill, and side faces warm\n        // toward their base — a colour gradient, not just a brightness ramp\n        let tr = 1, tg = 1, tb = 1;\n        if (face.dir[1] === -1) { tr = 1.22; tg = 0.84; tb = 0.62; }\n        else if (face.dir[1] === 1) { tr = 0.99; tg = 1.0; tb = 1.06; }\n        else if (corner[1] === 0) { tr = 1.09; tg = 0.95; tb = 0.85; }\n        let cr = tmpColor.r * v * tr, cg = tmpColor.g * v * tg, cb = tmpColor.b * v * tb;\n        // baked ember bounce — pools throw warm light onto facing surfaces\n        if (mat !== MAT.EMBER && embers.length) {\n          const vx = x + corner[0], vy = y + corner[1], vz = z + corner[2];\n          let glow = 0;\n          for (let e = 0; e < embers.length; e++) {\n            const s = embers[e];\n            const dx = s[0] - vx, dy = s[1] - vy, dz = s[2] - vz;\n            const d2 = dx * dx + dy * dy + dz * dz;\n            if (d2 > 64) continue;\n            const dl = Math.sqrt(d2) || 1;\n            const facing = Math.max(0, (face.dir[0] * dx + face.dir[1] * dy + face.dir[2] * dz) / dl) * 0.75 + 0.25;\n            glow += facing / (1 + d2 * 0.55);\n          }\n          glow = Math.min(0.8, glow * 0.55 * emberGain) * ao;\n          if (glow > 0.004) {\n            cr += 0.95 * glow; cg += 0.52 * glow; cb += 0.16 * glow;\n          }\n        }\n        bucket.col.push(cr, cg, cb);\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 45 glaze drops. A great wheel still turning\n// in the dark: ride the orbiting platforms up the spindle.\n// The wheel now stands in a real cavern — a sooty floor veined with\n// ember cracks catches you if you miss a platform. Climb back on.\n\nexport const bonusLevel = {\n  id: 10,\n  name: \"The Potter's Wheel\",\n  spawn: [5, 1, 3],\n  killY: -8,\n  boxes: [\n    // ---------- the cavern ----------\n    [-14, -4, -14, 28, 3, 28, 'soot'],   // cavern floor, top -1\n    // ember cracks glowing in the floor (scalding — stay on the wheel)\n    [-12, -2, -9, 2, 1, 5, 'ember'],\n    [8, -2, 4, 3, 1, 2, 'ember'],\n    [-5, -2, 9, 6, 1, 2, 'ember'],\n    [10, -2, -11, 2, 1, 3, 'ember'],\n    // ash drifts on the cavern floor\n    [-12, -1, 6, 3, 1, 3, 'ash'],\n    [9, -1, -6, 3, 1, 3, 'ash'],\n    // pedestal under the wheel bed\n    [-8, -1, -8, 16, 1, 16, 'terraDeep'],\n    // abandoned pots around the wheel\n    [-12, -1, -3, 2, 3, 2, 'terraDeep'],\n    [10, -1, 8, 2, 2, 2, 'glaze'],\n    [-10, -1, 10, 2, 2, 2, 'terraDeep'],\n    // ---------- the wheel (unchanged layout) ----------\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    // a LOW cavern ring all the way round — the camera orbits freely\n    // and always sees over it; the dark beyond has its own skyline\n    [-16, -1, -16, 32, 6, 2, 'soot'],        // north\n    [-16, -1, 14, 32, 6, 2, 'soot'],         // south\n    [-16, -1, -14, 2, 6, 28, 'soot'],        // west\n    [14, -1, -14, 2, 6, 28, 'soot'],         // east\n    // ember veins glowing on the ring\n    [-6, 0, -14.4, 1, 4, 1, 'ember'],\n    [5, 1, -14.4, 1, 4, 1, 'ember'],\n    [13.9, 0, 2, 1, 4, 1, 'ember'],\n    // broken kiln silhouettes out in the dark, every direction\n    [-26, -1, -22, 10, 14, 6, 'terraDeep'],\n    [8, -1, -24, 12, 11, 6, 'soot'],\n    [20, -1, -6, 6, 13, 14, 'terraDeep'],\n    [-24, -1, 6, 6, 12, 12, 'soot'],\n    [-8, -1, 20, 16, 10, 6, 'terraDeep'],\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 — and the ORBIT CAMERA.\n// A square canyon basin, SM64-style: you spawn on a plaza in the\n// MIDDLE of the world and the path horseshoes around it — east over\n// the gaps, north up the rises, west along the thin cooling racks,\n// ending on a tall goal mesa you could see from spawn. Secrets hide\n// off the path in every compass direction; falling short lands you on\n// the valley floor — climb a recovery mound or the mesa's back stairs.\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//           order matters — later boxes overwrite earlier cells (lava pools\n//           overwrite the bedrock's top layer so they sit flush in the floor)\n//   decor:  same shape, non-solid (backdrops, skyline mesas)\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: [0, 2, 0],\n  killY: -12,\n  boxes: [\n    // ---------- basin terrain ----------\n    [-22, -6, -22, 44, 4, 44, 'terraDeep'],  // bedrock — basin floor, top at -2\n    // molten glaze pools, flush with the floor (scalding — bounce out fast)\n    [9, -3, -5, 4, 1, 8, 'ember'],           // under the first gaps, east\n    [14, -3, -10, 5, 1, 4, 'ember'],         // under the turn north\n    [-7, -3, -17, 4, 1, 6, 'ember'],         // under the hop to the goal mesa\n    [12, -3, 12, 6, 1, 5, 'ember'],          // ember lake in the south-east corner\n    // ash drifts — rolling texture on the basin floor\n    [-14, -2, 2, 4, 1, 4, 'ash'],\n    [4, -2, -14, 3, 1, 3, 'ash'],\n    [-2, -2, 10, 4, 1, 3, 'ash'],\n    [16, -2, 4, 3, 1, 3, 'ash'],\n    // basin rim — low enough that the orbit camera always sees over it\n    [-22, -2, -22, 44, 6, 2, 'soot'],        // north rim\n    [-22, -2, 20, 44, 6, 2, 'soot'],         // south rim\n    [-22, -2, -20, 2, 6, 40, 'terraDeep'],   // west rim\n    [20, -2, -20, 2, 6, 40, 'terraDeep'],    // east rim\n    // mesa supports — the path grows out of the land\n    [-3, -2, -3, 6, 2, 6, 'terraDeep'],      // under the spawn plaza\n    [6, -2, -3, 4, 2, 4, 'terraDeep'],\n    [13, -2, -4, 4, 2, 4, 'terraDeep'],\n    [14, -2, -11, 4, 3, 4, 'terraDeep'],     // under the turn north\n    [13, -2, -17, 4, 4, 4, 'terraDeep'],     // under the north rise\n    // rack legs — the cooling shelves stand on slender posts\n    [8, -2, -15, 1, 6, 1, 'terraDeep'],\n    [3, -2, -15, 1, 6, 1, 'terraDeep'],\n    [-3, -2, -15, 1, 7, 1, 'terraDeep'],\n    // recovery mounds — fell off? climb back up here\n    [-1, -2, 6, 3, 2, 3, 'terra'],           // top 0\n    [2, -2, 6, 2, 3, 2, 'terra'],            // top 1 → hop onto the plaza\n    [9, -2, -13, 3, 2, 3, 'terra'],          // under the racks → back to the rises\n    // the goal mesa's back stairs — the long way up from the floor\n    [-12, -2, -1, 3, 2, 3, 'terra'],         // top 0\n    [-12, 0, -5, 3, 2, 3, 'terra'],          // top 2\n    [-12, 2, -9, 3, 2, 3, 'terra'],          // top 4 → hop onto the mesa\n    // great fired pots resting on the basin floor\n    [10, -2, 7, 2, 3, 2, 'glaze'],           // drop 3 sits on this one\n    [-16, -2, -8, 2, 2, 2, 'terraDeep'],\n    [5, -2, 12, 2, 2, 2, 'terraDeep'],\n    // ---------- the main path — a horseshoe around the basin ----------\n    [-3, 0, -3, 6, 2, 6, 'terra'],       // spawn plaza, centre of the world\n    [6, 0, -3, 4, 2, 4, 'terra'],        // east hop (gap 3)\n    [13, 0, -4, 4, 2, 4, 'terra'],       // east hop (gap 3)\n    [14, 1, -11, 4, 2, 4, 'terra'],      // turn NORTH (gap 3, rise 1)\n    [13, 2, -17, 4, 2, 4, 'terra'],      // north rise (gap 2, rise 1)\n    [7, 4, -16, 3, 1, 3, 'ash'],         // the cooling racks — thin, heading WEST\n    [2, 4, -16, 3, 1, 3, 'ash'],\n    [-4, 5, -16, 3, 1, 3, 'ash'],\n    [-13, -2, -16, 6, 8, 6, 'terra'],    // the goal mesa, top 6\n    // secret shelf west of spawn (drop 4 — look behind you)\n    [-9, 0, 1, 2, 1, 2, 'ash'],\n    // goal arch on the mesa\n    [-12, 6, -15, 1, 4, 1, 'terraDeep'],\n    [-12, 6, -12, 1, 4, 1, 'terraDeep'],\n    [-12, 10, -15, 1, 1, 4, 'terraDeep'],\n    // a few fired pots for set dressing (solid, small)\n    [7, 2, -2, 1, 1, 1, 'glaze'],\n    [15, 3, -10, 1, 1, 1, 'terraDeep'],\n    [14, 4, -16, 1, 1, 1, 'glazePale'],\n    [-8, 6, -12, 1, 2, 1, 'glaze'],\n  ],\n  decor: [\n    // stepped mesa skyline OUTSIDE the rim on every side — the camera\n    // can face any direction now, so the world needs a horizon all round\n    [-20, -6, -30, 18, 13, 6, 'terraDeep'],  // north\n    [2, -6, -30, 16, 10, 6, 'soot'],\n    [-30, -6, -14, 6, 15, 20, 'terraDeep'],  // west\n    [24, -6, -16, 6, 12, 22, 'soot'],        // east\n    [-16, -6, 24, 20, 11, 6, 'soot'],        // south\n    [6, -6, 24, 14, 14, 6, 'terraDeep'],\n    // kiln chimneys on the skyline\n    [8, 4, -28, 2, 8, 2, 'terra'],\n    [-26, 9, -4, 2, 8, 2, 'terra'],\n    [12, 8, 26, 2, 7, 2, 'terra'],\n  ],\n  drops: [\n    [11.5, 3, -2],       // hop for it, over the second east gap\n    [15, 5, -12],        // in the air over the rise heading north\n    [11, 2.6, 8],        // on the great glaze pot, south — off the path\n    [-8, 2.4, 2],        // the shelf west of spawn — look back\n    [-10, 7.8, -11],     // high above the goal mesa\n  ],\n  checkpoints: [\n    [16, 3, -9],         // on the turn-north platform\n    [3.5, 5, -14.5],     // on the middle cooling rack\n  ],\n  goal: [-10, 6, -13],\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 — laid out\n// as an open playfield, not a corridor. Spawn is mid-basin: the glaze\n// runway slides you WEST, a ferry platform carries you NORTH over a\n// molten seep, an elevator lifts you to the falls, and the long slick\n// slide runs EAST along the north wall to the goal mesa. Frozen falls\n// pour down the cliff into ponds on the basin floor below.\n\nexport const level2 = {\n  id: 2,\n  name: 'Glaze Falls',\n  spawn: [0, 2, 0],\n  killY: -12,\n  boxes: [\n    // ---------- basin terrain ----------\n    [-20, -6, -22, 48, 4, 44, 'terraDeep'],  // bedrock — basin floor, top at -2\n    // glaze ponds where the falls land (slippery, safe)\n    [8, -3, -14, 5, 1, 6, 'glaze'],\n    [-16, -3, 4, 5, 1, 7, 'glaze'],\n    [0, -3, -18, 4, 1, 4, 'glaze'],\n    // molten seep under the ferry crossing (scalding)\n    [-15, -3, -15, 6, 1, 9, 'ember'],\n    // ash drifts\n    [-8, -2, 10, 4, 1, 4, 'ash'],\n    [14, -2, -8, 4, 1, 3, 'ash'],\n    [4, -2, 14, 3, 1, 3, 'ash'],\n    // basin rim — low, the orbit camera always sees over it\n    [-20, -2, -22, 48, 6, 2, 'soot'],        // north rim (the falls wall)\n    [-20, -2, 20, 48, 6, 2, 'soot'],         // south rim\n    [-20, -2, -20, 2, 6, 40, 'terraDeep'],   // west rim\n    [26, -2, -20, 2, 6, 40, 'terraDeep'],    // east rim\n    // mesa supports under the path\n    [-3, -2, -3, 6, 2, 6, 'terraDeep'],      // spawn plaza\n    [-13, -2, -3, 8, 2, 5, 'terraDeep'],     // glaze runway\n    [-14, -2, -20, 6, 3, 4, 'terraDeep'],    // north landing\n    [-1, -2, -21, 6, 7, 5, 'terraDeep'],     // high landing\n    [9, -2, -18, 1, 6, 1, 'terraDeep'],      // aqueduct pillars under the slide\n    [14, -2, -18, 1, 6, 1, 'terraDeep'],\n    [18, -2, -18, 1, 6, 1, 'terraDeep'],\n    [21, -2, -19, 5, 5, 4, 'terraDeep'],     // final mesa base\n    // recovery mounds\n    [-1, -2, 6, 3, 2, 3, 'terra'],           // top 0\n    [2, -2, 6, 2, 3, 2, 'terra'],            // top 1 → hop onto the plaza\n    [-16, -2, -8, 3, 2, 3, 'terra'],         // by the ferry crossing → runway\n    // final mesa back stairs — the long way up from the floor\n    [22, -2, -10, 3, 2, 3, 'terra'],         // top 0\n    [22, 0, -13, 3, 2, 3, 'terra'],          // top 2\n    [23, 2, -15, 3, 2, 3, 'terra'],          // top 4 → hop onto the mesa\n    // great pots on the floor\n    [8, -2, 6, 2, 3, 2, 'glaze'],            // drop 5 sits on this one\n    [-10, -2, 12, 2, 2, 2, 'terraDeep'],\n    [18, -2, 2, 2, 2, 2, 'terraDeep'],\n    // ---------- the main path — west, then north, then east ----------\n    [-3, 0, -3, 6, 2, 6, 'terra'],       // spawn plaza, centre of the basin\n    [-13, 0, -3, 8, 2, 5, 'glaze'],      // glaze runway WEST — feel the slide\n    [-14, 1, -20, 6, 2, 4, 'terra'],     // north landing, after the ferry\n    [-1, 5, -21, 6, 2, 5, 'terra'],      // high landing, after the elevator\n    [7, 4, -20, 12, 1, 4, 'glaze'],      // the falls — long slick slide EAST\n    [21, 3, -19, 5, 3, 4, 'terra'],      // final mesa, top 6\n    // goal arch on the mesa\n    [22, 6, -18, 1, 4, 1, 'terraDeep'],\n    [22, 6, -16, 1, 4, 1, 'terraDeep'],\n    [22, 10, -18, 1, 1, 3, 'terraDeep'],\n    // dressing\n    [-6, 2, -1, 1, 1, 1, 'glazePale'],\n    [-12, 3, -18, 1, 1, 1, 'glaze'],\n    [3, 7, -19, 1, 1, 1, 'glazePale'],\n    [25, 6, -16, 1, 2, 1, 'glaze'],\n  ],\n  decor: [\n    // frozen glaze pouring down the north wall into the ponds\n    [9, -2, -20.9, 2, 9, 1, 'glaze'],\n    [16, 0, -20.9, 1, 7, 1, 'glazePale'],\n    [1, -2, -20.9, 2, 8, 1, 'glaze'],\n    [-6, 0, -20.9, 1, 6, 1, 'glazePale'],\n    // mesa skyline outside the rim, all four sides\n    [-16, -6, -30, 20, 12, 6, 'terraDeep'],  // north\n    [8, -6, -30, 16, 15, 6, 'soot'],\n    [-28, -6, -10, 6, 11, 22, 'soot'],       // west\n    [30, -6, -14, 6, 14, 24, 'terraDeep'],   // east\n    [-12, -6, 24, 18, 12, 6, 'terraDeep'],   // south\n    [10, -6, 24, 14, 10, 6, 'soot'],\n    [16, 9, -28, 2, 8, 2, 'terra'],          // chimney\n    [-24, 5, 6, 2, 8, 2, 'terra'],\n  ],\n  drops: [\n    [-9, 3, -0.5],       // sliding down the runway\n    [-12, 3.7, -11],     // riding the ferry\n    [-5, 8.7, -18],      // hop from the top of the elevator\n    [13, 6.5, -18],      // mid-air over the falls slide\n    [9, 2.6, 7],         // on the great glaze pot, south — off the path\n  ],\n  checkpoints: [\n    [-11, 3, -18],       // on the north landing\n    [2, 7, -18.5],       // on the high landing\n  ],\n  goal: [23.5, 6, -17],\n  movers: [\n    // the ferry — carries you north over the molten seep\n    { path: [[-13, 1.2, -8], [-13, 1.2, -14]], size: [2, 0.8, 2], speed: 2.2, mat: 'ash', dwell: 0.6 },\n    // the elevator — lifts you from the landing to the falls\n    { path: [[-6, 3, -19], [-6, 7, -19]], 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 — and now they blow across a PLAYFIELD.\n// From the central plaza a tailwind carries you EAST over a molten\n// trench; the crosswind bridge runs NORTH (gusts shove you sideways\n// into the glow); then a westward tailwind flings you along the island\n// hops back WEST to the goal mesa. Swing the camera, listen for the\n// gust, and lean into it.\n\nexport const level3 = {\n  id: 3,\n  name: 'The Bellows',\n  spawn: [0, 2, 0],\n  killY: -12,\n  boxes: [\n    // ---------- basin terrain ----------\n    [-22, -6, -22, 44, 4, 44, 'terraDeep'],  // bedrock — basin floor, top at -2\n    // molten trenches the bellows feed (scalding)\n    [4, -3, -5, 7, 1, 8, 'ember'],           // under the first tailwind gap\n    [10, -3, -15, 6, 1, 10, 'ember'],        // under the crosswind bridge\n    [-12, -3, -21, 14, 1, 6, 'ember'],       // under the island hops\n    // ash drifts\n    [-10, -2, 8, 4, 1, 4, 'ash'],\n    [16, -2, -12, 3, 1, 3, 'ash'],\n    [-6, -2, -10, 3, 1, 3, 'ash'],\n    // basin rim\n    [-22, -2, -22, 44, 6, 2, 'soot'],        // north rim\n    [-22, -2, 20, 44, 6, 2, 'soot'],         // south rim\n    [-22, -2, -20, 2, 6, 40, 'terraDeep'],   // west rim\n    [20, -2, -20, 2, 6, 40, 'terraDeep'],    // east rim\n    // supports — the path rises out of the land\n    [-3, -2, -3, 6, 2, 6, 'terraDeep'],      // spawn plaza\n    [11, -2, -3, 5, 2, 6, 'terraDeep'],      // east landing\n    [12, -2, -7, 1, 3, 1, 'terraDeep'],      // slender bridge pillars\n    [12, -2, -12, 1, 3, 1, 'terraDeep'],\n    [10, -2, -21, 5, 2, 4, 'terraDeep'],     // rest stop\n    [3, -2, -19, 2, 3, 1, 'terraDeep'],      // island stilts\n    [-4, -2, -19, 2, 3, 1, 'terraDeep'],\n    [-11, -2, -19, 2, 3, 1, 'terraDeep'],\n    [-19, -2, -21, 5, 2, 5, 'terraDeep'],    // goal mesa base\n    // the bellows themselves — great terracotta pipes aimed down the wind lines\n    [1, -2, -7, 2, 4, 2, 'terraDeep'],       // feeds the eastward gust\n    [17, -2, -20, 2, 4, 2, 'terraDeep'],     // feeds the westward gust\n    // recovery mounds\n    [-1, -2, 6, 3, 2, 3, 'terra'],           // top 0\n    [2, -2, 6, 2, 3, 2, 'terra'],            // top 1 → hop onto the plaza\n    [13, -2, -9, 3, 2, 3, 'terra'],          // blown off the bridge? climb here\n    [14, -2, -6, 2, 3, 2, 'terra'],          // → back onto the east landing\n    [-19, -2, -12, 3, 2, 3, 'terra'],        // goal mesa back steps\n    [-19, 0, -15, 3, 2, 3, 'terra'],         // → hop onto the mesa\n    // great pots on the floor\n    [-15, -2, 8, 2, 3, 2, 'terraDeep'],      // drop 5 sits on this one\n    [8, -2, 12, 2, 2, 2, 'glaze'],\n    // ---------- the main path — east, then north, then west ----------\n    [-3, 0, -3, 6, 2, 6, 'terra'],       // spawn plaza, centre of the basin\n    [11, 0, -3, 5, 2, 6, 'terra'],       // across the first tailwind gap (8 wide!)\n    [12, 1, -16, 2, 1, 13, 'ash'],       // narrow crosswind bridge, heading NORTH\n    [12, 2, -8, 2, 2, 1, 'terraDeep'],   // shelter blocks on the bridge\n    [12, 2, -12, 2, 2, 1, 'terraDeep'],\n    [10, 0, -21, 5, 2, 4, 'terra'],      // rest stop by the north rim\n    [3, 1, -20, 2, 1, 3, 'ash'],         // gusting island hops, heading WEST\n    [-4, 1, -20, 2, 1, 3, 'ash'],\n    [-11, 1, -20, 2, 1, 3, 'ash'],\n    [-19, 0, -21, 5, 3, 5, 'terra'],     // goal mesa\n    // secret shelf south of spawn (drop 4 — look behind you)\n    [0, 0, 6, 2, 1, 2, 'ash'],\n    // goal arch\n    [-18, 3, -20, 1, 4, 1, 'terraDeep'],\n    [-18, 3, -17, 1, 4, 1, 'terraDeep'],\n    [-18, 7, -20, 1, 1, 4, 'terraDeep'],\n  ],\n  decor: [\n    // the bellows machines looming beyond the rim\n    [-2, -6, -30, 10, 12, 5, 'terraDeep'],   // north\n    [24, -6, -14, 5, 12, 10, 'terraDeep'],   // east\n    // mesa skyline all round\n    [-18, -6, -30, 14, 9, 5, 'soot'],\n    [10, -6, -30, 14, 13, 5, 'soot'],\n    [-30, -6, -12, 6, 10, 20, 'terraDeep'],  // west\n    [-14, -6, 24, 18, 11, 6, 'soot'],        // south\n    [8, -6, 24, 14, 13, 6, 'terraDeep'],\n    [2, 6, -28, 2, 7, 2, 'terra'],           // bellows chimneys\n    [25, 6, -10, 2, 7, 2, 'terra'],\n  ],\n  drops: [\n    [7, 3.5, 0],         // mid-glide over the tailwind gap\n    [13, 3.5, -10],      // above the crosswind bridge\n    [-0.5, 3.4, -18.5],  // flung between the first island hops\n    [1, 2, 7],           // the shelf south of spawn — look back\n    [-14, 2.6, 9],       // on the great pot, south-west corner\n  ],\n  checkpoints: [\n    [13.5, 2, 0],        // on the east landing\n    [12, 2, -19],        // on the rest stop\n  ],\n  goal: [-16.5, 3, -18.5],\n  movers: [],\n  wind: [\n    // tailwind over the first gap — blows EAST\n    { box: [3, 0, -3, 8, 7, 6], dir: [1, 0], strength: 30, period: 4.0, duty: 0.45, offset: 0 },\n    // crosswind on the bridge — shoves you EAST off the edge\n    { box: [11, 1, -16, 4, 6, 13], dir: [1, 0], strength: 34, period: 5.0, duty: 0.42, offset: 1.2 },\n    // tailwind across the island hops — blows WEST (stops short of the rest stop)\n    { box: [-13, 1, -21, 22, 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// Laid out as an open field of ruin: the first chain hops EAST over a\n// molten trench, crumbling stairs climb NORTH up to a high mesa, and\n// the long bridge runs back WEST high above the basin to the goal.\n// From the top you can see the whole playfield — and the drops below.\n\nexport const level4 = {\n  id: 4,\n  name: 'Cracked Shelves',\n  spawn: [0, 2, 0],\n  killY: -12,\n  boxes: [\n    // ---------- basin terrain ----------\n    [-18, -6, -20, 44, 4, 40, 'terraDeep'],  // bedrock — basin floor, top at -2\n    // molten trenches under each crumble chain (scalding)\n    [4, -3, -4, 15, 1, 6, 'ember'],          // under the first chain, east\n    [20, -3, -17, 4, 1, 13, 'ember'],        // under the stairs, north\n    [-7, -3, -19, 20, 1, 6, 'ember'],        // under the long bridge, west\n    // ash drifts\n    [-10, -2, 8, 4, 1, 3, 'ash'],\n    [12, -2, -10, 3, 1, 3, 'ash'],\n    [-4, -2, -12, 4, 1, 3, 'ash'],\n    // basin rim\n    [-18, -2, -20, 44, 6, 2, 'soot'],        // north rim\n    [-18, -2, 18, 44, 6, 2, 'soot'],         // south rim\n    [-18, -2, -18, 2, 6, 36, 'terraDeep'],   // west rim\n    [24, -2, -18, 2, 6, 36, 'terraDeep'],    // east rim\n    // mesa supports\n    [-3, -2, -3, 6, 2, 6, 'terraDeep'],      // spawn plaza\n    [20, -2, -3, 4, 2, 6, 'terraDeep'],      // mid rest, east\n    [14, -2, -18, 5, 5, 5, 'terraDeep'],     // high-rest mesa, north\n    [-14, -2, -19, 6, 5, 6, 'terraDeep'],    // final mesa, west\n    // recovery mounds\n    [-1, -2, 6, 3, 2, 3, 'terra'],           // top 0\n    [2, -2, 6, 2, 3, 2, 'terra'],            // top 1 → hop onto the plaza\n    [17, -2, -9, 3, 2, 3, 'terra'],          // dropped off the stairs?\n    [18, -2, -6, 2, 3, 2, 'terra'],          // → climb back to the mid rest\n    // great cracked pots on the floor\n    [8, -2, 8, 2, 3, 2, 'terraDeep'],\n    [-12, -2, 4, 2, 2, 2, 'glaze'],\n    [4, -2, -14, 2, 3, 2, 'terraDeep'],\n    // ---------- the solid rests (the crumble chains link them) ----------\n    [-3, 0, -3, 6, 2, 6, 'terra'],       // spawn plaza, centre of the basin\n    [20, 0, -3, 4, 2, 6, 'terra'],       // mid rest, EAST across the chain\n    [14, 3, -18, 5, 3, 5, 'terra'],      // high rest, NORTH up the stairs\n    [-14, 3, -19, 6, 3, 6, 'terra'],     // final mesa, WEST along the bridge\n    // goal arch\n    [-13, 6, -18, 1, 4, 1, 'terraDeep'],\n    [-13, 6, -15, 1, 4, 1, 'terraDeep'],\n    [-13, 10, -18, 1, 1, 4, 'terraDeep'],\n    // dressing — broken pots on the rests\n    [22, 2, -2, 1, 1, 1, 'terraDeep'],\n    [17, 6, -14, 1, 2, 1, 'glaze'],\n    [-9, 6, -14, 1, 1, 1, 'glazePale'],\n  ],\n  decor: [\n    // collapsed shelf wreckage leaning on the rims\n    [6, 0, -19.9, 4, 1, 1, 'ash'],\n    [8, 1, -19.9, 3, 1, 1, 'ash'],\n    [-17.9, 0, -8, 1, 2, 4, 'ash'],\n    // ruined shelf towers on the skyline, all four sides\n    [-14, -6, -28, 16, 14, 5, 'terraDeep'],  // north\n    [6, -6, -28, 14, 10, 5, 'soot'],\n    [-28, -6, -10, 6, 11, 18, 'soot'],       // west\n    [28, -6, -12, 6, 13, 20, 'terraDeep'],   // east\n    [-12, -6, 22, 16, 12, 5, 'soot'],        // south\n    [8, -6, 22, 14, 10, 5, 'terraDeep'],\n    [0, 8, -26, 2, 7, 2, 'ash'],             // a leaning broken post\n    [30, 7, -2, 2, 8, 2, 'ash'],\n  ],\n  drops: [\n    [11.5, 3.2, -1],     // grab mid-hop on the first chain\n    [11, 3.2, 4],        // on the side crumble shelf\n    [22, 6, -10],        // above the crumbling stairs\n    [0.5, 7.2, -16],     // above the middle of the long bridge\n    [-11, 7.6, -18],     // tucked behind the goal arch\n  ],\n  checkpoints: [\n    [22, 2, 0],          // on the mid rest\n    [16, 6, -15.5],      // on the high rest\n  ],\n  goal: [-11, 6, -16],\n  movers: [],\n  wind: [],\n  crumble: [\n    // first chain — flat hops EAST\n    [5, 1, -2, 3, 1, 2],\n    [10, 1, -2, 3, 1, 2],\n    [15, 1, -2, 3, 1, 2],\n    // side shelf hiding drop 2\n    [10, 1, 3, 2, 1, 2],\n    // stairs NORTH\n    [21, 2, -8, 2, 1, 2],\n    [21, 3, -12, 2, 1, 2],\n    [21, 4, -16, 2, 1, 2],\n    // the long bridge WEST\n    [9, 5, -17, 3, 1, 2],\n    [4, 5, -17, 3, 1, 2],\n    [-1, 5, -17, 3, 1, 2],\n    [-6, 5, -17, 3, 1, 2],\n  ],\n  heat: null,\n  crabs: [],\n};\n"},{"name":"level5.js","content":"// KILN — Level 5: The Firing Chamber\n// The kiln is lit — heat rises from below. What was a walled shaft is\n// now an open-air SPIRE: a great brick chimney-column standing in a\n// lake of fire, with ash shelves spiralling all the way around it.\n// You MUST swing the camera as you climb — the next shelf is always\n// a quarter-turn around the column. The heat stops just short of the\n// summit: the top is safe.\n\nexport const level5 = {\n  id: 5,\n  name: 'The Firing Chamber',\n  spawn: [0, 2, 3],\n  killY: -10,\n  boxes: [\n    // ---------- the fire lake ----------\n    [-12, -5, -12, 24, 3, 24, 'ember'],  // molten firebed, top -2 (scalding)\n    // coal heaps — bounce out of the fire onto these, then up\n    [6, -2, 4, 3, 2, 3, 'soot'],         // top 0 → hop onto the chamber floor\n    [-9, -2, -2, 3, 2, 3, 'soot'],       // west-side heap, top 0\n    // pedestal under the chamber floor\n    [-6, -2, -6, 12, 2, 12, 'terraDeep'],\n    // ---------- the spire ----------\n    [-5, 0, -5, 10, 2, 10, 'terra'],     // chamber floor, top 2\n    [-2, 2, -2, 4, 21, 4, 'soot'],       // the chimney-column, y2..23\n    // the climb — a full spiral AROUND the column, rises of 2\n    [4, 3, 2, 3, 1, 3, 'ash'],           // p1 top 4  (SE)\n    [5, 5, -3, 3, 1, 3, 'ash'],          // p2 top 6  (E)\n    [1, 7, -6, 3, 1, 3, 'ash'],          // p3 top 8  (NE)\n    [-4, 9, -6, 3, 1, 3, 'ash'],         // p4 top 10 (NW)\n    [-7, 11, -2, 3, 1, 3, 'ash'],        // p5 top 12 (W)\n    [-6, 13, 3, 3, 1, 3, 'ash'],         // p6 top 14 (SW — lantern here)\n    [-1, 15, 5, 3, 1, 3, 'ash'],         // p7 top 16 (S)\n    [4, 17, 4, 3, 1, 3, 'ash'],          // p8 top 18 (SE again, a turn up)\n    [6, 19, -1, 3, 1, 3, 'ash'],         // p9 top 20 (E)\n    [3, 21, -5, 3, 1, 3, 'ash'],         // p10 top 22 (NE)\n    [-4, 23, -4, 8, 1, 8, 'terra'],      // the summit, capping the column\n    // side pocket shelf north of p3 (drop 2 — step off the spiral)\n    [5, 7, -8, 2, 1, 2, 'ash'],\n    // goal arch on the summit\n    [-1, 24, -2, 1, 4, 1, 'terraDeep'],\n    [-1, 24, 1, 1, 4, 1, 'terraDeep'],\n    [-1, 28, -2, 1, 1, 4, 'terraDeep'],\n  ],\n  decor: [\n    // ember veins riding up the column — the kiln is alive\n    [-2.9, 3, -1, 1, 7, 1, 'ember'],\n    [0, 10, -2.9, 1, 6, 1, 'ember'],\n    [-1, 15, 1.9, 1, 5, 1, 'ember'],\n    [1.9, 6, 0, 1, 6, 1, 'ember'],\n    // old shelf stubs jutting from the column\n    [-3.9, 6, -1, 1, 1, 2, 'ash'],\n    [1, 12, 2.9, 2, 1, 1, 'ash'],\n    [-1, 18, -3.9, 2, 1, 1, 'ash'],\n    // ruined kiln works on the horizon, all four sides\n    [-24, -5, -22, 14, 14, 6, 'soot'],\n    [10, -5, -24, 16, 11, 6, 'terraDeep'],\n    [-26, -5, 8, 6, 12, 14, 'terraDeep'],\n    [20, -5, -6, 6, 16, 16, 'soot'],\n    [-10, -5, 20, 18, 10, 6, 'soot'],\n    [-18, 6, -20, 2, 9, 2, 'terra'],     // chimneys\n    [22, 11, 2, 2, 8, 2, 'terra'],\n  ],\n  drops: [\n    [5.5, 5.8, 0.5],     // hop between p1 and p2\n    [6, 9.7, -7],        // above the pocket shelf, off the spiral\n    [-5.5, 13.6, 0.5],   // rounding the west side, p5 → p6\n    [0.5, 17.6, 6.5],    // the south face, p7 → p8\n    [4.5, 23.8, -3.5],   // the last leap onto the summit\n  ],\n  checkpoints: [\n    [-4.5, 14, 4.5],     // on p6 — respawning resets the heat\n  ],\n  goal: [0.5, 24, 0],\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// The hollow is a walled basin you spawn in the SOUTH of: room one\n// lies WEST, room two NORTH-EAST across a narrow bridge over the\n// central molten crack, and the exit climbs EAST then runs SOUTH\n// along a high path — a loop around the whole playfield.\n\nexport const level6 = {\n  id: 6,\n  name: 'Shard Hollow',\n  spawn: [0, 2, 7],\n  killY: -12,\n  boxes: [\n    // ---------- basin terrain ----------\n    [-18, -6, -18, 50, 4, 36, 'terraDeep'],  // bedrock — floor top at -2\n    // the central molten crack the bridge spans (scalding)\n    [-4, -3, -9, 8, 1, 6, 'ember'],\n    // a second seep by the east wall\n    [24, -3, -14, 3, 1, 4, 'ember'],\n    // ash drifts\n    [-8, -2, 12, 4, 1, 3, 'ash'],\n    [18, -2, -2, 3, 1, 3, 'ash'],\n    [-14, -2, -12, 3, 1, 3, 'ash'],\n    // basin rim\n    [-18, -2, -18, 50, 6, 2, 'soot'],        // north rim\n    [-18, -2, 16, 50, 6, 2, 'soot'],         // south rim\n    [-18, -2, -16, 2, 6, 32, 'terraDeep'],   // west rim\n    [30, -2, -16, 2, 6, 32, 'terraDeep'],    // east rim\n    // skirts under the rooms — they sit like raised kiln beds\n    [-3, -2, 4, 6, 2, 6, 'terraDeep'],       // spawn plaza\n    [-15, -2, -6, 10, 2, 10, 'terraDeep'],   // room one, west\n    [4, -2, -14, 12, 2, 10, 'terraDeep'],    // room two, north-east\n    [-2, -2, -6, 1, 3, 1, 'terraDeep'],      // bridge pillars over the crack\n    [1, -2, -6, 1, 3, 1, 'terraDeep'],\n    [18, -2, -12, 2, 3, 4, 'terraDeep'],     // under the exit stairs\n    [22, -2, -12, 2, 4, 4, 'terraDeep'],\n    [25, -2, -8, 5, 5, 12, 'terraDeep'],     // under the high path\n    // recovery mounds\n    [5, -2, 6, 3, 2, 3, 'terra'],            // top 0 → hop onto the plaza\n    [12, -2, -2, 2, 3, 2, 'terra'],          // top 1 → back into room two\n    [8, -2, -1, 3, 2, 3, 'terra'],           // top 0 → step to the mound above\n    // heaps of giant broken pots on the basin floor\n    [-14, -2, 10, 3, 4, 3, 'terraDeep'],     // a toppled amphora\n    [-13, 2, 10, 2, 1, 2, 'terra'],\n    [20, -2, 6, 2, 3, 2, 'glaze'],\n    [-4, -2, -14, 2, 3, 2, 'terraDeep'],\n    // ---------- the main path — a loop around the hollow ----------\n    [-3, 0, 4, 6, 2, 6, 'terra'],        // spawn plaza, south-centre\n    [-15, 0, -6, 10, 2, 10, 'terra'],    // room one, WEST — two crabs\n    [-5, 1, -6, 9, 1, 2, 'ash'],         // narrow bridge over the crack, one crab\n    [4, 0, -14, 12, 2, 10, 'terra'],     // room two, NORTH-EAST — three crabs\n    [8, 2, -11, 3, 1, 3, 'ash'],         // raised shelf in room two\n    [12, 2, -12, 2, 3, 2, 'terraDeep'],  // pillar to hide behind\n    [18, 1, -12, 2, 2, 4, 'ash'],        // exit stairs, EAST\n    [22, 2, -12, 2, 2, 4, 'ash'],\n    [25, 3, -8, 5, 2, 12, 'terra'],      // high path, running SOUTH — last crab\n    // side alcove behind room one (drop 5)\n    [-12, 0, 7, 2, 1, 2, 'ash'],\n    // goal arch at the south end of the high path\n    [26, 5, 1, 1, 4, 1, 'terraDeep'],\n    [29, 5, 1, 1, 4, 1, 'terraDeep'],\n    [26, 9, 1, 4, 1, 1, 'terraDeep'],\n    // shattered pots everywhere — this is where they crawled from\n    [-13, 2, -4, 1, 1, 1, 'terraDeep'],\n    [-7, 2, 2, 1, 1, 1, 'terraDeep'],\n    [6, 2, -6, 1, 1, 1, 'terraDeep'],\n    [14, 2, -7, 1, 2, 1, 'glaze'],\n  ],\n  decor: [\n    // heaps of shards leaning on the rims\n    [-2, 0, -17.9, 3, 2, 1, 'terraDeep'],\n    [-1, 2, -17.9, 1, 1, 1, 'ash'],\n    [29.9, 0, -4, 1, 2, 4, 'terraDeep'],\n    // broken kiln stacks on the skyline, all four sides\n    [-14, -6, -26, 18, 15, 5, 'terraDeep'],  // north\n    [8, -6, -26, 16, 11, 5, 'soot'],\n    [-28, -6, -8, 6, 12, 18, 'soot'],        // west\n    [34, -6, -10, 6, 13, 18, 'terraDeep'],   // east\n    [-10, -6, 20, 16, 12, 5, 'soot'],        // south\n    [10, -6, 20, 14, 14, 5, 'terraDeep'],\n    [0, 9, -24, 2, 8, 2, 'terra'],           // chimney\n    [36, 7, -2, 2, 7, 2, 'terra'],\n  ],\n  drops: [\n    [-10, 3.5, -1],      // room one, off the main line\n    [-0.5, 3.3, -5],     // above the bridge (jump over the crab!)\n    [9.5, 4.2, -9.5],    // on the raised shelf\n    [13, 2.8, -13],      // hidden behind the pillar\n    [-11, 2, 8],         // the alcove behind room one\n  ],\n  checkpoints: [\n    [10, 2, -8],         // in room two\n    [27, 5, -6],         // on the high path\n  ],\n  goal: [27.5, 5, 2],\n  movers: [],\n  wind: [],\n  crumble: [],\n  heat: null,\n  crabs: [\n    { a: [-14, -4], b: [-7, -4], y: 2, speed: 2.2 },\n    { a: [-7, 2], b: [-14, 2], y: 2, speed: 2.6 },\n    { a: [-4, -5], b: [3, -5], y: 2, speed: 2.0 },\n    { a: [5, -12], b: [15, -12], y: 2, speed: 2.4 },\n    { a: [15, -6], b: [5, -6], y: 2, speed: 2.8 },\n    { a: [10, -13], b: [10, -5], y: 2, speed: 2.2 },\n    { a: [27, -7], b: [27, 3], 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, a colossal\n// cracked amphora that lobs molten glaze in three phases.\n// The approach is a scorched valley that WINDS: the glaze runway bends\n// north-east, the ferry crosses a molten fork, the bellows-gap blows\n// you SOUTH, crumbling stairs climb back EAST, and the crab bridge\n// runs NORTH before the final hops east into the boss chamber.\n// (The arena itself keeps its exact old coordinates — the fight is tuned.)\n\nexport const level7 = {\n  id: 7,\n  name: 'The Kilnheart',\n  spawn: [2, 2, 0],\n  killY: -12,\n  boxes: [\n    // ---------- valley terrain ----------\n    [-8, -6, -16, 117, 4, 32, 'terraDeep'],  // bedrock — runs under the arena too\n    // the molten river, fork by fork (scalding)\n    [17, -3, -13, 9, 1, 7, 'ember'],         // under the ferry\n    [25, -3, -7, 8, 1, 7, 'ember'],          // under the wind gap\n    [32, -3, -1, 12, 1, 6, 'ember'],         // under the crumble stairs\n    [45, -3, -9, 6, 1, 8, 'ember'],          // under the crab bridge\n    [50, -3, -16, 30, 1, 10, 'ember'],       // the chasm under the final hops\n    // ash drifts\n    [-4, -2, 6, 4, 1, 3, 'ash'],\n    [36, -2, -12, 4, 1, 2, 'ash'],\n    [58, -2, 4, 4, 1, 3, 'ash'],\n    // valley walls (the boss chamber has its own decor walls)\n    [-8, -2, -16, 93, 7, 2, 'soot'],         // north wall along the approach\n    [-8, -2, 14, 93, 7, 2, 'soot'],          // south wall\n    [-8, -2, -14, 2, 7, 28, 'terraDeep'],    // west wall behind spawn\n    // mesa supports\n    [0, -2, -3, 6, 2, 6, 'terraDeep'],       // start plaza\n    [8, -2, -6, 8, 2, 4, 'terraDeep'],       // glaze runway\n    [26, -2, -13, 5, 2, 5, 'terraDeep'],     // ferry landing\n    [26, -2, 0, 5, 2, 5, 'terraDeep'],       // wind landing\n    [45, -2, -1, 6, 5, 6, 'terraDeep'],      // high rest\n    [52, -2, -12, 2, 6, 2, 'terraDeep'],     // stilts under the final hops\n    [59, -2, -11, 2, 6, 2, 'terraDeep'],\n    [66, -2, -10, 2, 6, 2, 'terraDeep'],\n    [73, -2, -9, 2, 6, 2, 'terraDeep'],\n    [79, -2, -8, 6, 5, 8, 'terraDeep'],      // pre-boss mesa\n    [88, -2, -9, 20, 2, 18, 'terraDeep'],    // skirt under the arena\n    // recovery mounds\n    [2, -2, 4, 3, 2, 3, 'terra'],            // top 0\n    [5, -2, 4, 2, 3, 2, 'terra'],            // top 1 → start plaza\n    [21, -2, -15, 3, 2, 3, 'terra'],         // top 0 by the ferry landing\n    [24, -2, -15, 2, 3, 2, 'terra'],         // top 1 → ferry landing\n    [30, -2, 6, 3, 2, 3, 'terra'],           // top 0 → wind landing\n    // scorched pots along the road\n    [12, -2, 4, 2, 3, 2, 'terraDeep'],\n    [38, -2, -8, 2, 2, 2, 'glaze'],\n    // ---------- the gauntlet — it winds ----------\n    [0, 0, -3, 6, 2, 6, 'terra'],        // start plaza\n    [8, 0, -6, 8, 2, 4, 'glaze'],        // glaze runway, bending NORTH-EAST\n    [26, 0, -13, 5, 2, 5, 'terra'],      // ferry landing, north side\n    [26, 0, 0, 5, 2, 5, 'terra'],        // wind landing — the gust blew you SOUTH\n    [33, 1, 1, 2, 1, 2, 'ash'],          // crumble stairs climbing EAST (solid tops below)\n    [45, 3, -1, 6, 3, 6, 'terra'],       // high rest after the stairs\n    [47, 5, -9, 2, 1, 8, 'ash'],         // crab gauntlet bridge, running NORTH\n    [51, 4, -13, 4, 2, 4, 'terra'],      // final hops EAST, high over the chasm\n    [58, 4, -12, 4, 2, 4, 'terra'],\n    [65, 4, -11, 4, 2, 4, 'terra'],\n    [72, 4, -10, 4, 2, 4, 'terra'],\n    [79, 3, -8, 6, 3, 8, 'terra'],       // pre-boss landing\n    // ---- boss arena (UNCHANGED coordinates — the fight is tuned to it) ----\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 (genuinely scalding — stay central)\n    [88, 4, -9, 3, 1, 2, 'ember'],\n    [105, 4, 7, 3, 1, 2, 'ember'],\n  ],\n  decor: [\n    // the kilnheart chamber — a soot shell behind the arena, kept LOW\n    // so the orbit camera can always see over it\n    [85, -2, -14, 26, 1, 28, 'ember'],   // firebed under the arena\n    [85, 4, -13, 26, 8, 2, 'soot'],      // back wall of the chamber\n    [107, 4, -13, 2, 8, 26, 'soot'],     // far end wall\n    // ruined kiln works on the skyline, both sides of the valley\n    [-6, -6, -24, 18, 12, 5, 'soot'],\n    [20, -6, -24, 20, 15, 5, 'terraDeep'],\n    [46, -6, -24, 18, 11, 5, 'soot'],\n    [10, -6, 18, 18, 13, 5, 'terraDeep'],\n    [40, -6, 18, 20, 10, 5, 'soot'],\n    [66, -6, 18, 16, 14, 5, 'terraDeep'],\n    [28, 9, -23, 2, 9, 2, 'terra'],      // chimneys\n    [72, 8, 20, 2, 8, 2, 'terra'],\n  ],\n  drops: [\n    [11, 3.3, -4],       // on the glaze runway\n    [21, 3.5, -9],       // riding the ferry\n    [28.5, 3.6, -3],     // mid-air in the wind gap\n    [39, 4.8, 2],        // above the crumble stairs\n    [48, 7.5, -5],       // above the crab bridge\n  ],\n  checkpoints: [\n    [28, 2, -10.5],      // on the ferry landing\n    [47, 6, 2],          // on the high rest\n    [81, 6, -4],         // right before the boss\n  ],\n  goal: null,            // beating the Kilnheart ends the level\n  movers: [\n    // the ferry across the molten fork\n    { path: [[18, 1.2, -10], [24, 1.2, -10]], size: [2, 0.8, 2], speed: 2.2, mat: 'ash', dwell: 0.6 },\n  ],\n  wind: [\n    // the bellows gap — blows you SOUTH across the river\n    { box: [26, 0, -8, 6, 7, 10], dir: [0, 1], strength: 30, period: 4.0, duty: 0.45, offset: 0 },\n  ],\n  crumble: [\n    [37, 2, 1, 2, 1, 2],\n    [41, 3, 1, 2, 1, 2],\n  ],\n  heat: null,\n  crabs: [\n    { a: [48, -8], b: [48, -2], y: 6, speed: 2.6 },\n    { a: [48, -2], b: [48, -8], 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"},{"name":"level8.js","content":"// KILN — Level 8: The Glassworks\n// POST-KILNHEART. The kiln is warm again — and the first firing GLASSED\n// everything. An open basin of vitrified glaze: a slick runway slides\n// you EAST, a glass carousel ferries you NORTH over an ember lake, the\n// bellows-draft flings you WEST across a trench, glass stairs climb to\n// a high terrace where shard crabs survived the firing — and a second,\n// higher carousel swings you SOUTH onto the goal mesa you could see\n// from spawn. Everything slippery, everything gleaming.\n\nexport const level8 = {\n  id: 8,\n  name: 'The Glassworks',\n  spawn: [0, 2, 5],\n  killY: -12,\n  boxes: [\n    // ---------- basin terrain ----------\n    [-24, -6, -20, 48, 4, 38, 'terraDeep'],  // bedrock — basin floor, top at -2\n    // molten glaze pools, flush with the floor (scalding — bounce out fast)\n    [12, -3, -6, 9, 1, 10, 'ember'],         // the ember lake under carousel one\n    [5, -3, -14, 7, 1, 8, 'ember'],          // the trench under the wind gap\n    [-18, -3, 10, 5, 1, 5, 'ember'],         // ember pond in the south-west corner\n    // ash drifts — the last un-glassed dust\n    [-8, -2, 6, 3, 1, 3, 'ash'],\n    [14, -2, 12, 4, 1, 3, 'ash'],\n    [-14, -2, -14, 3, 1, 3, 'ash'],\n    // basin rim — low enough that the orbit camera always sees over it\n    [-24, -2, -20, 48, 6, 2, 'soot'],        // north rim\n    [-24, -2, 16, 48, 6, 2, 'soot'],         // south rim\n    [-24, -2, -18, 2, 6, 34, 'terraDeep'],   // west rim\n    [22, -2, -18, 2, 6, 34, 'terraDeep'],    // east rim\n    // mesa supports — the path grows out of the land\n    [-3, -2, 2, 6, 2, 6, 'terraDeep'],       // under the spawn plaza\n    [5, -2, 2, 8, 2, 4, 'terraDeep'],        // under the glass runway\n    [12, -2, -12, 6, 3, 5, 'terraDeep'],     // under the carousel landing\n    [0, -2, -13, 5, 3, 5, 'terraDeep'],      // under the wind landing\n    [-5, -2, -13, 3, 4, 3, 'terraDeep'],     // under the glass stairs\n    [-9, -2, -9, 3, 5, 3, 'terraDeep'],\n    [-17, -2, -8, 8, 4, 6, 'terraDeep'],     // under the crab terrace\n    // recovery mounds — fell off? climb back up here\n    [5, -2, 9, 3, 2, 3, 'terra'],            // top 0\n    [2, -2, 9, 2, 3, 2, 'terra'],            // top 1 → hop onto the plaza\n    [8, -2, -17, 3, 2, 3, 'terra'],          // top 0, under the north landings\n    [5, -2, -17, 2, 3, 2, 'terra'],          // top 1 → hop to the wind landing\n    // the goal mesa's back stairs — the long way up from the floor\n    [-13, -2, 10, 3, 2, 3, 'terra'],         // top 0\n    [-14, 0, 6, 3, 2, 3, 'terra'],           // top 2\n    [-15, 2, 2, 3, 2, 3, 'terra'],           // top 4 → hop onto the mesa\n    // great glassed pots resting on the basin floor\n    [9, -2, 11, 2, 3, 2, 'glaze'],           // drop 4 sits on this one\n    [-6, -2, 13, 2, 2, 2, 'glazePale'],\n    [17, -2, 7, 2, 2, 2, 'glaze'],\n    // ---------- the main path — a horseshoe of glass ----------\n    [-3, 0, 2, 6, 2, 6, 'terra'],        // spawn plaza — the last matte clay\n    [5, 0, 2, 8, 2, 4, 'glaze'],         // the glass runway, sliding EAST\n    [10, 2, 3, 1, 1, 1, 'glazePale'],    // a fused pot mid-runway — slalom it\n    [12, 1, -12, 6, 2, 5, 'terra'],      // carousel landing, NORTH shore\n    [0, 1, -13, 5, 2, 5, 'terra'],       // wind landing — the draft blew you WEST\n    [-5, 2, -13, 3, 2, 3, 'glaze'],      // glass stairs climbing SOUTH-WEST\n    [-9, 3, -9, 3, 2, 3, 'glaze'],\n    [-17, 2, -8, 8, 4, 6, 'terra'],      // the crab terrace, top 6\n    [-22, -2, 6, 6, 8, 6, 'terra'],      // the goal mesa, top 6 — visible from spawn\n    // goal arch on the mesa\n    [-19, 6, 8, 1, 4, 1, 'terraDeep'],\n    [-19, 6, 11, 1, 4, 1, 'terraDeep'],\n    [-19, 10, 8, 1, 1, 4, 'terraDeep'],\n    // small fired pots for set dressing (solid, small)\n    [1, 2, 3, 1, 1, 1, 'glazePale'],\n    [16, 3, -10, 1, 1, 1, 'glaze'],\n    [-11, 6, -7, 1, 1, 1, 'glazePale'],\n    [-18, 6, 7, 1, 2, 1, 'glaze'],\n  ],\n  decor: [\n    // stepped mesa skyline outside the rim on every side\n    [-22, -6, -30, 20, 12, 6, 'soot'],       // north\n    [4, -6, -30, 16, 14, 6, 'terraDeep'],\n    [-32, -6, -12, 6, 13, 20, 'terraDeep'],  // west\n    [26, -6, -14, 6, 11, 22, 'soot'],        // east\n    [-14, -6, 22, 18, 10, 6, 'terraDeep'],   // south\n    [8, -6, 22, 16, 13, 6, 'soot'],\n    // vitrified spires — the firing glassed the horizon too\n    [26, 3, -2, 2, 9, 2, 'glazePale'],\n    [-30, 4, 4, 2, 8, 2, 'glazePale'],\n    [12, 8, -28, 2, 7, 2, 'glazePale'],\n    // kiln chimneys, breathing again\n    [-28, 7, -6, 2, 8, 2, 'terra'],\n    [14, 8, 24, 2, 7, 2, 'terra'],\n  ],\n  drops: [\n    [11, 4.2, 4],        // mid-slide over the glass runway — jump for it\n    [19.5, 4.6, -1],     // riding carousel one, out over the ember lake\n    [8.5, 4.8, -10],     // mid-air in the wind gap\n    [10, 2.6, 12],       // on the great glassed pot, south-east — off the path\n    [-19, 7.6, 4],       // the leap from carousel two onto the goal mesa\n  ],\n  checkpoints: [\n    [15, 3, -9.5],       // on the carousel landing\n    [-16, 6, -7],        // on the crab terrace, away from the patrol\n  ],\n  goal: [-18.5, 6, 9.5],\n  movers: [\n    // carousel one — two glass platforms turning over the ember lake\n    { orbit: { center: [15, 2.4, -1], radius: 4.5, speed: 0.6, phase: 0 }, size: [3, 0.8, 3], mat: 'glaze' },\n    { orbit: { center: [15, 2.4, -1], radius: 4.5, speed: 0.6, phase: 3.14 }, size: [3, 0.8, 3], mat: 'glazePale' },\n    // carousel two — higher, tighter, swinging from the terrace to the mesa\n    { orbit: { center: [-19, 5.2, 2], radius: 4, speed: 0.65, phase: 0 }, size: [3, 0.8, 3], mat: 'glazePale' },\n    { orbit: { center: [-19, 5.2, 2], radius: 4, speed: 0.65, phase: 3.14 }, size: [3, 0.8, 3], mat: 'glaze' },\n  ],\n  wind: [\n    // the bellows-draft — blows you WEST across the trench\n    { box: [4, 1, -14, 9, 6, 8], dir: [-1, 0], strength: 28, period: 3.8, duty: 0.5, offset: 0 },\n  ],\n  crumble: [],\n  heat: null,\n  crabs: [\n    // shard crabs that survived the firing, patrolling the terrace\n    { a: [-15, -5], b: [-11, -5], y: 6, speed: 2.3 },\n    { a: [-11, -4], b: [-15, -4], y: 6, speed: 2.9 },\n  ],\n};\n"},{"name":"level9.js","content":"// KILN — Level 9: The Chimney\n// POST-KILNHEART. The way OUT. The great flue rises out of the relit\n// firebed — taller than the Firing Chamber, and meaner. Heat climbs\n// from below while you spiral the stack: ash shelves give way to\n// CRUMBLING brick, a glaze elevator hauls you up the windless middle,\n// and near the mouth of the flue a crosswind howls, shoving you EAST\n// off the last crumbling holds. The crown is safe. The sky is close.\n\nexport const level9 = {\n  id: 9,\n  name: 'The Chimney',\n  spawn: [0, 2, 4],\n  killY: -10,\n  boxes: [\n    // ---------- the relit firebed ----------\n    [-14, -5, -14, 28, 3, 28, 'ember'],  // molten firebed, top -2 (scalding)\n    // coal heaps — bounce out of the fire onto these, then up\n    [7, -2, 3, 3, 2, 3, 'soot'],         // top 0 → hop onto the chamber floor\n    [-9, -2, -7, 3, 2, 3, 'soot'],       // west-side heap, top 0\n    // pedestal under the chamber floor\n    [-6, -2, -6, 12, 2, 12, 'terraDeep'],\n    // ---------- the flue ----------\n    [-5, 0, -5, 10, 2, 10, 'terra'],     // chamber floor, top 2 — mind the crab\n    [-3, 2, -3, 6, 30, 6, 'soot'],       // the great flue, y2..32\n    // the climb — spiralling the stack, rises of 2\n    [5, 3, 3, 3, 1, 3, 'ash'],           // p1 top 4  (SE)\n    [6, 5, -2, 3, 1, 3, 'ash'],          // p2 top 6  (E)\n    [3, 7, -7, 3, 1, 3, 'ash'],          // p3 top 8  (NE)\n    // (crumbling brick takes over — c1 top 10, c2 top 12, keep moving)\n    [-8, 13, -2, 3, 1, 3, 'ash'],        // p4 top 14 (W — lantern, elevator stop)\n    [-2, 23, 5, 3, 1, 3, 'ash'],         // p5 top 24 (S — step off the elevator)\n    [4, 25, 3, 3, 1, 3, 'ash'],          // p6 top 26 (SE — the wind starts here)\n    // (crumbling again — c3 top 28, c4 top 30, into the gusts)\n    [-2, 31, -8, 3, 1, 3, 'ash'],        // p7 top 32 (N — lantern, above the heat)\n    [-4, 32, -4, 8, 2, 8, 'terra'],      // the crown, capping the flue — top 34\n    // side pocket shelf east of p3 (drop 2 — step off the spiral)\n    [7, 7, -8, 2, 1, 2, 'ash'],\n    // goal arch on the crown — the mouth of the kiln\n    [-1, 34, -2, 1, 4, 1, 'terraDeep'],\n    [-1, 34, 1, 1, 4, 1, 'terraDeep'],\n    [-1, 38, -2, 1, 1, 4, 'terraDeep'],\n  ],\n  decor: [\n    // ember veins riding up the flue — the kiln breathes now\n    [-3.9, 4, -1, 1, 8, 1, 'ember'],\n    [0, 12, -3.9, 1, 7, 1, 'ember'],\n    [-1, 19, 2.9, 1, 6, 1, 'ember'],\n    [2.9, 8, 0, 1, 7, 1, 'ember'],\n    [1, 26, -3.9, 1, 5, 1, 'ember'],\n    // a vitrified collar under the crown — the first firing reached this high\n    [-3, 31, -3.9, 6, 1, 1, 'glazePale'],\n    [-3, 31, 2.9, 6, 1, 1, 'glazePale'],\n    [-3.9, 31, -3, 1, 1, 6, 'glazePale'],\n    [2.9, 31, -3, 1, 1, 6, 'glazePale'],\n    // old shelf stubs jutting from the stack\n    [-4.9, 8, -1, 1, 1, 2, 'ash'],\n    [1, 16, 2.9, 2, 1, 1, 'ash'],\n    [-1, 21, -4.9, 2, 1, 1, 'ash'],\n    [2.9, 28, 1, 1, 1, 2, 'ash'],\n    // ruined kiln works on the horizon, all four sides\n    [-26, -5, -22, 14, 15, 6, 'soot'],\n    [10, -5, -24, 16, 12, 6, 'terraDeep'],\n    [-28, -5, 8, 6, 13, 14, 'terraDeep'],\n    [20, -5, -6, 6, 17, 16, 'soot'],\n    [-10, -5, 20, 18, 11, 6, 'soot'],\n    [-20, 8, -20, 2, 10, 2, 'terra'],    // chimneys — none as tall as this one\n    [22, 12, 2, 2, 9, 2, 'terra'],\n  ],\n  drops: [\n    [7.5, 6.4, 1.5],     // the hop between p1 and p2\n    [8, 9.8, -7],        // above the pocket shelf, off the spiral\n    [-5, 19, 4],         // lean off the elevator's east edge, mid-ride\n    [5.5, 31.6, -2.5],   // the crumbling leap from c3 to c4, in the gusts\n    [2.5, 35.8, 2.5],    // over the crown's south-east corner\n  ],\n  checkpoints: [\n    [-6.5, 14, -0.5],    // on p4 — respawning resets the heat\n    [-0.5, 32, -6.5],    // on p7, above the heat line\n  ],\n  goal: [0.5, 34, 0],\n  movers: [\n    // the glaze elevator — hauls you up the windless middle of the climb\n    { path: [[-6, 15, 4], [-6, 23, 4]], size: [2.5, 0.8, 2.5], speed: 2.4, mat: 'glaze', dwell: 0.9 },\n  ],\n  wind: [\n    // the draft over the flue mouth — shoves you EAST off the upper holds\n    { box: [-9, 24, -9, 18, 10, 18], dir: [1, 0], strength: 16, period: 5.5, duty: 0.4, offset: 1.2 },\n  ],\n  crumble: [\n    [-1, 9, -8, 2, 1, 2],    // c1 top 10 (N)\n    [-6, 11, -7, 2, 1, 2],   // c2 top 12 (NW)\n    [6, 27, -2, 2, 1, 2],    // c3 top 28 (E)\n    [3, 29, -6, 2, 1, 2],    // c4 top 30 (NE)\n  ],\n  heat: { startY: -2, delay: 6, speed: 0.6, maxY: 29 },\n  crabs: [\n    // one last shard crab, circling the chamber floor at the start\n    { a: [-3.5, 4.2], b: [3.5, 4.2], y: 2, speed: 2.4 },\n  ],\n};\n"}],"folders":[]}]},{"folder":"other","files":[{"name":"main.sketch","content":""}],"folders":[]}]},"variants":null,"createdAt":"2026-07-07T22:09:09.593Z","updatedAt":"2026-07-08T00:19:15.254Z"}}