• 01 - Critter Models 02
  • 02 - VantaJS Shaders
  • 03 - App Bundle 02
  • 04 - Cathedrals
  • 05 - App Bundle 01
  • 06 - Retroscopic
  • 07 - Games
  • 08 - Voronoi
  • 09 - Terrain
  • 10 - Critter Models 01
  • 11 - Chromatophores
  • 12 - Conformal Maps
  • 13 - Echo Logix
  • 14 - Music
  • 15 - Colour Theory
MIKE'S NOTEBOOK
Notes
Number:
Project:
07Games
└ Rockfall└ Speak & Math└ Space Invaders└ Apache└ Asteroids└ Molly Roger└ Carrom└ Chess└ Connect 4└ Anagrams
08Voronoi
└ 001└ 002└ 003└ 004└ 005
09Terrain
└ Simple Polygon└ Simple Flyover└ Infinite Flyover 01└ Infinite Flyover 02└ Hexagonal Plane└ Mountain Ridges
10Critter Models 01
└ Wireframe Spider└ Wireframe Fly└ Wireframe Scorpion└ Wireframe Mosquito└ Wireframe Bee└ Wireframe Butterfly└ Wireframe Locust
11Chromatophores
└ 01└ 02└ 03└ 04
12Conformal Maps
└ 01└ 02└ 03└ 04└ 05
02 / 03

ROCKFALL - INTERACTIVE ARCADE GAME SOURCE CODE

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ROCKFALL — a Repton-style puzzler</title>
<style>
  @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;700&display=swap');

  :root{
    --phosphor:#57ff8a;
    --amber:#ffffff;
    --bezel:#0c0d0d;
    --bezel-hi:#141515;
    --screen:#000000;
    --danger:#ff5d5d;
    --bg-color:#000000;
    --text-color:#ffffff;
    --text-secondary:#848484;
    --border-color:rgba(255, 255, 255, 0.18);
  }
  *{box-sizing:border-box;margin:0;padding:0}
  html,body{min-height:100%}
  body{
    background:#000000;
    color:var(--text-color);
    font-family:'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
    display:flex;flex-direction:column;align-items:center;
    justify-content:flex-start;
    min-height:100vh;
    padding:185px 24px 48px;
    overflow-x:hidden;
    -webkit-font-smoothing:antialiased;
  }
  .cabinet{
    background:var(--bezel);
    border:1px solid var(--border-color);
    border-radius:0px;
    padding:20px 24px 18px;
    box-shadow:0 24px 60px rgba(0,0,0,0.9);
    max-width:840px;width:100%;
    display:flex;flex-direction:column;
  }
  .marquee{
    display:flex;justify-content:space-between;align-items:baseline;
    padding:0px 0px 14px;
    border-bottom:1px solid var(--border-color);
    margin-bottom:14px;
    letter-spacing:0.02em;text-transform:uppercase;
  }
  .marquee h1{font-family:'Inter',sans-serif;font-size:22px;color:var(--text-color);font-weight:800;letter-spacing:-0.02em}
  .marquee small{font-family:'JetBrains Mono',monospace;color:var(--text-secondary);letter-spacing:0.15em;font-size:11px;font-weight:500}
  .screen{
    position:relative;background:var(--screen);
    border-radius:0px;border:1px solid rgba(255,255,255,0.15);
    box-shadow:inset 0 0 40px rgba(0,0,0,0.9), inset 0 0 6px rgba(87,255,138,0.12);
    overflow:hidden;
    display:flex;justify-content:center;align-items:center;
  }
  canvas{
    display:block;width:100%;
    max-height:calc(100vh - 340px);
    height:auto;
    aspect-ratio:840/480;
    image-rendering:pixelated;
    margin:0 auto;
  }
  .scanlines{
    pointer-events:none;position:absolute;inset:0;
    background:repeating-linear-gradient(0deg, rgba(0,0,0,.16) 0 1px, transparent 1px 3px);
    mix-blend-mode:multiply;
  }
  .glow{pointer-events:none;position:absolute;inset:0;
    background:radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,.35) 100%);}
  .hud{
    display:flex;flex-wrap:wrap;gap:8px 24px;justify-content:space-between;
    padding:0px 0px 14px;font-size:11px;font-family:'JetBrains Mono',monospace;
    letter-spacing:0.15em;text-transform:uppercase;color:var(--text-secondary);
    border-bottom:1px solid var(--border-color);
    margin-bottom:14px;font-weight:500;
  }
  .hud span b{color:var(--amber);font-weight:700}
  .hud .low b{color:var(--danger)}
  .hud .go b{color:var(--phosphor);animation:pulse 1s infinite}
  @keyframes pulse{50%{opacity:.4}}
  .controls{
    display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:12px;
    padding:14px 0px 0px;color:var(--text-secondary);font-size:11px;
    font-family:'JetBrains Mono',monospace;letter-spacing:0.12em;text-transform:uppercase;
  }
  .controls-btns{
    display:flex;flex-wrap:wrap;gap:8px;justify-content:flex-end;
  }
  button{
    background:#000000;color:var(--text-color);
    border:1px solid var(--border-color);border-radius:0px;
    font-family:'JetBrains Mono',monospace;font-size:11px;letter-spacing:0.15em;text-transform:uppercase;
    padding:6px 14px;cursor:pointer;font-weight:500;
    box-shadow:2px 2px 0px 0px rgba(255,255,255,0.3);
    transition:all 0.2s ease;
  }
  button:hover{background:#ffffff;color:#000000;border-color:#ffffff;box-shadow:none;transform:translate(2px,2px)}
  button:focus-visible{outline:1px solid #ffffff;outline-offset:2px}
  .pad{display:none;gap:8px;justify-content:center;padding-top:14px;user-select:none;touch-action:none}
  .pad .col{display:flex;flex-direction:column;gap:8px;align-items:center}
  .pad button{width:56px;height:56px;font-size:20px;border-radius:0px;box-shadow:none}
  @media (pointer:coarse){ .pad{display:flex} }
  @media (prefers-reduced-motion:reduce){ .scanlines{display:none} }

  /* Fullscreen Mode Styling */
  body.in-fullscreen { padding: 16px 20px !important; justify-content: center !important; }
  body.in-fullscreen .cabinet { max-width: 1060px !important; box-shadow: none !important; border: none !important; margin: auto !important; }
  body.in-fullscreen canvas { max-height: calc(100vh - 160px) !important; }
</style>
</head>
<body>
<div class="cabinet">
  <div class="marquee"><h1>Rockfall</h1><small>a Repton-style puzzler · original edition</small></div>
  <div class="hud">
    <span>Level <b id="hLevel">1</b></span>
    <span id="hGemWrap">Diamonds <b id="hGems">0/0</b></span>
    <span>Score <b id="hScore">0</b></span>
    <span id="hTimeWrap">Time <b id="hTime">120</b></span>
    <span>Lives <b id="hLives">3</b></span>
  </div>
  <div class="screen">
    <canvas id="game" width="840" height="480" aria-label="Rockfall game screen"></canvas>
    <div class="scanlines"></div>
    <div class="glow"></div>
  </div>
  <div class="controls">
    <span id="hint">Arrows / WASD move · M for survey map · F for fullscreen</span>
    <span class="controls-btns">
      <button id="btnMap">Map (M)</button>
      <button id="btnPause">Pause (P)</button>
      <button id="btnRestart">Restart (R)</button>
      <button id="btnSound">SFX: On</button>
      <button id="btnMusic">Music: On</button>
      <button id="btnFullscreen">Fullscreen (F)</button>
    </span>
  </div>
  <div class="pad" id="pad">
    <div class="col"><button data-d="left">◀</button></div>
    <div class="col"><button data-d="up">▲</button><button data-d="down">▼</button></div>
    <div class="col"><button data-d="right">▶</button></div>
  </div>
</div>

<script>
"use strict";
/* ============================ ROCKFALL ============================
   An original tribute to the boulder-and-diamond puzzlers of the
   BBC Micro era. All art, levels and code are original.

   THE STRATEGIC CORE
   · Quota + portal : you need N of M diamonds, then must REACH the
     exit — so every tunnel you dig is also your escape route.
   · Fragile gems   : a falling rock SHATTERS a diamond. Dig order
     matters. Slack (M-N) is your error budget.
   · Monsters       : hatch from dropped eggs, hunt you through your
     own tunnels, and EAT diamonds. Trap them under rocks, fast.
   · Spirits        : follow walls with perfect predictability. Open
     a path at the right moment and they glide into a cage — and
     become a diamond. Mistime it and they're loose in your tunnels.
   =================================================================== */

const COLS = 48, ROWS = 32, TILE = 60;      // 14x8 viewport of a 48x32 world — 12+ screens
const cv = document.getElementById("game");
const ctx = cv.getContext("2d");
ctx.imageSmoothingEnabled = false;

/* ---------- scrolling camera + screen shake ---------- */
let camX=0, camY=0, mapView=false, shake=0;
const REDUCED = typeof window!=="undefined" && window.matchMedia
             && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function addShake(n){ if(!REDUCED) shake=Math.min(12,Math.max(shake,n)); }
function camTarget(){
  const tx=Math.max(0,Math.min(COLS*TILE-cv.width,  playerX*TILE+TILE/2-cv.width/2));
  const ty=Math.max(0,Math.min(ROWS*TILE-cv.height, playerY*TILE+TILE/2-cv.height/2));
  return [tx,ty];
}

/* ---------- tile ids ---------- */
const T = {EMPTY:0, DIRT:1, WALL:2, ROCK:3, DIAMOND:4, EGG:5, CRACK:6,
           MONSTER:7, KEY:8, SAFE:9, PLAYER:10, EXIT:11, SPIRIT:12, CAGE:13};
const CH = {"#":T.WALL, ".":T.DIRT, " ":T.EMPTY, "O":T.ROCK, "*":T.DIAMOND,
            "E":T.EGG, "K":T.KEY, "S":T.SAFE, "P":T.PLAYER, "M":T.MONSTER,
            "X":T.EXIT, "G":T.SPIRIT, "C":T.CAGE};

/* ---------- levels (28 x 16) ----------
   need  = diamonds required to open the portal
   intro = what this level teaches                                    */
const LEVELS = [
{ name:"First Dig", time:240, need:16, intro:"Falling rocks SHATTER diamonds. Approach from the side.", map:[
"################################################",
"#..............................................#",
"#.P............................................#",
"#...........O.............O....................#",
"#...........................................*..#",
"#.....O...............O........................#",
"#.....*...............*...............O........#",
"#.............O.......................*........#",
"#.............*...............O................#",
"#........O....................*................#",
"#...*...............*...............#####......#",
"#...................................#.*.#......#",
"#...................................#...#......#",
"#...................................##.##......#",
"#.......O...................O....*.......O..*..#",
"#.......*...................*..................#",
"#..O..............O............................#",
"#.................*............................#",
"#..........................O........O..........#",
"#...................................*..........#",
"#....*..........######......................O..#",
"#...............#....#.........................#",
"#...............#.*..#....O....................#",
"#...............#....#.........................#",
"#.........O.....##.###.............O...........#",
"#.........*....................................#",
"#.......................O...............O......#",
"#.......................*...............*......#",
"#..............*.................*.............#",
"#.............................O..............X.#",
"#..............................................#",
"################################################"]},
{ name:"Egg Hunt", time:260, need:13, intro:"Dropped eggs hatch demons \u2014 and demons EAT diamonds.", map:[
"################################################",
"#..............................................#",
"#..............................................#",
"#..............................................#",
"#....E......O.......*.......*.......O.....E....#",
"#..............................................#",
"#..............................................#",
"#..........E.......................E...........#",
"#.......   .    ................   .    .......#",
"#....... *      ................ *      .......#",
"#.......      * ........E.......      * .......#",
"#.......        ................        .......#",
"#..............................................#",
"#..............................................#",
"#.............*.........O.........*............#",
"#...*......................................*...#",
"#.......................P......................#",
"#..............................................#",
"#.........O.............*.............O........#",
"#..............................................#",
"#...................*..........................#",
"#..........E.......................E...........#",
"#.......   .    ................   .    .......#",
"#....... *      ................ *      .......#",
"#.......      * ................      * .......#",
"#.......        ................        .......#",
"#.................O.....E.....O................#",
"#....E....................................E....#",
"#.............*...................*............#",
"#.X............................................#",
"#..............................................#",
"################################################"]},
{ name:"The Vault", time:300, need:19, intro:"The key turns safes into diamonds. You will need them all.", map:[
"################################################",
"#..............................................#",
"#............................................X.#",
"#..............................................#",
"#...*.............O.....S.....O................#",
"#..............................................#",
"#.....S..................................S.....#",
"#..............................................#",
"#.......O.....O.........*.........O.....O......#",
"#.......O.....O...................O.....O......#",
"#.......*.....*...................*.....*...O..#",
"#..............................................#",
"#...................########...................#",
"#...................#......#...................#",
"#...*...............#......#...................#",
"#...................#..K...#...................#",
"#.............O.....#......#......O............#",
"#...................#......#...................#",
"#...................###.####................*..#",
"#..............................................#",
"#..........O.................O.................#",
"#..........*.................*.................#",
"#...O............O.................O...........#",
"#................*.................*...........#",
"#......................O.......................#",
"#......................*.......................#",
"#.....S..................................S.....#",
"#..............................................#",
"#.........*.............S.....O.......*.....*..#",
"#.P............................................#",
"#..............................................#",
"################################################"]},
{ name:"Restless Spirits", time:320, need:13, intro:"Spirits follow walls forever. Dig a path \u2014 lure them into cages.", map:[
"################################################",
"#..............................................#",
"#.P............................................#",
"#.......*...........*...................O......#",
"#.................................*............#",
"#.............O................................#",
"#...........................O...............*..#",
"#.##.######....................................#",
"#.#C.   G #....................................#",
"#.#########....................................#",
"#.................................*............#",
"#..............................................#",
"#.......................O......................#",
"#.........*..........................#########.#",
"#....................................# G   .C#.#",
"#.................#############......######.##.#",
"#.................#.*   G   *.#................#",
"#.................#.#########.#................#",
"#.......O......................................#",
"#..............................................#",
"#.....................................*........#",
"#.##.######....................................#",
"#.#C.   G #...................O................#",
"#.#########....................................#",
"#...............O..............................#",
"#....................................#########.#",
"#....................................# G   .C#.#",
"#....................................######.##.#",
"#.....................*........................#",
"#.....*......................................X.#",
"#..............................................#",
"################################################"]},
{ name:"The Gauntlet", time:360, need:16, intro:"Everything at once. Plan the route. Mind the clock.", map:[
"################################################",
"#..............................................#",
"#.P............................................#",
"#...................*..........................#",
"#...............E.........S.................*..#",
"#..............................###########.....#",
"#.......S......................#.*  G  *.#.....#",
"#..............................#.#######.#.....#",
"#.................*............................#",
"#...........O...............O..................#",
"#...........O...........O...O...............*..#",
"#.##.######.*...............*..................#",
"#.#C.   G #....................................#",
"#.#########....................................#",
"#....................########.......E.......O..#",
"#....................#......#..................#",
"#.........*..........#......#...........S......#",
"#....................#..K...#..................#",
"#.....O.........O....#......#..................#",
"#.....*..............#......#........#########.#",
"#....................###.####........# G   .C#.#",
"#....................................######.##.#",
"#.......E.........O...........*................#",
"#.................*............................#",
"#.....O.....O.....................O............#",
"#...........*.....................*............#",
"#.......................E......................#",
"#..............................................#",
"#.............S.....................O..........#",
"#...*...................................*....X.#",
"#..............................................#",
"################################################"]}
];

/* ---------- sprite factory (all original pixel art) ---------- */
const P = {
  dirt1:"#5b3a1e", dirt2:"#7a4f28", dirt3:"#3f2712",
  wall1:"#8a2f2f", wall2:"#b0453c", wall3:"#5c1d1d", mortar:"#241010",
  rock1:"#8f8f9a", rock2:"#c2c2cc", rock3:"#55555f",
  gem1:"#59f0ff", gem2:"#c8fbff", gem3:"#1a7f97",
  egg1:"#f2e3b8", egg2:"#fff8e0", egg3:"#b09a63",
  key1:"#ffd24a", key2:"#fff2b0", key3:"#8f6a12",
  safe1:"#3f6fb0", safe2:"#7aa3d8", safe3:"#20365c",
  mon1:"#c94bd6", mon2:"#f2a0ff", mon3:"#6c1f78", monEye:"#fff",
  spr1:"#7fe7ff", spr2:"#d8f9ff", spr3:"#1e6d86",
  cage1:"#c7a445", cage2:"#efd98a", cage3:"#6e5312",
  exit1:"#2affc0", exit2:"#0a4d3a", exit3:"#083026",
  guy1:"#ff8c1a", guy2:"#ffc266", guy3:"#9c4c00", visor:"#57ff8a"
};

function sprite(draw,G=15){
  const c=document.createElement("canvas"); c.width=c.height=TILE;
  const g=c.getContext("2d"); g.imageSmoothingEnabled=false;
  const u=TILE/G, px=(x,y,w,h,col)=>{g.fillStyle=col;g.fillRect(Math.round(x*u),Math.round(y*u),Math.ceil(w*u),Math.ceil(h*u));};
  draw(px,g);
  return c;
}
/* horizontally mirror a rendered sprite (for facing-left frames) */
function mirror(c){
  const m=document.createElement("canvas"); m.width=m.height=TILE;
  const g=m.getContext("2d"); g.imageSmoothingEnabled=false;
  g.translate(TILE,0); g.scale(-1,1); g.drawImage(c,0,0);
  return m;
}

const SPR = {};
SPR.dirt = sprite(px=>{
  px(0,0,15,15,P.dirt1);
  [[1,2],[5,1],[10,3],[3,7],[8,6],[12,9],[2,12],[7,11],[13,13],[6,14]]
    .forEach(([x,y])=>px(x,y,2,1,P.dirt2));
  [[4,4],[11,6],[1,9],[9,13],[13,2],[6,8]].forEach(([x,y])=>px(x,y,1,1,P.dirt3));
});
SPR.wall = sprite(px=>{
  px(0,0,15,15,P.mortar);
  const brick=(x,y,w)=>{px(x,y,w,3,P.wall1);px(x,y,w,1,P.wall2);px(x,y+2,w,1,P.wall3);};
  brick(0,0,7);brick(8,0,7);
  brick(0,4,3);brick(4,4,7);brick(12,4,3);
  brick(0,8,7);brick(8,8,7);
  brick(0,12,3);brick(4,12,7);brick(12,12,3);
});
SPR.rock = sprite(px=>{
  px(3,1,9,1,P.rock1);px(2,2,11,2,P.rock1);px(1,4,13,7,P.rock1);
  px(2,11,11,2,P.rock1);px(3,13,9,1,P.rock1);
  px(4,2,5,2,P.rock2);px(3,4,3,3,P.rock2);
  px(9,9,4,3,P.rock3);px(4,11,7,2,P.rock3);px(11,4,2,5,P.rock3);
});
/* diamond: 4 frames — a glint sweeps across the facets */
function makeDiamond(f){
  return sprite((px)=>{
    const G={a:"#59f0ff",b:"#c8fbff",c:"#1a7f97"};
    for(let i=0;i<9;i++) px(9-i,2+i,2+2*i,1,G.a);      // upper facets
    for(let i=0;i<8;i++) px(1+i,11+i,18-2*i,1,G.a);    // lower facets
    px(12,8,4,5,G.c); px(5,13,4,3,G.c);                // shaded facets
    const gx=[6,8,11,13][f];                            // sweeping glint
    px(gx,6,1,6,G.b); px(gx+1,5,1,6,G.b);
    if(f===2){ px(15,3,1,1,"#fff"); px(16,2,1,1,G.b); } // corner sparkle
    if(f===0){ px(4,14,1,1,"#fff"); }
  },20);
}
const DIAMOND=[makeDiamond(0),makeDiamond(1),makeDiamond(2),makeDiamond(3)];
SPR.diamond=DIAMOND[0];
SPR.egg = sprite(px=>{
  px(6,2,3,1,P.egg1);px(5,3,5,2,P.egg1);px(4,5,7,4,P.egg1);
  px(3,9,9,3,P.egg1);px(4,12,7,1,P.egg1);px(5,13,5,1,P.egg1);
  px(6,3,2,3,P.egg2);px(5,5,2,2,P.egg2);
  px(9,9,3,3,P.egg3);px(6,12,4,1,P.egg3);
});
/* cracked egg: 2 frames — the shell trembles as something stirs inside */
const CRACKF=[0,1].map(f=>sprite((px,g)=>{
  g.drawImage(SPR.egg,0,0);
  const o=f?1:0;
  px(7+o,3,1,4,P.egg3); px(6+o,6,1,2,P.egg3); px(8-o,7,1,3,P.egg3);
  px(5,9+o,1,1,P.egg3); px(9,4+o,1,2,P.egg3);
  if(f){ px(4,2,1,1,P.egg3); px(10,8,1,1,P.egg3);         // shell chips
         px(7,5,1,1,"#7a0d0d"); }                          // something glints within
}));
SPR.crack=CRACKF[0];
SPR.key = sprite(px=>{
  px(3,3,4,4,P.key1);px(4,4,2,2,"#000");
  px(6,5,7,2,P.key1);px(11,7,2,2,P.key1);px(9,7,1,2,P.key1);
  px(3,3,4,1,P.key2);px(6,5,7,1,P.key2);
  px(3,6,4,1,P.key3);px(11,8,2,1,P.key3);
});
SPR.safe = sprite(px=>{
  px(1,1,13,13,P.safe1);
  px(1,1,13,1,P.safe2);px(1,1,1,13,P.safe2);
  px(1,13,13,1,P.safe3);px(13,1,1,13,P.safe3);
  px(5,5,5,5,P.safe3);px(6,6,3,3,P.safe2);px(7,7,1,1,P.key1);
  px(2,2,1,1,P.safe3);px(12,2,1,1,P.safe3);px(2,12,1,1,P.safe3);px(12,12,1,1,P.safe3);
});
/* demon: 2 frames — wings flap, eyes flare, tail swishes */
function makeDemon(f){
  return sprite((px)=>{
    const D={body:"#b3202a",dark:"#6e0f16",lite:"#e0555c",horn:"#e8d9a0",
             eye:"#ffd24a",pupil:"#7a0d0d",fang:"#fff6e0",wing:"#2b1030",wing2:"#4a1d55"};
    if(f){ px(1,4,4,4,D.wing); px(15,4,4,4,D.wing);        // wings raised
           px(2,3,2,2,D.wing2); px(16,3,2,2,D.wing2);
           px(1,4,4,1,D.wing2); px(15,4,4,1,D.wing2); }
    else { px(3,7,2,6,D.wing); px(15,7,2,6,D.wing);        // wings folded
           px(3,7,2,1,D.wing2); px(15,7,2,1,D.wing2); }
    px(3,0,2,2,D.horn); px(15,0,2,2,D.horn);               // horns
    px(4,2,2,2,D.horn); px(14,2,2,2,D.horn);
    px(5,2,10,6,D.body); px(5,2,10,1,D.lite);              // head
    px(6,4,3,2,D.eye); px(11,4,3,2,D.eye);                 // glowing eyes
    px(7,5,1,1,D.pupil); px(12,5,1,1,D.pupil);
    if(f){ px(6,4,1,1,"#fff"); px(13,4,1,1,"#fff"); }      // eye flare
    px(6,7,8,2,D.dark);                                     // maw
    px(7,7,1,2,D.fang); px(12,7,1,2,D.fang);               // fangs
    px(6,9,8,5,D.body); px(7,9,6,1,D.lite);                // torso
    px(8,11,4,2,D.dark);                                    // ribbed belly
    px(4,9,2,4,D.body); px(14,9,2,4,D.body);               // arms
    px(3,12,2,1,D.fang); px(15,12,2,1,D.fang);             // claws
    px(7,14,2,4,D.body); px(11,14,2,4,D.body);             // legs
    px(6,18,3,1,D.dark); px(11,18,3,1,D.dark);             // claw feet
    if(f){ px(15,14,2,1,D.body); px(17,12,1,2,D.body); px(17,11,1,1,D.lite); }
    else { px(15,15,2,1,D.body); px(17,15,1,2,D.body); px(17,17,1,1,D.lite); }
  },20);
}
const DEMON=[makeDemon(0),makeDemon(1)];
SPR.monster=DEMON[0];
/* spirit: 4 frames — bobs up and down, wisps flicker */
function makeSpirit(f){
  const bob=[1,0,1,2][f];
  return sprite((px)=>{
    const S={a:"#7fe7ff",b:"#d8f9ff",c:"#1e6d86"};
    const y=o=>o+bob;
    px(7,y(0),6,2,S.a); px(5,y(2),10,8,S.a);               // hood + body
    px(6,y(1),3,3,S.b); px(5,y(4),2,4,S.b);                // sheen
    px(7,y(5),2,2,S.c); px(12,y(5),2,2,S.c);               // hollow eyes
    px(9,y(9),3,1,S.c);                                     // mouth
    if(f%2){ px(4,y(10),2,4,S.a); px(8,y(10),3,3,S.a); px(13,y(10),2,4,S.a);
             px(5,y(15),1,1,S.a); px(10,y(14),1,1,S.a); }  // wisps A
    else   { px(5,y(10),2,3,S.a); px(9,y(10),2,4,S.a); px(14,y(10),2,3,S.a);
             px(4,y(14),1,1,S.a); px(11,y(15),1,1,S.a); px(15,y(13),1,1,S.a); } // wisps B
  },20);
}
const SPIRIT=[makeSpirit(0),makeSpirit(1),makeSpirit(2),makeSpirit(3)];
SPR.spirit=SPIRIT[0];
SPR.cage = sprite(px=>{
  px(1,1,13,2,P.cage1);px(1,12,13,2,P.cage1);
  px(1,1,13,1,P.cage2);px(1,13,13,1,P.cage3);
  for(const x of [2,5,8,11]){ px(x,3,2,9,P.cage1); px(x,3,1,9,P.cage2); }
  px(1,1,1,13,P.cage1);px(13,1,1,13,P.cage1);
});
SPR.exitClosed = sprite(px=>{
  px(1,1,13,13,P.exit3);
  px(2,2,11,11,P.exit2);
  px(6,6,3,3,P.exit3);
  px(2,2,11,1,"#0f6b52");px(2,2,1,11,"#0f6b52");
});
SPR.exitOpen1 = sprite(px=>{
  px(1,1,13,13,P.exit2);
  px(3,3,9,9,P.exit1);
  px(5,5,5,5,"#bffff0");
  px(7,7,1,1,"#ffffff");
  px(1,1,13,1,P.exit1);px(1,13,13,1,P.exit1);
});
SPR.exitOpen2 = sprite(px=>{
  px(1,1,13,13,P.exit2);
  px(4,4,7,7,P.exit1);
  px(6,6,3,3,"#bffff0");
  px(2,7,1,1,P.exit1);px(12,7,1,1,P.exit1);px(7,2,1,1,P.exit1);px(7,12,1,1,P.exit1);
});
/* the alien: 4 facings x 2 walk frames, plus an idle blink */
const A={skin:"#52d97a",lite:"#a9f0bb",dark:"#2a7a45",eye:"#0b0f0b",
         glint:"#e8fff0",bulb:"#ffb347",mouth:"#1c4a2c",pack:"#3f6fb0",pack2:"#7aa3d8"};
function makeAlien(face,stepf,blink=false){
  return sprite((px)=>{
    const legs=(l,r)=>{                                   // l/r lifted flags
      px(7,14,2,4-l,A.skin);  px(6,18-l,3,1,A.dark);
      px(11,14,2,4-r,A.skin); px(11,18-r,3,1,A.dark);
    };
    if(face==="down"||face==="up"){
      px(5,0,2,2,A.bulb); px(13,0,2,2,A.bulb);            // antenna bulbs
      px(6,2,1,2,A.dark); px(13,2,1,2,A.dark);            // stalks
      px(6,3,8,1,A.skin); px(5,4,10,2,A.skin);            // domed head
      px(4,6,12,4,A.skin); px(5,10,10,1,A.skin);
      px(5,4,2,2,A.lite);                                  // sheen
      if(face==="down"){
        if(blink){ px(6,7,3,1,A.dark); px(11,7,3,1,A.dark); }
        else{
          px(6,6,3,3,A.eye); px(11,6,3,3,A.eye);          // big black eyes
          px(6,6,1,1,A.glint); px(11,6,1,1,A.glint);
        }
        px(9,9,2,1,A.mouth);
      }else{
        px(7,6,6,4,A.pack); px(8,7,4,1,A.pack2);          // backpack (from behind)
        px(9,8,2,1,A.pack2);
      }
      px(7,11,6,3,A.skin); px(8,11,4,2,A.lite);           // body + belly
      px(5,11,2,3,A.skin); px(13,11,2,3,A.skin);          // arms
      legs(stepf?1:0, stepf?0:1);
    }else{                                                 // right profile (left is mirrored)
      px(9,0,2,2,A.bulb); px(10,2,1,2,A.dark);            // one antenna
      px(6,3,9,2,A.skin); px(5,5,11,5,A.skin); px(6,10,9,1,A.skin);
      px(6,5,2,2,A.lite);
      if(blink) px(11,7,3,1,A.dark);
      else{ px(11,6,3,3,A.eye); px(11,6,1,1,A.glint); }   // one big eye
      px(14,9,1,1,A.mouth);
      px(5,11,3,3,A.pack);                                 // pack on the back
      px(7,11,6,3,A.skin); px(9,11,3,2,A.lite);
      px(11,11,2,3,A.skin);                                // near arm
      legs(stepf?1:0, stepf?0:1);
    }
  },20);
}
const ALIEN={
  down:[makeAlien("down",0),makeAlien("down",1)],
  up:[makeAlien("up",0),makeAlien("up",1)],
  right:[makeAlien("right",0),makeAlien("right",1)],
};
ALIEN.left=[mirror(ALIEN.right[0]),mirror(ALIEN.right[1])];
ALIEN.blink=makeAlien("down",0,true);
SPR.player=ALIEN.down[0];
/* ---- effect animations: landing dust, shell burst, gem shatter ---- */
const FX={dust:"#9a8b74",dust2:"#c4b8a4"};
const DUSTF=[
  sprite(px=>{ px(2,15,4,3,FX.dust); px(7,16,4,2,FX.dust2); px(13,15,4,3,FX.dust);
               px(5,14,2,2,FX.dust2); px(11,14,2,2,FX.dust); },20),
  sprite(px=>{ px(0,13,4,3,FX.dust2); px(6,14,3,2,FX.dust); px(11,13,4,3,FX.dust2);
               px(16,14,3,2,FX.dust); px(4,11,2,1,FX.dust2); px(13,11,2,1,FX.dust2); },20),
  sprite(px=>{ px(1,12,2,1,FX.dust2); px(6,10,1,1,FX.dust); px(10,11,2,1,FX.dust2);
               px(15,12,2,1,FX.dust); px(18,14,1,1,FX.dust2); px(3,15,1,1,FX.dust); },20)
];
const HATCHF=[
  sprite(px=>{ px(8,8,4,4,"#ffffff"); px(9,6,2,2,"#fff6c0");        // flash
               px(5,6,3,2,P.egg1); px(12,6,2,3,P.egg1);             // big shards
               px(6,12,3,2,P.egg2); px(12,12,2,2,P.egg1); },20),
  sprite(px=>{ px(2,3,3,2,P.egg1); px(15,4,2,2,P.egg1); px(9,1,2,2,P.egg2);
               px(3,14,2,2,P.egg1); px(15,13,3,2,P.egg2); px(9,16,2,2,P.egg1);
               px(6,8,1,1,P.egg3); px(13,9,1,1,P.egg3); },20),
  sprite(px=>{ px(1,1,1,1,P.egg1); px(18,2,1,1,P.egg1); px(0,16,1,1,P.egg2);
               px(18,16,1,1,P.egg1); px(9,0,1,1,P.egg2); px(10,18,1,1,P.egg1); },20)
];
const SHATTERF=[
  sprite(px=>{ [[9,3],[4,6],[14,5],[6,12],[13,13],[9,9],[3,15],[16,15]]
               .forEach(([x,y])=>px(x,y,2,2,"#c8fbff"));
               px(8,8,3,3,"#ffffff"); },20),
  sprite(px=>{ [[6,2],[13,3],[2,8],[17,9],[5,15],[14,16],[9,5],[10,13]]
               .forEach(([x,y])=>px(x,y,2,1,"#59f0ff"));
               [[9,9],[4,4],[15,14]].forEach(([x,y])=>px(x,y,1,1,"#c8fbff")); },20),
  sprite(px=>{ [[2,2],[17,3],[1,16],[18,15],[9,1],[10,18],[5,9],[15,10]]
               .forEach(([x,y])=>px(x,y,1,1,"#c8fbff")); },20)
];
const FXDUR={dust:420, hatch:560, shatter:520};
const FXSET={dust:DUSTF, hatch:HATCHF, shatter:SHATTERF};
SPR.shatter=SHATTERF[0];

/* alien death: shock → stagger → collapse → the little soul departs */
const DEATHF=[
  sprite(px=>{                                     // shock: X eyes, antennae splayed
    px(3,0,2,2,A.bulb); px(15,0,2,2,A.bulb);
    px(5,2,1,2,A.dark); px(14,2,1,2,A.dark);
    px(6,3,8,1,A.skin); px(5,4,10,2,A.skin); px(4,6,12,4,A.skin); px(5,10,10,1,A.skin);
    px(6,6,1,1,A.eye);px(8,6,1,1,A.eye);px(7,7,1,1,A.eye);px(6,8,1,1,A.eye);px(8,8,1,1,A.eye);
    px(11,6,1,1,A.eye);px(13,6,1,1,A.eye);px(12,7,1,1,A.eye);px(11,8,1,1,A.eye);px(13,8,1,1,A.eye);
    px(8,9,4,1,A.mouth);
    px(7,11,6,3,A.skin); px(4,10,2,3,A.skin); px(14,10,2,3,A.skin);
    px(7,14,2,4,A.skin); px(11,14,2,4,A.skin);
  },20),
  sprite(px=>{                                     // stagger: sinking, first stars
    px(9,2,1,1,A.bulb); px(13,3,1,1,A.bulb);
    px(5,6,10,1,A.skin); px(4,7,12,4,A.skin); px(5,11,10,1,A.skin);
    px(6,8,2,2,A.dark); px(12,8,2,2,A.dark);
    px(6,12,8,3,A.skin);
    px(4,15,3,3,A.skin); px(13,15,3,3,A.skin);
    px(2,3,1,1,"#ffd24a"); px(16,2,1,1,"#ffd24a");
  },20),
  sprite(px=>{                                     // collapse: flattened, dizzy stars
    px(4,13,12,3,A.skin); px(3,16,14,2,A.skin); px(5,14,4,1,A.lite);
    px(6,14,2,1,A.dark); px(12,14,2,1,A.dark);
    px(3,8,2,2,"#ffd24a"); px(15,7,2,2,"#ffd24a"); px(9,5,2,2,"#ffd24a");
    px(4,9,1,1,"#fff"); px(16,8,1,1,"#fff");
  },20),
  sprite(px=>{                                     // a tiny soul floats away
    px(4,16,12,2,A.dark);
    px(8,3,4,4,"#d8f9ff"); px(9,2,2,1,"#d8f9ff");
    px(8,7,1,2,"#d8f9ff"); px(11,7,1,2,"#d8f9ff");
    px(9,4,1,1,"#1e6d86"); px(11,4,1,1,"#1e6d86");
    px(6,10,1,1,"#d8f9ff"); px(13,9,1,1,"#d8f9ff");
  },20)
];
SPR.empty = sprite(px=>{ px(0,0,15,15,"#04070c"); });

/* ---------- audio: 8-bit engine (2 pulse + triangle + noise, like the old micros) ---------- */
let AC=null, soundOn=true, musicOn=true, noiseBuf=null, musicBus=null;

function ensureAC(){
  if(AC) { AC.resume && AC.resume(); return AC; }
  try{
    AC = new (window.AudioContext||window.webkitAudioContext)();
    // shared white-noise buffer — the classic "noise channel"
    noiseBuf = AC.createBuffer(1, AC.sampleRate*0.5, AC.sampleRate);
    const d = noiseBuf.getChannelData(0);
    for(let i=0;i<d.length;i++) d[i]=Math.random()*2-1;
    musicBus = AC.createGain(); musicBus.gain.value=0.6;
    musicBus.connect(AC.destination);
    MUSIC.boot();
  }catch(e){}
  return AC;
}

function beep(freq, dur=0.07, type="square", vol=0.05, slide=0, when=0){
  if(!soundOn || !ensureAC()) return;
  try{
    const t=(when||AC.currentTime);
    const o=AC.createOscillator(), g=AC.createGain();
    o.type=type; o.frequency.setValueAtTime(freq,t);
    if(slide) o.frequency.exponentialRampToValueAtTime(Math.max(30,freq+slide),t+dur);
    g.gain.setValueAtTime(vol,t);
    g.gain.exponentialRampToValueAtTime(0.001,t+dur);
    o.connect(g).connect(AC.destination);
    o.start(t); o.stop(t+dur+0.02);
  }catch(e){}
}
/* noise burst — for crunches, thuds and smashes */
function crunch(dur=0.08, vol=0.06, hz=900, when=0, dest=null){
  if(!soundOn && !dest) return;
  if(!ensureAC()) return;
  try{
    const t=(when||AC.currentTime);
    const src=AC.createBufferSource(); src.buffer=noiseBuf;
    const f=AC.createBiquadFilter(); f.type="highpass"; f.frequency.value=hz;
    const g=AC.createGain();
    g.gain.setValueAtTime(vol,t);
    g.gain.exponentialRampToValueAtTime(0.001,t+dur);
    src.connect(f).connect(g).connect(dest||AC.destination);
    src.start(t); src.stop(t+dur+0.02);
  }catch(e){}
}
const SFX = {
  step:  ()=>beep(140,0.03,"square",0.02),
  dig:   ()=>{beep(90,0.05,"sawtooth",0.025);crunch(0.05,0.04,400);},
  gem:   ()=>{beep(880,0.06,"square",0.05);setTimeout(()=>beep(1320,0.08,"square",0.05),60);},
  push:  ()=>{beep(70,0.08,"sawtooth",0.04);crunch(0.09,0.03,250);},
  thud:  ()=>{beep(60,0.1,"triangle",0.08,-30);crunch(0.09,0.06,180);},
  shatter:()=>{beep(1600,0.12,"sawtooth",0.05,-1400);crunch(0.14,0.07,2500);},
  key:   ()=>{beep(660,0.07);setTimeout(()=>beep(990,0.07),70);setTimeout(()=>beep(1480,0.1),140);},
  crack: ()=>{beep(300,0.08,"sawtooth",0.05,-200);crunch(0.06,0.04,1200);},
  hatch: ()=>beep(200,0.2,"sawtooth",0.06,400),
  eat:   ()=>beep(500,0.15,"square",0.06,-350),
  cageIt:()=>{beep(740,0.08);setTimeout(()=>beep(1110,0.12),80);},
  open:  ()=>{[392,523,659,784].forEach((f,i)=>setTimeout(()=>beep(f,0.1,"triangle",0.07),i*90));},
  die:   ()=>{beep(400,0.3,"sawtooth",0.09,-350);crunch(0.25,0.06,300);},
  life:  ()=>{[523,659,1047].forEach((f,i)=>setTimeout(()=>beep(f,0.09),i*80));},
  win:   ()=>{[523,659,784,1047].forEach((f,i)=>setTimeout(()=>beep(f,0.12,"square",0.06),i*110));}
};

/* ---------- background music: an original chiptune loop ----------
   Written for this game. Four bars in A minor (Am–C–F–E), sequenced
   as a 64-step pattern: square lead, triangle bass, noise hi-hat,
   swept kick. Tempo lifts when the clock runs low.                  */
const MUSIC=(()=>{
  const R=0, mf=m=>440*Math.pow(2,(m-69)/12);
  /* lead: 64 sixteenth-steps (midi numbers, 0 = rest) — original melody */
  const LEAD=[
    69,R,72,R, 76,R,74,72, 71,R,R,R, 69,R,R,R,
    67,R,72,R, 76,R,79,R,  76,R,74,R, 72,R,R,R,
    65,R,69,R, 72,R,74,R,  72,R,69,R, 65,R,R,R,
    64,R,68,R, 71,R,72,71, 68,R,64,R, R,R,R,R];
  /* bass: 32 eighth-steps — original line */
  const BASS=[
    45,45,52,45, 45,45,43,45,
    48,48,55,48, 48,48,43,48,
    41,41,48,41, 41,41,45,41,
    40,40,47,40, 40,40,47,43];
  let step=0, nextT=0, booted=false;

  function playing(){ return AC && musicOn && state==="play" && !mapView; }

  function voice(m,t,dur,type,vol){
    const o=AC.createOscillator(), g=AC.createGain();
    o.type=type; o.frequency.setValueAtTime(mf(m),t);
    g.gain.setValueAtTime(vol,t);
    g.gain.exponentialRampToValueAtTime(0.001,t+dur);
    o.connect(g).connect(musicBus);
    o.start(t); o.stop(t+dur+0.02);
  }
  function kick(t){
    const o=AC.createOscillator(), g=AC.createGain();
    o.type="triangle";
    o.frequency.setValueAtTime(130,t);
    o.frequency.exponentialRampToValueAtTime(45,t+0.09);
    g.gain.setValueAtTime(0.10,t);
    g.gain.exponentialRampToValueAtTime(0.001,t+0.1);
    o.connect(g).connect(musicBus); o.start(t); o.stop(t+0.12);
  }
  function scheduleStep(s,t,dur16){
    const lead=LEAD[s];
    if(lead) voice(lead,t,dur16*1.8,"square",0.030);
    if(s%2===0){
      const bass=BASS[s>>1];
      if(bass) voice(bass,t,dur16*1.6,"triangle",0.055);
      crunch(0.03, s%4===2?0.020:0.012, 6000, t, musicBus);   // hi-hat
    }
    if(s%16===0||s%16===8) kick(t);
  }
  function sched(){
    if(!AC) return;
    if(!playing()){ nextT=AC.currentTime+0.06; return; }
    const bpm=(timeLeft<25)?140:112, dur16=60/bpm/4;
    while(nextT<AC.currentTime+0.12){
      scheduleStep(step,Math.max(nextT,AC.currentTime),dur16);
      nextT+=dur16; step=(step+1)%64;
    }
  }
  return {
    boot(){ if(booted) return; booted=true; nextT=AC.currentTime; setInterval(sched,25); },
    toggle(){ musicOn=!musicOn; return musicOn; }
  };
})();

/* ---------- game state ---------- */
let grid, falling, primed, fallDist, crackT, playerX, playerY;
let facing="down", walkPhase=0, lastStepT=0;
let monsters=[], spirits=[], effects=[];
let level=0, lives=3, score=0, nextLifeAt=500;
let need=0, collected=0, timeLeft=0;
let exitX=0, exitY=0, exitOpen=false;
let state="title";  // title | play | dying | leveldone | gameover | won | paused
let tickCount=0, msgTimer=0, message="", deathReason="";

function loadLevel(n){
  const L=LEVELS[n];
  grid=[]; falling=[]; primed=[]; fallDist=[]; crackT={};
  monsters=[]; spirits=[]; effects=[]; shake=0;
  collected=0; need=L.need; timeLeft=L.time; tickCount=0; exitOpen=false;
  for(let y=0;y<ROWS;y++){
    grid[y]=[]; falling[y]=[]; primed[y]=[]; fallDist[y]=[];
    const row=(L.map[y]||"").padEnd(COLS,"#");
    for(let x=0;x<COLS;x++){
      let t=CH[row[x]] ?? T.WALL;
      if(t===T.PLAYER){playerX=x;playerY=y;t=T.EMPTY;}
      if(t===T.EXIT){exitX=x;exitY=y;}
      if(t===T.MONSTER){monsters.push({x,y});}
      if(t===T.SPIRIT){spirits.push({x,y,dir:spawnDir(L.map,x,y)});}
      grid[y][x]=t; falling[y][x]=false; primed[y][x]=false; fallDist[y][x]=0;
    }
  }
  mapView=false;
  facing="down"; walkPhase=0; lastStepT=0;
  [camX,camY]=camTarget();                 // snap camera onto the player
  updateHUD();
}

const DIRS=[[0,-1],[1,0],[0,1],[-1,0]]; // up right down left
function spawnDir(map,x,y){
  const at=(xx,yy)=>((map[yy]||"")[xx]||"#");
  // prefer a direction whose left-hand side is a wall (so wall-following starts clean)
  for(let d=0;d<4;d++){
    const [dx,dy]=DIRS[d], [lx,ly]=DIRS[(d+3)%4];
    if(at(x+dx,y+dy)===" " && at(x+lx,y+ly)==="#") return d;
  }
  for(let d=0;d<4;d++){ const[dx,dy]=DIRS[d]; if(at(x+dx,y+dy)===" ") return d; }
  return 1;
}

/* how many diamonds could still, in principle, be obtained */
function potentialGems(){
  let d=0,s=0,k=false,c=0;
  for(let y=0;y<ROWS;y++)for(let x=0;x<COLS;x++){
    const t=grid[y][x];
    if(t===T.DIAMOND)d++; else if(t===T.SAFE)s++;
    else if(t===T.KEY)k=true; else if(t===T.CAGE)c++;
  }
  return d + (k?s:0) + Math.min(spirits.length,c);
}
function checkWinnable(){
  if(state!=="play") return;
  if(collected + potentialGems() < need){
    flash("NOT ENOUGH DIAMONDS LEFT — PRESS R", 9999);
  }
}

function updateHUD(){
  document.getElementById("hLevel").textContent = level+1;
  document.getElementById("hGems").textContent  = collected+"/"+need;
  document.getElementById("hGemWrap").className = exitOpen ? "go" : "";
  document.getElementById("hScore").textContent = score;
  document.getElementById("hTime").textContent  = Math.max(0,Math.ceil(timeLeft));
  document.getElementById("hLives").textContent = lives;
  document.getElementById("hTimeWrap").className = timeLeft<=25 ? "low":"";
}
function addScore(n){
  score+=n;
  if(score>=nextLifeAt){ lives++; nextLifeAt+=500; SFX.life(); flash("EXTRA LIFE!"); }
}
function gemCollected(){
  collected++; addScore(5); SFX.gem();
  if(!exitOpen && collected>=need){
    exitOpen=true; SFX.open();
    flash("PORTAL OPEN — GET TO THE EXIT!");
  }
}
function loseGem(x,y,why){
  effects.push({x,y,t0:performance.now(),kind:"shatter"});
  SFX.shatter(); flash(why);
  checkWinnable();
}

/* ---------- player ---------- */
function tryMove(dx,dy){
  if(state!=="play") return;
  facing = dx<0?"left":dx>0?"right":dy<0?"up":"down";   // turn to face even if blocked
  const nx=playerX+dx, ny=playerY+dy;
  if(nx<0||ny<0||nx>=COLS||ny>=ROWS) return;
  const t=grid[ny][nx];
  if(t===T.WALL||t===T.SAFE||t===T.CRACK||t===T.CAGE) return;
  if(t===T.MONSTER){ die("The demon got you"); return; }
  if(t===T.SPIRIT){ die("A spirit touched you"); return; }
  if(t===T.EXIT){
    if(exitOpen){ levelDone(); }
    else flash("PORTAL SEALED — NEED "+(need-collected)+" MORE");
    return;
  }
  if(t===T.ROCK||t===T.EGG){
    if(dy!==0) return;
    const bx=nx+dx;
    if(bx<0||bx>=COLS||grid[ny][bx]!==T.EMPTY) return;
    grid[ny][bx]=t; grid[ny][nx]=T.EMPTY;
    falling[ny][bx]=false; primed[ny][bx]=false; fallDist[ny][bx]=0;
    SFX.push();
  }
  else if(t===T.DIRT){ SFX.dig(); }
  else if(t===T.DIAMOND){ gemCollected(); }
  else if(t===T.KEY){
    addScore(30); SFX.key(); flash("SAFES UNLOCKED!");
    for(let y=0;y<ROWS;y++)for(let x=0;x<COLS;x++)
      if(grid[y][x]===T.SAFE) grid[y][x]=T.DIAMOND;
  }
  else{ SFX.step(); }
  grid[ny][nx] = (grid[ny][nx]===T.ROCK||grid[ny][nx]===T.EGG) ? grid[ny][nx] : T.EMPTY;
  if(grid[ny][nx]===T.EMPTY){
    playerX=nx; playerY=ny;
    walkPhase^=1; lastStepT=performance.now();
  }
  updateHUD();
}

function flash(txt,frames){ message=txt; msgTimer=frames||90; }

function die(reason){
  if(state!=="play") return;
  mapView=false;
  deathReason=reason; SFX.die(); state="dying"; msgTimer=70;
}
function afterDeath(){
  lives--;
  if(lives<=0){ state="gameover"; }
  else{ loadLevel(level); state="play"; flash(deathReason.toUpperCase()+" — TRY AGAIN"); }
  updateHUD();
}
function levelDone(){
  const bonus=Math.ceil(timeLeft)*2;
  addScore(bonus); SFX.win();
  state="leveldone"; msgTimer=130;
  flash("LEVEL CLEAR! TIME BONUS +"+bonus);
  updateHUD();
}
function nextLevel(){
  level++;
  if(level>=LEVELS.length){ state="won"; }
  else{ loadLevel(level); state="play"; flash(LEVELS[level].intro,160); }
  updateHUD();
}

/* ---------- physics ---------- */
const ROUND = t => t===T.ROCK||t===T.DIAMOND||t===T.EGG;

function removeEntityAt(x,y){
  monsters=monsters.filter(m=>!(m.x===x&&m.y===y));
  spirits =spirits .filter(s=>!(s.x===x&&s.y===y));
}

function physicsTick(){
  tickCount++;
  const moved=new Set();
  const key=(x,y)=>y*COLS+x;

  for(let y=ROWS-2;y>=0;y--){
    for(let x=0;x<COLS;x++){
      const t=grid[y][x];
      if((t!==T.ROCK&&t!==T.EGG)||moved.has(key(x,y))) continue;
      const below=grid[y+1][x];
      const playerBelow = (playerX===x&&playerY===y+1);

      const drop=()=>{
        grid[y+1][x]=t; grid[y][x]=T.EMPTY;
        falling[y+1][x]=true; falling[y][x]=false; primed[y][x]=false;
        fallDist[y+1][x]=fallDist[y][x]+1; fallDist[y][x]=0;
        moved.add(key(x,y+1));
      };

      if(below===T.EMPTY && !playerBelow){
        if(falling[y][x]) drop();                    // already moving: keep going
        else if(primed[y][x]){ primed[y][x]=false; falling[y][x]=true; drop(); }
        else primed[y][x]=true;                      // wobble one tick before falling
      }
      else if(falling[y][x] && playerBelow){
        grid[y+1][x]=t; grid[y][x]=T.EMPTY; falling[y+1][x]=true;
        fallDist[y+1][x]=fallDist[y][x]+1; fallDist[y][x]=0;
        addShake(8);
        die(t===T.EGG?"Crushed by an egg":"Crushed by a rock");
      }
      else if(falling[y][x] && below===T.MONSTER){
        removeEntityAt(x,y+1); drop(); addShake(6);
        addScore(20); SFX.thud(); flash("DEMON SQUASHED! +20");
      }
      else if(falling[y][x] && below===T.SPIRIT){
        removeEntityAt(x,y+1); drop(); addShake(6);
        addScore(20); SFX.thud(); flash("SPIRIT CRUSHED! +20");
        checkWinnable();
      }
      else if(falling[y][x] && t===T.ROCK && below===T.DIAMOND){
        drop(); addShake(5);                         // rock smashes straight through
        loseGem(x,y+1,"A ROCK SHATTERED A DIAMOND!");
      }
      else if(ROUND(below) && !playerBelow){
        const canL = x>0 && grid[y][x-1]===T.EMPTY && grid[y+1][x-1]===T.EMPTY
                     && !(playerX===x-1&&(playerY===y||playerY===y+1));
        const canR = x<COLS-1 && grid[y][x+1]===T.EMPTY && grid[y+1][x+1]===T.EMPTY
                     && !(playerX===x+1&&(playerY===y||playerY===y+1));
        if(canL||canR){
          if(!falling[y][x] && !primed[y][x]){ primed[y][x]=true; }
          else{
            primed[y][x]=false;
            const gx = canL ? x-1 : x+1;
            grid[y][gx]=t; grid[y][x]=T.EMPTY;
            falling[y][gx]=true; falling[y][x]=false;
            fallDist[y][gx]=fallDist[y][x]; fallDist[y][x]=0;   // momentum carries
            moved.add(key(gx,y));
          }
        } else settle(x,y,t);
      }
      else settle(x,y,t);
    }
  }
  // egg cracking / hatching
  for(const k in crackT){
    crackT[k]--;
    if(crackT[k]<=0){
      const x=k%COLS, y=(k-x)/COLS;
      delete crackT[k];
      if(grid[y][x]===T.CRACK){
        grid[y][x]=T.MONSTER; monsters.push({x,y});
        effects.push({x,y,t0:performance.now(),kind:"hatch"});
        SFX.hatch(); flash("A DEMON HAS HATCHED!");
      }
    }
  }
  moveSpirits();
  if(tickCount%2===0) moveMonsters();
}

function settle(x,y,t){
  primed[y][x]=false;
  if(falling[y][x]){
    falling[y][x]=false;
    const d=fallDist[y][x];
    if(t===T.EGG){ grid[y][x]=T.CRACK; crackT[y*COLS+x]=5; SFX.crack(); }
    else{
      SFX.thud(); effects.push({x,y,t0:performance.now(),kind:"dust"});
      if(t===T.ROCK && d>=2){                 // a long fall lands HARD
        addShake(3+d*2);
        crunch(0.16,0.09,120);
      }
    }
  }
  fallDist[y][x]=0;
}

/* spirits: strict left-hand wall following — perfectly predictable */
function moveSpirits(){
  for(const s of spirits){
    if(grid[s.y][s.x]!==T.SPIRIT) continue;
    const order=[(s.dir+3)%4, s.dir, (s.dir+1)%4, (s.dir+2)%4];
    for(const d of order){
      const nx=s.x+DIRS[d][0], ny=s.y+DIRS[d][1];
      if(nx<0||ny<0||nx>=COLS||ny>=ROWS) continue;
      if(nx===playerX&&ny===playerY){ die("A spirit touched you"); s.dir=d; break; }
      const t=grid[ny][nx];
      if(t===T.CAGE){
        grid[ny][nx]=T.DIAMOND; grid[s.y][s.x]=T.EMPTY;
        s.x=-9; s.y=-9;  // mark dead; filtered below
        addScore(25); SFX.cageIt(); flash("SPIRIT CAGED — IT LEFT A DIAMOND!");
        break;
      }
      if(t===T.EMPTY){
        grid[s.y][s.x]=T.EMPTY; grid[ny][nx]=T.SPIRIT;
        s.x=nx; s.y=ny; s.dir=d;
        break;
      }
    }
  }
  spirits=spirits.filter(s=>s.x>=0);
}

/* monsters: hunt the player through open tunnels, eating diamonds */
function moveMonsters(){
  for(const m of monsters){
    if(grid[m.y][m.x]!==T.MONSTER) continue;
    const step=bfsStep(m.x,m.y);
    let nx,ny;
    if(step){ [nx,ny]=step; }
    else{
      const opts=DIRS.map(([dx,dy])=>[m.x+dx,m.y+dy])
        .filter(([x,y])=>x>=0&&y>=0&&x<COLS&&y<ROWS&&grid[y][x]===T.EMPTY);
      if(!opts.length) continue;
      [nx,ny]=opts[(Math.random()*opts.length)|0];
    }
    if(nx===playerX&&ny===playerY){ die("The demon got you"); continue; }
    const t=grid[ny][nx];
    if(t===T.DIAMOND){
      grid[m.y][m.x]=T.EMPTY; grid[ny][nx]=T.MONSTER; m.x=nx; m.y=ny;
      SFX.eat(); loseGem(nx,ny,"THE DEMON ATE A DIAMOND!");
    }else if(t===T.EMPTY){
      grid[m.y][m.x]=T.EMPTY; grid[ny][nx]=T.MONSTER; m.x=nx; m.y=ny;
    }
  }
}
function bfsStep(sx,sy){
  const pass=t=>t===T.EMPTY||t===T.DIAMOND;
  const prev=new Map(), start=sy*COLS+sx;
  const q=[start]; prev.set(start,-1);
  let found=-1, n=0;
  while(q.length && n<420){
    const cur=q.shift(); n++;
    const cx=cur%COLS, cyy=(cur-cx)/COLS;
    for(const [dx,dy] of DIRS){
      const nx=cx+dx, ny=cyy+dy;
      if(nx<0||ny<0||nx>=COLS||ny>=ROWS) continue;
      const k=ny*COLS+nx;
      if(prev.has(k)) continue;
      if(nx===playerX&&ny===playerY){ prev.set(k,cur); found=k; break; }
      if(!pass(grid[ny][nx])) continue;
      prev.set(k,cur); q.push(k);
    }
    if(found>=0) break;
  }
  if(found<0) return null;
  let k=found;
  while(prev.get(k)!==start) k=prev.get(k);
  return [k%COLS,(k-(k%COLS))/COLS];
}

/* ---------- render ---------- */
function draw(){
  ctx.fillStyle="#04070c"; ctx.fillRect(0,0,cv.width,cv.height);
  if(state==="title"){ drawTitle(); return; }

  const jx=shake? Math.round((Math.random()*2-1)*shake) : 0;
  const jy=shake? Math.round((Math.random()*2-1)*shake) : 0;
  const cx=Math.round(camX)+jx, cy=Math.round(camY)+jy;
  const x0=Math.max(0,Math.floor(cx/TILE)),   x1=Math.min(COLS-1,Math.ceil((cx+cv.width)/TILE));
  const y0=Math.max(0,Math.floor(cy/TILE)),   y1=Math.min(ROWS-1,Math.ceil((cy+cv.height)/TILE));

  const now=performance.now();
  const aDem=Math.floor(now/230)%2;            // demon wing-beat
  const aGem=Math.floor(now/160)%4;            // diamond glint sweep
  const aSpr=Math.floor(now/170)%4;            // spirit bob
  const aCrk=Math.floor(now/90)%2;             // cracked-egg tremble

  for(let y=y0;y<=y1;y++)for(let x=x0;x<=x1;x++){
    const t=grid[y][x];
    let s = t===T.DIRT?SPR.dirt : t===T.WALL?SPR.wall : t===T.ROCK?SPR.rock :
            t===T.DIAMOND?DIAMOND[aGem] : t===T.EGG?SPR.egg : t===T.CRACK?CRACKF[aCrk] :
            t===T.MONSTER?DEMON[aDem] : t===T.KEY?SPR.key : t===T.SAFE?SPR.safe :
            t===T.SPIRIT?SPIRIT[aSpr] : t===T.CAGE?SPR.cage :
            t===T.EXIT?(exitOpen?(tickCount%2?SPR.exitOpen1:SPR.exitOpen2):SPR.exitClosed) :
            SPR.empty;
    let ox=0;
    if((t===T.ROCK||t===T.EGG)&&primed[y][x]) ox=(tickCount%2? 3:-3); // wobble warning
    ctx.drawImage(s,x*TILE+ox-cx,y*TILE-cy);
  }
  for(const e of effects){
    const fr=Math.min(2, ((now-e.t0)/FXDUR[e.kind]*3)|0);
    ctx.drawImage(FXSET[e.kind][fr], e.x*TILE-cx, e.y*TILE-cy);
  }
  if(state==="dying"){
    const di=Math.min(3, ((70-msgTimer)/16)|0);   // shock → stagger → collapse → soul
    ctx.drawImage(DEATHF[di],playerX*TILE-cx,playerY*TILE-cy);
  }else{
    const walking = now-lastStepT<200;
    const pf = walking ? ALIEN[facing][walkPhase]
             : (facing==="down" && (now%3400)<160 ? ALIEN.blink : ALIEN[facing][0]);
    ctx.drawImage(pf,playerX*TILE-cx,playerY*TILE-cy);
  }

  if(mapView && state==="play") drawMiniMap();

  if(message&&msgTimer>0) banner(message);
  if(state==="paused")    overlay("PAUSED","press P to resume");
  if(state==="gameover")  overlay("GAME OVER","score "+score+" — press R to try again");
  if(state==="leveldone") overlay("LEVEL CLEAR!","get ready…");
  if(state==="won")       overlay("YOU WIN!","final score "+score+" — press R to play again");
}

/* survey map (M) — the whole level at a glance, but the clock keeps running */
function drawMiniMap(){
  const MT=Math.min(Math.floor((cv.width-40)/COLS), Math.floor((cv.height-90)/ROWS)), w=COLS*MT, h=ROWS*MT;
  const mx=(cv.width-w)/2, my=(cv.height-h)/2 - 14;
  ctx.fillStyle="rgba(0,2,8,.88)"; ctx.fillRect(0,0,cv.width,cv.height);
  ctx.strokeStyle="#3a3f47"; ctx.strokeRect(mx-3,my-3,w+6,h+6);
  for(let y=0;y<ROWS;y++)for(let x=0;x<COLS;x++){
    const t=grid[y][x];
    const s = t===T.DIRT?SPR.dirt : t===T.WALL?SPR.wall : t===T.ROCK?SPR.rock :
              t===T.DIAMOND?SPR.diamond : t===T.EGG?SPR.egg : t===T.CRACK?SPR.crack :
              t===T.MONSTER?SPR.monster : t===T.KEY?SPR.key : t===T.SAFE?SPR.safe :
              t===T.SPIRIT?SPR.spirit : t===T.CAGE?SPR.cage :
              t===T.EXIT?(exitOpen?SPR.exitOpen1:SPR.exitClosed) : SPR.empty;
    ctx.drawImage(s,mx+x*MT,my+y*MT,MT,MT);
  }
  if(Math.floor(Date.now()/300)%2){                    // blinking "you are here"
    ctx.fillStyle="#ffb347";
    ctx.fillRect(mx+playerX*MT-1,my+playerY*MT-1,MT+2,MT+2);
    ctx.drawImage(SPR.player,mx+playerX*MT,my+playerY*MT,MT,MT);
  }
  ctx.strokeStyle="#57ff8a";                           // current camera window
  ctx.strokeRect(mx+(camX/TILE)*MT, my+(camY/TILE)*MT,
                 (cv.width/TILE)*MT, (cv.height/TILE)*MT);
  ctx.textAlign="center";
  ctx.fillStyle="#ffb347"; ctx.font="bold 18px 'Courier New',monospace";
  ctx.fillText("SURVEY MAP",cv.width/2,my-14);
  ctx.fillStyle="#ff5d5d"; ctx.font="13px 'Courier New',monospace";
  ctx.fillText("TIME IS STILL RUNNING — PRESS M TO CLOSE",cv.width/2,my+h+24);
}
function banner(txt){
  ctx.fillStyle="rgba(0,0,0,.75)";
  ctx.fillRect(0,cv.height-34,cv.width,34);
  ctx.fillStyle="#57ff8a"; ctx.font="bold 15px 'Courier New',monospace";
  ctx.textAlign="center";
  ctx.fillText(txt.toUpperCase(),cv.width/2,cv.height-12);
}
function overlay(big,small){
  ctx.fillStyle="rgba(0,2,8,.82)"; ctx.fillRect(0,0,cv.width,cv.height);
  ctx.textAlign="center";
  ctx.fillStyle="#ffb347"; ctx.font="bold 46px 'Courier New',monospace";
  ctx.fillText(big,cv.width/2,cv.height/2-14);
  ctx.fillStyle="#57ff8a"; ctx.font="16px 'Courier New',monospace";
  ctx.fillText(small.toUpperCase(),cv.width/2,cv.height/2+24);
}
function drawTitle(){
  ctx.textAlign="center";
  ctx.fillStyle="#ffb347"; ctx.font="bold 60px 'Courier New',monospace";
  ctx.fillText("ROCKFALL",cv.width/2,120);
  ctx.fillStyle="#57ff8a"; ctx.font="15px 'Courier New',monospace";
  ctx.fillText("AN ORIGINAL REPTON-STYLE PUZZLER",cv.width/2,155);
  const items=[
    [SPR.exitOpen1,"COLLECT THE QUOTA, THEN ESCAPE THROUGH THE PORTAL"],
    [SPR.rock,     "FALLING ROCKS CRUSH YOU — AND SHATTER DIAMONDS"],
    [SPR.egg,      "EGGS HATCH DEMONS THAT HUNT YOU AND EAT DIAMONDS"],
    [SPR.spirit,   "SPIRITS FOLLOW WALLS — LURE THEM INTO CAGES FOR DIAMONDS"],
    [SPR.key,      "KEYS TURN SAFES INTO DIAMONDS"]];
  items.forEach(([s,txt],i)=>{
    const ix=cv.width/2-240, iy=180+i*46, sz=34;   // fixed icon size, clear of the text
    ctx.drawImage(s,ix,iy,sz,sz);
    ctx.textAlign="left"; ctx.fillStyle="#9aa3ad";
    ctx.fillText(txt,ix+sz+16,iy+sz/2+5);          // baseline centred on the icon
  });
  ctx.textAlign="center"; ctx.fillStyle="#ffb347";
  if(Math.floor(Date.now()/500)%2) ctx.fillText("PRESS ANY KEY OR TAP TO START",cv.width/2,438);
}

/* ---------- loop ---------- */
const PHYS_MS=160, MOVE_MS=115;
let lastPhys=0, lastMove=0, lastFrame=0;
const held={};

function frame(t){
  requestAnimationFrame(frame);
  const dt=Math.min(0.1,(t-lastFrame)/1000); lastFrame=t;

  if(state==="play"){
    timeLeft-=dt;                                   // the clock never stops — even on the map
    if(timeLeft<=0){ die("Out of time"); }
    if(!mapView){
      if(t-lastPhys>=PHYS_MS){ lastPhys=t; physicsTick(); updateHUD(); }
      if(t-lastMove>=MOVE_MS){
        let dx=0,dy=0;
        if(held.left)dx=-1; else if(held.right)dx=1;
        else if(held.up)dy=-1; else if(held.down)dy=1;
        if(dx||dy){ lastMove=t; tryMove(dx,dy); }
      }
    } else updateHUD();
  }
  const [tx,ty]=camTarget();                        // smooth-follow the player
  camX+=(tx-camX)*0.18; camY+=(ty-camY)*0.18;
  if(Math.abs(tx-camX)<0.5)camX=tx; if(Math.abs(ty-camY)<0.5)camY=ty;
  shake*=0.86; if(shake<0.4) shake=0;               // shake settles quickly
  const nowFx=performance.now();                    // effects expire on wall-clock time
  if(effects.length) effects=effects.filter(e=>nowFx-e.t0<FXDUR[e.kind]);
  if(msgTimer>0){
    msgTimer--;
    if(msgTimer===0){
      if(state==="dying") afterDeath();
      else if(state==="leveldone") nextLevel();
      else message="";
    }
  }
  draw();
}

/* ---------- input ---------- */
const KEYMAP={ArrowLeft:"left",ArrowRight:"right",ArrowUp:"up",ArrowDown:"down",
              a:"left",d:"right",w:"up",s:"down",A:"left",D:"right",W:"up",S:"down"};
function startGame(){
  ensureAC();                       // unlock audio + start the music sequencer
  level=0; lives=3; score=0; nextLifeAt=500;
  loadLevel(0); state="play"; flash(LEVELS[0].intro,160);
}
addEventListener("keydown",e=>{
  if(state==="title" && e.key !== "f" && e.key !== "F"){ startGame(); return; }
  const d=KEYMAP[e.key];
  if(d){ held[d]=true; lastMove=0; e.preventDefault(); }
  if(e.key==="p"||e.key==="P") togglePause();
  if(e.key==="m"||e.key==="M"){ if(state==="play") mapView=!mapView; }
  if(e.key==="f"||e.key==="F"){ toggleFullscreen(); }
  if(e.key==="r"||e.key==="R"){
    if(state==="gameover"||state==="won") startGame();
    else if(state==="play"||state==="paused"){ loadLevel(level); state="play"; flash("RESTARTED"); }
  }
});
addEventListener("keyup",e=>{ const d=KEYMAP[e.key]; if(d) held[d]=false; });
cv.addEventListener("pointerdown",()=>{ if(state==="title") startGame(); });

function togglePause(){
  if(state==="play"){ state="paused"; }
  else if(state==="paused"){ state="play"; }
}
function toggleFullscreen(){
  const isFull = document.fullscreenElement || document.webkitFullscreenElement || window.innerHeight >= window.screen.height - 10;
  if(!isFull){
    const elem = document.documentElement;
    if(elem.requestFullscreen) elem.requestFullscreen().catch(err=>console.log(err));
    else if(elem.webkitRequestFullscreen) elem.webkitRequestFullscreen();
  } else {
    if(document.exitFullscreen) document.exitFullscreen();
    else if(document.webkitExitFullscreen) document.webkitExitFullscreen();
  }
}
function updateFullscreenState(){
  const isFull = !!(document.fullscreenElement || document.webkitFullscreenElement || window.innerHeight >= window.screen.height - 10);
  document.body.classList.toggle("in-fullscreen", isFull);
  const btn = document.getElementById("btnFullscreen");
  if(btn) btn.textContent = isFull ? "Exit Full (F)" : "Fullscreen (F)";
}
document.addEventListener("fullscreenchange", updateFullscreenState);
document.addEventListener("webkitfullscreenchange", updateFullscreenState);
window.addEventListener("resize", updateFullscreenState);
document.getElementById("btnFullscreen").onclick = toggleFullscreen;

document.getElementById("btnPause").onclick=togglePause;
document.getElementById("btnMap").onclick=()=>{ if(state==="play") mapView=!mapView; };
document.getElementById("btnRestart").onclick=()=>{
  if(state==="title"||state==="gameover"||state==="won"){ startGame(); return; }
  loadLevel(level); state="play"; flash("RESTARTED");
};
document.getElementById("btnSound").onclick=function(){
  soundOn=!soundOn; this.textContent="SFX: "+(soundOn?"On":"Off");
};
document.getElementById("btnMusic").onclick=function(){
  ensureAC();
  this.textContent="Music: "+(MUSIC.toggle()?"On":"Off");
};

/* touch pad */
document.querySelectorAll("#pad button").forEach(b=>{
  const d=b.dataset.d;
  const on=e=>{e.preventDefault(); if(state==="title"){startGame();return;} held[d]=true; lastMove=0;};
  const off=e=>{e.preventDefault(); held[d]=false;};
  b.addEventListener("pointerdown",on);
  b.addEventListener("pointerup",off);
  b.addEventListener("pointerleave",off);
  b.addEventListener("pointercancel",off);
});

requestAnimationFrame(frame);
</script>
</body>
</html>
MikeHiggins.me.uk LinkedIn GitHub Bluesky Contra Upwork
© 2026 Mike Higgins. All rights reserved.