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>