I loved playing Repton on the BBC Micro when I was young. The puzzle of it — the ticking clock, the fragile diamonds, the things that hatched if you dropped a rock in the wrong place — stayed with me. Rockfall is a personal tribute to that era: a complete browser game built entirely in a single HTML file with no external libraries, no canvas frameworks and no images whatsoever. Every pixel is drawn at runtime in JavaScript.
Play it below. Arrow keys or WASD to move, M for the survey map, P to pause.
What is Repton?
Repton was a BBC Micro game released in 1985, designed by Tim Tyler and published by Superior Software. It set the template for a genre of puzzle-platformer that would define a generation of home computing: you play a small character navigating underground caverns, collecting diamonds whilst avoiding falling boulders, hatching eggs and roaming monsters. The rules were simple, the consequences were not.
The strategic depth came from a single physical interaction: round objects slide off each other. A rock sitting on top of another rock will roll sideways if there is space. That one rule, applied consistently across an entire level, creates puzzles of extraordinary complexity. Which rock do you move first? Which diamond can you never reach safely? Which egg is it already too late to avoid?
Rockfall inherits the same core ideas. Five handcrafted levels escalate the challenge and introduce new mechanics one at a time. The name on the tin is different but the DNA is unmistakably BBC Micro.
Code Breakdown
The cabinet and screen layout
The HTML is structured around a .cabinet div that mimics the look of an upright arcade machine. CSS custom properties define the palette: phosphor green, amber and the dark near-black of a switched-off CRT.
:root {
--phosphor: #57ff8a;
--amber: #ffb347;
--bezel: #17181c;
--bezel-hi: #2b2d34;
--screen: #000208;
--danger: #ff5d5d;
}
The body uses a radial gradient that lightens slightly towards the top, creating the impression of depth behind the cabinet. The .cabinet itself has a subtle inset top border highlight and a deep drop shadow beneath it. These details are small but they matter: the whole point is that the game should feel like it belongs in a physical case.
The canvas is 840×480 at native resolution and scaled to 100% width in CSS with image-rendering: pixelated. This means the pixel art remains sharp at any viewport width because the browser performs nearest-neighbour scaling rather than bilinear interpolation.
A .scanlines div sits absolutely positioned over the canvas, applying a repeating-linear-gradient of 1px dark bands with mix-blend-mode: multiply. A .glow div applies a radial vignette. Both are pointer-events: none so they never interfere with input.
The world dimensions
const COLS = 48, ROWS = 32, TILE = 60;
The playfield is a 48×32 grid of 60-pixel tiles, making the full world 2,880×1,920 pixels. The canvas viewport is 840×480 — roughly 14×8 tiles — which means the player can see about one-twelfth of the level at a time. The survey map (M) reveals the full level but the clock keeps running whilst you look at it. This is a faithful recreation of Repton’s mechanic: knowledge has a cost.
Tile IDs and the ASCII level format
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 are stored as arrays of 32 strings, each 48 characters wide. The CH object maps single characters to tile IDs. This means the levels are human-readable in the source — you can look at the map array and immediately see where the rocks are, where the diamonds sit and where the monsters lurk. Here is a small excerpt from level one, “First Dig”:
"#...*...............*...............#####......#",
"#...................................#.*.#......#",
"#...................................#...#......#",
"#...................................##.##......#",
The room with a diamond behind two walls is not there by accident. It is a deliberate teaching moment: the player must work out how to approach from the side without dislodging the rock above.
loadLevel iterates this map, parses each character and populates four parallel two-dimensional arrays: grid (the tile IDs), falling (whether each cell’s occupant is currently airborne), primed (whether a cell is one tick away from falling) and fallDist (how many tiles an object has fallen so far, used for impact magnitude).
The sprite factory
Every piece of artwork in the game is procedurally generated at startup by the sprite function.
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;
const 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;
}
A temporary off-screen canvas is created at 60×60 pixels. G defines the grid resolution — 15 by default, 20 for more detailed sprites. The px helper takes grid coordinates and a colour and draws a filled rectangle scaled to canvas pixels. Each call to sprite passes a drawing function that uses px to compose shapes.
The result is a pre-rendered canvas element that lives in memory. During the render loop, ctx.drawImage blits these cached canvases directly to the screen — no per-frame computation, no coordinate lookups, just a direct pixel copy.
Mirroring for left-facing sprites costs almost nothing:
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;
}
A canvas transform flips the right-facing sprite horizontally. The left-facing alien frames are produced from the right-facing ones for free.
The diamond: animated with four frames
The diamond is the only tile with animated glint built into its sprite set rather than its render call.
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
}, 20);
}
const DIAMOND = [makeDiamond(0), makeDiamond(1), makeDiamond(2), makeDiamond(3)];
The diamond is drawn with looped px calls that produce the classical faceted gem shape: horizontal bands widening towards the middle, narrowing below. The glint position gx shifts across the face between frames [6, 8, 11, 13]. Frame 2 adds a corner sparkle — a single white pixel with a highlight beside it. The render loop picks the frame using Math.floor(now / 160) % 4, cycling every 160ms.
The demon: a two-frame animation
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
} else {
px(3, 7, 2, 6, D.wing); px(15, 7, 2, 6, D.wing); // wings folded
}
// horns, head, glowing eyes, fangs, torso, claws, legs...
}, 20);
}
Frame 0 has wings folded at the sides; frame 1 has them raised, and the eyes gain a white flare pixel to simulate a predatory gleam. The wing beat runs at Math.floor(now / 230) % 2 — approximately 4.3 beats per second, which is fast enough to feel alive but not so fast as to feel mechanical.
The alien character: nine sprite variants
The player character — a small green alien with amber antennae — is the most complex sprite in the game. makeAlien accepts a facing direction, a walk-phase flag and an optional blink flag, then draws the appropriate view.
function makeAlien(face, stepf, blink = false) {
return sprite((px) => {
const legs = (l, r) => {
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);
};
// front/back view differs from side profile
if (face === "down" || face === "up") { /* ... */ }
else { /* side profile — left is a mirror of right */ }
}, 20);
}
The legs helper draws left and right legs with one raised by one grid unit when l or r is 1. This means walking is expressed as alternating legs(1, 0) and legs(0, 1) frames. The blink variant replaces the eye pixels with a thin horizontal line — identical geometry, different fill, so the blinking costs no extra computation.
Nine canvases are produced: down × 2 walk frames, up × 2, right × 2, left × 2 (mirrored from right), plus one idle-blink frame. The render loop selects between them based on facing direction, whether the character moved in the last 200ms and a periodic timer (now % 3400) < 160 for the idle blink.
The death sequence: four frames
When the player is killed, a four-frame death animation plays over 70 ticks.
const DEATHF = [
sprite(px => { /* shock: X eyes, antennae splayed */ }, 20),
sprite(px => { /* stagger: sinking, first stars */ }, 20),
sprite(px => { /* collapse: flattened, dizzy stars */ }, 20),
sprite(px => { /* a tiny soul floats away */ }, 20)
];
The shock frame gives the alien crossed X eyes and wide-splayed antennae. Stagger shows the body sinking with amber dizziness stars appearing. Collapse flattens the body to the ground with three stars orbiting. The final frame shows the flattened silhouette and a tiny translucent figure — the soul — floating upward. The render loop drives this with Math.min(3, ((70 - msgTimer) / 16) | 0).
The physics engine
The physics simulation runs every 160ms in physicsTick. It iterates the grid from the second-to-last row upward, so each row is processed before the row above it — this prevents a chain of falling objects from resolving in a single tick.
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];
// ...
}
}
A moved Set tracks which cells have already been processed this tick, preventing double-drops. The drop itself is:
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));
};
fallDist accumulates across each tick of falling. When a rock lands (settle), if fallDist >= 2 the impact triggers proportional screen shake: addShake(3 + d * 2). A one-tile drop is a quiet thud. A six-tile drop shakes the screen significantly. This is the kind of detail that makes physics feel weighty rather than abstract.
The wobble priming system
Objects do not fall the instant the cell below them becomes empty. They wobble first.
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
}
On the first tick where a fall is possible, primed is set. The render loop uses this flag to oscillate the rock’s draw position by ±3 pixels horizontally on alternate ticks:
let ox = 0;
if ((t === T.ROCK || t === T.EGG) && primed[y][x]) ox = (tickCount % 2 ? 3 : -3);
ctx.drawImage(s, x * TILE + ox - cx, y * TILE - cy);
This is the “wobble warning” visible in Repton’s original. It gives the player one physics tick to move before the rock falls — fair warning, if they are watching.
Sliding off round objects
A rock sitting on top of another round object — rock, diamond or egg — will slide sideways if there is room.
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 { /* slide into the available space, carrying momentum */ }
} else settle(x, y, t);
}
The player-position guard prevents rocks from sliding into a cell occupied by the player. Momentum is carried across the slide: fallDist[y][gx] = fallDist[y][x], so a rock that has already fallen three tiles and slides sideways continues as if those three tiles of falling history still apply.
Egg cracking and hatching
When an egg falls and lands, it transitions to T.CRACK rather than being destroyed.
function settle(x, y, t) {
// ...
if (t === T.EGG) { grid[y][x] = T.CRACK; crackT[y * COLS + x] = 5; SFX.crack(); }
// ...
}
A crackT dictionary stores the number of physics ticks remaining before the cracked egg hatches. Each tick, all entries in crackT are decremented. At zero, the tile becomes T.MONSTER and a new monster is pushed to the monsters array.
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!");
}
}
}
The cracked egg sprite alternates between two frames driven by Math.floor(now / 90) % 2 — a fast tremble that signals something stirring within. The hatch effect animation (three frames of shell burst) plays when the monster emerges.
Spirit wall-following AI
Spirits navigate using a strict left-hand wall-following algorithm, which makes their paths perfectly predictable once the player understands it.
function moveSpirits() {
for (const s of spirits) {
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 (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);
}
The priority order is: turn left first, then continue straight, then turn right, then reverse. This is classic left-hand rule maze solving. When a spirit enters a T.CAGE tile, it is removed from the simulation and the cage becomes a diamond. The cage mechanic gives the player complete strategic control over spirits — if you understand the algorithm, you can build a corridor that guides a spirit into its cage like a ball rolling down a ramp.
Monster BFS pathfinding
Monsters are smarter. They use a breadth-first search to find the shortest path to the player through open tunnels and diamond cells.
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++;
// ... explore neighbours ...
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) return null;
let k = found;
while (prev.get(k) !== start) k = prev.get(k);
return [k % COLS, (k - (k % COLS)) / COLS];
}
The search is capped at 420 nodes per monster per tick to prevent frame budget overruns on large maps. The prev map stores each node’s predecessor, enabling path reconstruction by walking backwards from the player’s position to the monster’s starting cell. The function returns only the next step — one cell — not the full path.
Crucially, monsters can traverse diamond tiles. This is what makes them a strategic threat beyond mere obstacle: they actively destroy diamonds as they hunt you. Dig a wide tunnel to collect gems quickly and you have also given the monsters a motorway.
The Web Audio chiptune engine
The audio system recreates the sound character of an early home computer in a browser. It has four channels: square oscillator (lead melody), triangle oscillator (bass), band-filtered noise (hi-hat) and a frequency-swept triangle (kick drum).
function beep(freq, dur = 0.07, type = "square", vol = 0.05, slide = 0, when = 0) {
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);
}
The slide parameter applies an exponential pitch ramp — used for the die sound (sawtooth falling to near-zero) and the hatch sound (rising pitch that mimics an ascending creature). Every oscillator node is created, used and discarded rather than reused, which is the correct Web Audio pattern: nodes are lightweight, and the garbage collector handles cleanup.
The background music is sequenced using a 64-step lead pattern and a 32-step bass pattern, both stored as arrays of MIDI note numbers.
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];
MIDI note 69 is A4 (440Hz). The melody is in A natural minor. The progression is Am–C–F–E — a classic minor-key sequence. The scheduler runs on a 25ms setInterval and looks 120ms ahead, scheduling notes into the future using AudioContext.currentTime. This look-ahead buffer prevents glitches if the JavaScript thread is briefly busy.
When timeLeft < 25, the tempo jumps from 112 to 140 BPM:
const bpm = (timeLeft < 25) ? 140 : 112, dur16 = 60 / bpm / 4;
The music literally accelerates when the clock runs low — a small detail that creates measurable tension.
The game loop
The main loop runs on requestAnimationFrame. It maintains two independent timers: lastPhys for physics ticks (160ms interval) and lastMove for held-key movement (115ms interval). These intentionally run at different rates: physics can happen without the player moving, and the player can attempt to move between physics ticks.
function frame(t) {
requestAnimationFrame(frame);
const dt = Math.min(0.1, (t - lastFrame) / 1000); lastFrame = t;
if (state === "play") {
timeLeft -= dt;
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();
}
// smooth camera follow
camX += (tx - camX) * 0.18; camY += (ty - camY) * 0.18;
// shake decay
shake *= 0.86; if (shake < 0.4) shake = 0;
// effect expiry, message timer, then draw
}
The camera follows the player with exponential smoothing (* 0.18). The shake value decays by 14% per frame, reaching zero cleanly rather than oscillating around it.
The dt clamp of 0.1 seconds means that if the page is hidden and then restored, the timer does not suddenly jump forward by several seconds. This is an important detail in games with tight time limits.
The winability check
After every event that reduces the number of available diamonds — a monster eating one, a rock shattering one, or a spirit being crushed — the game checks whether the quota is still achievable.
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);
}
potentialGems() counts the diamonds already on the map, adds safes if the key is still accessible and adds the number of spirits that could still be caged. The formula is Math.min(spirits.length, c) because you cannot cage more spirits than there are cages. If collected + potentialGems() < need, the game tells the player to restart — there is no way to win from this position. This prevents players from spending minutes completing an impossible level.
Full Source Code
The complete, unabridged source is below. It is entirely self-contained: one file, no dependencies, no network requests after load.
<!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>
:root {
--phosphor: #57ff8a;
--amber: #ffb347;
--bezel: #17181c;
--bezel-hi: #2b2d34;
--screen: #000208;
--danger: #ff5d5d;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0
}
html,
body {
height: 100%
}
body {
background: #0a0b0e;
background-image: radial-gradient(ellipse at 50% -20%, #16202b 0%, #0a0b0e 60%);
color: var(--phosphor);
font-family: "Courier New", ui-monospace, monospace;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 12px;
}
.cabinet {
background: linear-gradient(180deg, var(--bezel-hi), var(--bezel) 30%);
border: 2px solid #000;
border-radius: 18px;
padding: 14px 14px 10px;
box-shadow: 0 24px 60px rgba(0, 0, 0, .7), inset 0 1px 0 rgba(255, 255, 255, .08);
max-width: 920px;
width: 100%;
}
.marquee {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 2px 6px 10px;
letter-spacing: .35em;
text-transform: uppercase;
}
.marquee h1 {
font-size: 20px;
color: var(--amber);
text-shadow: 0 0 12px rgba(255, 179, 71, .5);
font-weight: 700
}
.marquee small {
color: #9aa3ad;
letter-spacing: .15em;
font-size: 11px
}
.screen {
position: relative;
background: var(--screen);
border-radius: 10px;
border: 2px solid #000;
box-shadow: inset 0 0 40px rgba(0, 0, 0, .9), inset 0 0 6px rgba(87, 255, 138, .15);
overflow: hidden;
}
canvas {
display: block;
width: 100%;
height: auto;
image-rendering: pixelated
}
.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: 6px 18px;
justify-content: space-between;
padding: 10px 6px 4px;
font-size: 13px;
letter-spacing: .12em;
text-transform: uppercase;
}
.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;
justify-content: space-between;
align-items: center;
gap: 10px;
padding: 6px 6px 2px;
color: #9aa3ad;
font-size: 11px;
letter-spacing: .08em;
}
button {
background: #0e1013;
color: var(--phosphor);
border: 1px solid #3a3f47;
border-radius: 6px;
font: inherit;
font-size: 11px;
letter-spacing: .12em;
text-transform: uppercase;
padding: 5px 10px;
cursor: pointer;
}
button:hover {
border-color: var(--phosphor)
}
button:focus-visible {
outline: 2px solid var(--amber);
outline-offset: 2px
}
.pad {
display: none;
gap: 8px;
justify-content: center;
padding-top: 10px;
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: 10px
}
@media (pointer:coarse) {
.pad {
display: flex
}
}
@media (prefers-reduced-motion:reduce) {
.scanlines {
display: none
}
}
</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 · reach the quota, then escape through the
portal</span>
<span>
<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>
</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") { 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 === "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"; }
}
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>