A maze game looks like one of those projects you need a game engine for. It isn’t. The whole thing fits in one HTML file, runs offline, works on a phone, and the part people find most impressive β a fresh maze every single time you press play β is around forty lines of logic.
I built this one and called it Stellix Maze. Here is how it comes together, in the order I actually built it, so you can follow along and end up with your own version instead of a copy of mine.
Start with the shape of the game
Before touching any code, decide what the player is doing. “Get from corner A to corner B” is a walk, not a game. So I added one rule: the exit gate stays locked until you have picked up every key hidden in the maze. Now you cannot just hug one wall and sprint to the end. You have to explore, and exploring is where the tension lives.
Three things follow from that one rule:
The gate needs two visual states β locked and open. Red and green do that job without a word of explanation.
Keys need to be placed far from the start, otherwise you collect them by accident on your way past.
The player needs feedback for everything: a counter that says 2 of 3, a colour change on the gate, a small burst of light when a key is picked up.
Write your rule down in one sentence before you start. Every design decision afterwards gets easier because you have something to check it against.
Build the page skeleton first
The layout is simple: a header with your brand, a row of stat boxes, a square board, a row of tool buttons, and a directional pad for phones. That is it. I used a single square canvas for the maze itself and plain HTML for everything around it, because HTML text is easier to style, easier to read on screen readers, and does not need to be redrawn sixty times a second.
Keep the board square with the aspect-ratio property in CSS and let the width be fluid. That one line saves you from a pile of media queries later, because the board just shrinks with the screen and always stays a perfect square.
For type, one family used properly beats three families fighting each other. I used Jost for the interface and a monospaced face only for numbers β timers and counters look wrong when the digits change width while they tick.
Generate the maze
This is the heart of it, and it is far less scary than it sounds.
Think of the grid as a set of cells, and every cell starts with four walls: north, east, south, west. The algorithm is called recursive backtracking, and it works like a person exploring with chalk and a piece of string.
Stand on the first cell and mark it as visited. Look at the neighbours that have not been visited yet. Pick one at random, knock down the wall between you and it, step into it, mark it visited. Keep doing that. When you reach a cell where every neighbour has already been visited, you are in a dead end β so step backwards along the path you came from until you find a cell that still has an unvisited neighbour, and carry on from there. When you have backtracked all the way to the start with nothing left to visit, the maze is finished.
Two things make this algorithm perfect for a beginner project. It always produces a maze where every cell is reachable from every other cell, so you can never generate an unsolvable level by accident. And it produces long, winding corridors rather than open rooms, which is exactly the feel a maze game wants.
One implementation note that saves an hour of confusion: when you knock down a wall, you have to remove it from both cells. The north wall of one cell is the south wall of its neighbour. Forget that and your player will walk through solid lines.
Move the player
Movement is a permission check, not a physics problem. When the player presses up, look at the current cell’s north wall. If it is still standing, refuse the move and shake the screen slightly. If it is gone, update the player’s cell.
The shake is worth adding. Without it, a blocked move feels like the game ignored the key press. With it, the game clearly says “you tried, there is a wall there.”
For the movement to feel smooth rather than teleporting, keep two positions: the cell the player logically occupies, and the pixel position that gets drawn. Each frame, nudge the drawn position a fraction of the way toward the target. The player slides instead of jumping, and it costs one line of maths.
Make it work on a phone
Three input methods cover everyone. Arrow keys and WASD for desktop. Swipe on the board for phones β record where a touch started and where it ended, compare the horizontal and vertical distance, and whichever is larger decides the direction. And an on-screen directional pad for people who prefer buttons or are playing one-handed.
Add a small minimum distance before a swipe counts, around twenty pixels. Without it, an accidental tap registers as a swipe and the player moves when they did not mean to.
Add the hint system
A maze can beat you, and a player who is genuinely stuck will close the tab rather than lose gracefully. A hint button fixes that, and it happens to teach a second algorithm.
Breadth-first search finds the shortest route between two cells. Start a queue with the player’s cell, then repeatedly take the front cell out, look at every neighbour you can actually reach β meaning the wall between you is gone β and add any you have not seen before, remembering which cell you came from. When you reach the target, walk backwards through those “came from” links and you have the shortest path.
I show that path as a trail of green dots for about two seconds, aimed at the nearest key if keys remain, or the gate if they are all collected. Hints are limited to three, with one returned after each cleared level, so the safety net exists without removing the challenge.
Torch mode is the one feature people remember
Everything above makes a solid maze game. This is the bit that makes it memorable, and it is almost embarrassingly cheap to build.
After drawing the maze, paint a radial gradient over the whole board, transparent in the middle and near-black at the edges, centred on the player. The maze is still there; you just cannot see most of it. Suddenly a level you cleared in forty seconds takes four minutes, and you are navigating from memory. One gradient, an entirely different game.
Scale the difficulty
Level one is a nine by nine grid with one key. Every level adds two rows and columns and, every second level, another key, up to a sensible ceiling of twenty-three by twenty-three. Because the maze is generated fresh each time, you never need to design a level by hand. The algorithm is your level designer, and it never runs out of ideas.
Polish before you publish
Track time, moves and a best time per level so there is a reason to replay. Add a light mode. Make the focus outlines visible for keyboard players. Respect the reduced-motion setting for anyone who finds animation uncomfortable. And test on a real phone, not just a narrow browser window β touch targets always feel smaller in your hand than they look on a monitor.
Build it once, and you will have used procedural generation, pathfinding, canvas rendering, animation loops and touch input in a single afternoon project. That is a good return for one HTML file.
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<title>Stellix Maze β Coding Stellix</title>
<meta name="description" content="Stellix Maze β a procedural maze runner game by Coding Stellix. Collect every key, unlock the gate, escape before the clock beats you." />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Jost:wght@300;400;500;600;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
<style>
:root{
--void:#0a0713;
--panel:rgba(255,255,255,.05);
--stroke:rgba(255,255,255,.10);
--ink:#efe9ff;
--muted:#9b91b8;
--violet:#8b5cf6;
--cyan:#22d3ee;
--lime:#b7f34a;
--rose:#ff3d7f;
--amber:#ffc14d;
--wall:#6d5bd0;
--floor:rgba(255,255,255,.03);
--shadow:0 24px 70px rgba(0,0,0,.55);
}
html[data-theme="light"]{
--void:#f4f1fb;
--panel:rgba(20,10,45,.04);
--stroke:rgba(20,10,45,.10);
--ink:#1a1030;
--muted:#61557f;
--wall:#5b46c4;
--floor:rgba(20,10,45,.035);
--shadow:0 24px 60px rgba(60,40,120,.18);
}
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent}
body{
font-family:'Jost',sans-serif;
background:var(--void);
color:var(--ink);
min-height:100vh;
display:flex;flex-direction:column;align-items:center;
padding:18px 14px 34px;
overflow-x:hidden;
transition:background .35s ease,color .35s ease;
}
body::before{
content:"";position:fixed;inset:0;pointer-events:none;z-index:0;
background:
radial-gradient(58vw 48vw at 12% -8%, rgba(139,92,246,.30), transparent 60%),
radial-gradient(50vw 42vw at 100% 8%, rgba(34,211,238,.20), transparent 60%),
radial-gradient(60vw 50vw at 50% 115%, rgba(255,61,127,.16), transparent 62%);
}
html[data-theme="light"] body::before{opacity:.55}
.wrap{position:relative;z-index:1;width:100%;max-width:640px;display:flex;flex-direction:column;gap:16px}
/* ---------- header ---------- */
header{display:flex;align-items:center;justify-content:space-between;gap:12px}
.brand{display:flex;align-items:center;gap:10px}
.mark{
width:38px;height:38px;border-radius:12px;flex:none;
background:linear-gradient(140deg,var(--violet),var(--cyan));
display:grid;place-items:center;
box-shadow:0 8px 26px rgba(139,92,246,.45);
}
.mark svg{width:20px;height:20px}
.brand b{display:block;font-size:.95rem;font-weight:600;letter-spacing:.02em;line-height:1.1}
.brand small{display:block;font-size:.68rem;letter-spacing:.24em;text-transform:uppercase;color:var(--muted)}
.icon-btn{
width:40px;height:40px;border-radius:12px;border:1px solid var(--stroke);
background:var(--panel);color:var(--ink);cursor:pointer;display:grid;place-items:center;
transition:transform .18s ease,border-color .18s ease;
}
.icon-btn:hover{transform:translateY(-2px);border-color:var(--violet)}
.icon-btn:focus-visible{outline:2px solid var(--cyan);outline-offset:3px}
.icon-btn svg{width:19px;height:19px}
/* ---------- title ---------- */
.title{text-align:center;margin-top:2px}
.title h1{
font-size:clamp(2rem,9vw,2.9rem);font-weight:700;letter-spacing:-.03em;line-height:1;
background:linear-gradient(100deg,var(--violet),var(--cyan) 55%,var(--lime));
-webkit-background-clip:text;background-clip:text;color:transparent;
}
.title p{color:var(--muted);font-size:.9rem;margin-top:6px;font-weight:300}
/* ---------- hud ---------- */
.hud{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}
.stat{
background:var(--panel);border:1px solid var(--stroke);border-radius:14px;
padding:9px 6px;text-align:center;backdrop-filter:blur(10px);
}
.stat span{display:block;font-size:.6rem;letter-spacing:.16em;text-transform:uppercase;color:var(--muted)}
.stat strong{font-family:'Space Mono',monospace;font-size:1.05rem;font-weight:700;letter-spacing:-.02em}
.stat.warn strong{color:var(--rose)}
/* ---------- board ---------- */
.board{
position:relative;border-radius:22px;overflow:hidden;
border:1px solid var(--stroke);background:var(--panel);
box-shadow:var(--shadow);backdrop-filter:blur(12px);
aspect-ratio:1/1;width:100%;
}
canvas{display:block;width:100%;height:100%;touch-action:none}
.overlay{
position:absolute;inset:0;display:none;place-items:center;text-align:center;
background:rgba(10,7,19,.86);backdrop-filter:blur(7px);padding:22px;z-index:3;
}
html[data-theme="light"] .overlay{background:rgba(244,241,251,.90)}
.overlay.show{display:grid;animation:pop .3s ease}
@keyframes pop{from{opacity:0;transform:scale(.96)}to{opacity:1;transform:none}}
.overlay h2{font-size:1.9rem;font-weight:700;letter-spacing:-.02em}
.overlay .tag{font-size:.68rem;letter-spacing:.26em;text-transform:uppercase;color:var(--lime);margin-bottom:6px}
.overlay p{color:var(--muted);margin:8px 0 16px;font-size:.95rem;line-height:1.5}
.overlay .row{display:flex;gap:16px;justify-content:center;margin-bottom:18px}
.overlay .row div{font-family:'Space Mono',monospace}
.overlay .row div small{display:block;font-family:'Jost',sans-serif;font-size:.6rem;letter-spacing:.16em;text-transform:uppercase;color:var(--muted)}
/* ---------- buttons ---------- */
.btn{
font-family:'Jost',sans-serif;font-size:.88rem;font-weight:500;
padding:11px 20px;border-radius:12px;cursor:pointer;border:1px solid var(--stroke);
background:var(--panel);color:var(--ink);
display:inline-flex;align-items:center;gap:8px;justify-content:center;
transition:transform .16s ease,border-color .16s ease,background .16s ease;
}
.btn:hover{transform:translateY(-2px);border-color:var(--violet)}
.btn:focus-visible{outline:2px solid var(--cyan);outline-offset:3px}
.btn.primary{
background:linear-gradient(120deg,var(--violet),var(--cyan));
border-color:transparent;color:#0a0713;font-weight:600;
box-shadow:0 10px 30px rgba(139,92,246,.4);
}
.btn.on{border-color:var(--lime);color:var(--lime)}
.btn svg{width:16px;height:16px}
.tools{display:flex;gap:8px;flex-wrap:wrap;justify-content:center}
.tools .btn{flex:1;min-width:118px}
/* ---------- dpad ---------- */
.pad{display:grid;grid-template-columns:repeat(3,64px);grid-template-rows:repeat(2,64px);gap:8px;justify-content:center}
.pad button{
border-radius:16px;border:1px solid var(--stroke);background:var(--panel);color:var(--ink);
cursor:pointer;display:grid;place-items:center;transition:transform .12s ease,background .12s ease;
}
.pad button:active{transform:scale(.93);background:rgba(139,92,246,.25)}
.pad button:focus-visible{outline:2px solid var(--cyan);outline-offset:2px}
.pad button svg{width:22px;height:22px}
.pad .up{grid-column:2}
.hint-line{text-align:center;font-size:.78rem;color:var(--muted);font-weight:300}
footer{
text-align:center;font-size:.75rem;color:var(--muted);font-weight:300;
border-top:1px solid var(--stroke);padding-top:14px;letter-spacing:.04em;
}
footer b{color:var(--ink);font-weight:600}
@media (max-width:420px){
.pad{grid-template-columns:repeat(3,56px);grid-template-rows:repeat(2,56px)}
.stat strong{font-size:.92rem}
}
@media (prefers-reduced-motion:reduce){
*{animation-duration:.01ms!important;transition-duration:.01ms!important}
}
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="brand">
<div class="mark">
<svg viewBox="0 0 24 24" fill="none" stroke="#0a0713" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 3h7v4H7v4h6V7h-3V3h11v7h-4v4h4v7h-7v-4h3v-3h-6v3h3v4H3v-7h4v-4H3z"/>
</svg>
</div>
<div>
<b>Coding Stellix</b>
<small>Maze</small>
</div>
</div>
<button class="icon-btn" id="themeBtn" aria-label="Switch to light mode" title="Switch theme">
<svg id="themeIcon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M19.1 4.9l-1.4 1.4M6.3 17.7l-1.4 1.4"/>
</svg>
</button>
</header>
<div class="title">
<h1>Stellix Maze</h1>
<p>Collect every key, unlock the gate, get out before the walls win.</p>
</div>
<div class="hud">
<div class="stat"><span>Level</span><strong id="sLevel">1</strong></div>
<div class="stat"><span>Time</span><strong id="sTime">0:00</strong></div>
<div class="stat"><span>Moves</span><strong id="sMoves">0</strong></div>
<div class="stat"><span>Keys</span><strong id="sKeys">0/1</strong></div>
</div>
<div class="board">
<canvas id="cv"></canvas>
<div class="overlay" id="ovStart">
<div>
<div class="tag">Coding Stellix</div>
<h2>Ready to run?</h2>
<p>Arrow keys or WASD on desktop.<br>Swipe or use the pad on mobile.</p>
<button class="btn primary" id="startBtn">Enter the maze</button>
</div>
</div>
<div class="overlay" id="ovWin">
<div>
<div class="tag" id="winTag">Level cleared</div>
<h2 id="winTitle">You escaped</h2>
<div class="row">
<div><small>Time</small><span id="wTime">0:00</span></div>
<div><small>Moves</small><span id="wMoves">0</span></div>
<div><small>Best</small><span id="wBest">β</span></div>
</div>
<button class="btn primary" id="nextBtn">Next level</button>
</div>
</div>
</div>
<div class="tools">
<button class="btn" id="newBtn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 15.5-6.2L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-15.5 6.2L3 16"/><path d="M3 21v-5h5"/></svg>
New maze
</button>
<button class="btn" id="hintBtn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M12 2a7 7 0 0 0-4 12.7V17h8v-2.3A7 7 0 0 0 12 2z"/></svg>
<span id="hintLabel">Hint (3)</span>
</button>
<button class="btn" id="torchBtn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3a4 4 0 0 1 4 4c0 2-2 3-2 5h-4c0-2-2-3-2-5a4 4 0 0 1 4-4z"/><path d="M10 16h4M11 20h2"/></svg>
Torch mode
</button>
</div>
<div class="pad" aria-label="Movement controls">
<button class="up" data-dir="0" aria-label="Move up"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg></button>
<button data-dir="3" aria-label="Move left"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5M12 19l-7-7 7-7"/></svg></button>
<button data-dir="2" aria-label="Move down"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12l7 7 7-7"/></svg></button>
<button data-dir="1" aria-label="Move right"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg></button>
</div>
<p class="hint-line" id="tipLine">Every maze is generated fresh β no two runs are the same.</p>
<footer>Built by <b>Coding Stellix</b></footer>
</div>
<script>
(function(){
"use strict";
/* ============ elements ============ */
const cv = document.getElementById('cv');
const ctx = cv.getContext('2d');
const sLevel=document.getElementById('sLevel'), sTime=document.getElementById('sTime'),
sMoves=document.getElementById('sMoves'), sKeys=document.getElementById('sKeys');
const ovStart=document.getElementById('ovStart'), ovWin=document.getElementById('ovWin');
const tipLine=document.getElementById('tipLine');
/* ============ state ============ */
const S = {
cols:9, rows:9, grid:[], player:0, exit:0,
keys:[], taken:0, level:1, moves:0, t0:0, elapsed:0,
running:false, torch:false, hints:3, path:[], pathUntil:0,
best:{}, px:0, py:0, tx:0, ty:0, trail:[], sparks:[], shake:0
};
const DR=[-1,0,1,0], DC=[0,1,0,-1]; // N E S W
/* ============ maze generation (recursive backtracker) ============ */
function inBounds(r,c){ return r>=0 && c>=0 && r<S.rows && c<S.cols; }
function idx(r,c){ return r*S.cols+c; }
function generate(){
const n=S.cols*S.rows;
const g=new Array(n);
for(let i=0;i<n;i++) g[i]={w:[1,1,1,1],v:false};
const stack=[0]; g[0].v=true;
while(stack.length){
const cur=stack[stack.length-1];
const r=(cur/S.cols)|0, c=cur%S.cols;
const options=[];
for(let d=0;d<4;d++){
const nr=r+DR[d], nc=c+DC[d];
if(inBounds(nr,nc) && !g[idx(nr,nc)].v) options.push(d);
}
if(options.length){
const d=options[(Math.random()*options.length)|0];
const ni=idx(r+DR[d],c+DC[d]);
g[cur].w[d]=0; g[ni].w[(d+2)%4]=0; g[ni].v=true;
stack.push(ni);
} else stack.pop();
}
return g;
}
/* ============ BFS solver ============ */
function bfs(from,to){
const prev=new Array(S.cols*S.rows).fill(-1);
const seen=new Array(S.cols*S.rows).fill(false);
const q=[from]; seen[from]=true;
while(q.length){
const cur=q.shift();
if(cur===to) break;
const r=(cur/S.cols)|0, c=cur%S.cols;
for(let d=0;d<4;d++){
if(S.grid[cur].w[d]) continue;
const nr=r+DR[d], nc=c+DC[d];
if(!inBounds(nr,nc)) continue;
const ni=idx(nr,nc);
if(seen[ni]) continue;
seen[ni]=true; prev[ni]=cur; q.push(ni);
}
}
if(!seen[to]) return [];
const path=[]; let cur=to;
while(cur!==-1){ path.push(cur); cur=prev[cur]; }
return path.reverse();
}
/* ============ level setup ============ */
function sizeForLevel(l){ return Math.min(9 + (l-1)*2, 23); }
function keysForLevel(l){ return Math.min(1 + Math.floor((l-1)/2), 5); }
function newLevel(keepLevel){
if(!keepLevel) S.level++;
const n = sizeForLevel(S.level);
S.cols=n; S.rows=n;
S.grid=generate();
S.player=0;
S.exit=S.cols*S.rows-1;
// place keys on cells far from start, spread out
const target=keysForLevel(S.level);
const dist=bfsDistances(0);
const pool=[];
for(let i=0;i<S.cols*S.rows;i++){
if(i!==0 && i!==S.exit && dist[i] > n*0.6) pool.push(i);
}
S.keys=[];
for(let k=0;k<target && pool.length;k++){
const pick=(Math.random()*pool.length)|0;
S.keys.push(pool[pick]);
pool.splice(pick,1);
}
while(S.keys.length<target){
const rnd=1+((Math.random()*(S.cols*S.rows-2))|0);
if(rnd!==S.exit && !S.keys.includes(rnd)) S.keys.push(rnd);
}
S.taken=0; S.moves=0; S.elapsed=0; S.t0=performance.now();
S.path=[]; S.pathUntil=0; S.trail=[]; S.sparks=[];
const p=cellCenter(S.player); S.px=S.tx=p.x; S.py=S.ty=p.y;
S.running=true;
resize();
updateHUD();
}
function bfsDistances(from){
const d=new Array(S.cols*S.rows).fill(-1);
d[from]=0; const q=[from];
while(q.length){
const cur=q.shift();
const r=(cur/S.cols)|0, c=cur%S.cols;
for(let k=0;k<4;k++){
if(S.grid[cur].w[k]) continue;
const nr=r+DR[k], nc=c+DC[k];
if(!inBounds(nr,nc)) continue;
const ni=idx(nr,nc);
if(d[ni]===-1){ d[ni]=d[cur]+1; q.push(ni); }
}
}
return d;
}
/* ============ canvas sizing ============ */
let CS=20, PAD=10, DPR=1;
function resize(){
const rect=cv.parentElement.getBoundingClientRect();
const size=Math.max(240, Math.floor(Math.min(rect.width, rect.height)));
DPR=Math.min(window.devicePixelRatio||1, 2);
cv.width=size*DPR; cv.height=size*DPR;
ctx.setTransform(DPR,0,0,DPR,0,0);
PAD=Math.max(10, size*0.035);
CS=(size-PAD*2)/S.cols;
const p=cellCenter(S.player);
S.tx=p.x; S.ty=p.y;
}
function cellCenter(i){
const r=(i/S.cols)|0, c=i%S.cols;
return {x:PAD+c*CS+CS/2, y:PAD+r*CS+CS/2};
}
/* ============ movement ============ */
function move(d){
if(!S.running) return;
const cur=S.player;
if(S.grid[cur].w[d]) { S.shake=6; return; }
const r=(cur/S.cols)|0, c=cur%S.cols;
const nr=r+DR[d], nc=c+DC[d];
if(!inBounds(nr,nc)) return;
const ni=idx(nr,nc);
S.player=ni; S.moves++;
S.trail.push({i:cur,a:1});
if(S.trail.length>26) S.trail.shift();
const ki=S.keys.indexOf(ni);
if(ki>-1){
S.keys.splice(ki,1); S.taken++;
burst(cellCenter(ni), 'var(--amber)');
}
if(ni===S.exit && S.keys.length===0) win();
const p=cellCenter(ni); S.tx=p.x; S.ty=p.y;
updateHUD();
}
function burst(pos,color){
for(let i=0;i<18;i++){
const a=Math.random()*Math.PI*2, sp=1+Math.random()*3;
S.sparks.push({x:pos.x,y:pos.y,vx:Math.cos(a)*sp,vy:Math.sin(a)*sp,life:1,c:color});
}
}
/* ============ win ============ */
function win(){
S.running=false;
const t=S.elapsed;
const key='L'+S.level;
const prevBest=S.best[key];
const isBest = prevBest===undefined || t<prevBest;
if(isBest) S.best[key]=t;
document.getElementById('wTime').textContent=fmt(t);
document.getElementById('wMoves').textContent=S.moves;
document.getElementById('wBest').textContent=fmt(S.best[key]);
document.getElementById('winTag').textContent = isBest ? 'New best time' : 'Level cleared';
document.getElementById('winTitle').textContent = 'Level '+S.level+' escaped';
burst(cellCenter(S.exit),'var(--lime)');
setTimeout(()=>ovWin.classList.add('show'), 420);
}
/* ============ hud ============ */
function fmt(ms){
if(ms===undefined) return 'β';
const s=Math.floor(ms/1000);
return Math.floor(s/60)+':'+String(s%60).padStart(2,'0');
}
function updateHUD(){
sLevel.textContent=S.level;
sMoves.textContent=S.moves;
const total=S.taken+S.keys.length;
sKeys.textContent=S.taken+'/'+total;
sKeys.parentElement.classList.toggle('warn', S.keys.length>0);
}
/* ============ render loop ============ */
function css(v){
return getComputedStyle(document.documentElement).getPropertyValue(v).trim();
}
let COL={};
function readColors(){
COL={wall:css('--wall'),cyan:css('--cyan'),violet:css('--violet'),lime:css('--lime'),
rose:css('--rose'),amber:css('--amber'),floor:css('--floor'),ink:css('--ink')};
}
readColors();
function draw(now){
requestAnimationFrame(draw);
const W=cv.width/DPR, H=cv.height/DPR;
ctx.clearRect(0,0,W,H);
if(S.running){ S.elapsed=now-S.t0; sTime.textContent=fmt(S.elapsed); }
let ox=0,oy=0;
if(S.shake>0){ ox=(Math.random()-.5)*S.shake; oy=(Math.random()-.5)*S.shake; S.shake*=0.82; if(S.shake<0.3)S.shake=0; }
ctx.save(); ctx.translate(ox,oy);
// smooth player
S.px += (S.tx-S.px)*0.28;
S.py += (S.ty-S.py)*0.28;
// floor
ctx.fillStyle=COL.floor;
ctx.fillRect(PAD,PAD,CS*S.cols,CS*S.rows);
// trail
S.trail.forEach((t,n)=>{
const p=cellCenter(t.i);
const a=(n+1)/S.trail.length*0.20;
ctx.fillStyle=hexA(COL.cyan,a);
ctx.fillRect(p.x-CS/2+CS*0.16,p.y-CS/2+CS*0.16,CS*0.68,CS*0.68);
});
// hint path
if(now < S.pathUntil && S.path.length){
ctx.fillStyle=hexA(COL.lime,.55);
S.path.forEach(i=>{
const p=cellCenter(i);
ctx.beginPath(); ctx.arc(p.x,p.y,Math.max(1.6,CS*0.11),0,Math.PI*2); ctx.fill();
});
}
// exit gate
const ep=cellCenter(S.exit);
const open=S.keys.length===0;
ctx.save();
ctx.shadowBlur=18; ctx.shadowColor=open?COL.lime:COL.rose;
ctx.fillStyle=hexA(open?COL.lime:COL.rose,.85);
roundRect(ep.x-CS*0.32, ep.y-CS*0.32, CS*0.64, CS*0.64, CS*0.16);
ctx.fill();
ctx.restore();
// keys
const pulse=0.5+0.5*Math.sin(now/280);
S.keys.forEach(i=>{
const p=cellCenter(i);
ctx.save();
ctx.shadowBlur=10+8*pulse; ctx.shadowColor=COL.amber;
ctx.fillStyle=COL.amber;
ctx.beginPath();
ctx.arc(p.x,p.y-CS*0.06,CS*0.14,0,Math.PI*2);
ctx.fill();
ctx.fillRect(p.x-CS*0.03,p.y+CS*0.04,CS*0.06,CS*0.20);
ctx.fillRect(p.x-CS*0.03,p.y+CS*0.18,CS*0.13,CS*0.05);
ctx.restore();
});
// walls
ctx.strokeStyle=COL.wall;
ctx.lineWidth=Math.max(1.6,CS*0.10);
ctx.lineCap='round';
ctx.save();
ctx.shadowBlur=10; ctx.shadowColor=hexA(COL.violet,.7);
ctx.beginPath();
for(let r=0;r<S.rows;r++){
for(let c=0;c<S.cols;c++){
const cell=S.grid[idx(r,c)];
const x=PAD+c*CS, y=PAD+r*CS;
if(cell.w[0]){ ctx.moveTo(x,y); ctx.lineTo(x+CS,y); }
if(cell.w[3]){ ctx.moveTo(x,y); ctx.lineTo(x,y+CS); }
if(r===S.rows-1 && cell.w[2]){ ctx.moveTo(x,y+CS); ctx.lineTo(x+CS,y+CS); }
if(c===S.cols-1 && cell.w[1]){ ctx.moveTo(x+CS,y); ctx.lineTo(x+CS,y+CS); }
}
}
ctx.stroke();
ctx.restore();
// player
ctx.save();
ctx.shadowBlur=22; ctx.shadowColor=COL.cyan;
const grd=ctx.createRadialGradient(S.px,S.py,1,S.px,S.py,CS*0.36);
grd.addColorStop(0,'#ffffff'); grd.addColorStop(1,COL.cyan);
ctx.fillStyle=grd;
ctx.beginPath(); ctx.arc(S.px,S.py,CS*0.26,0,Math.PI*2); ctx.fill();
ctx.restore();
// sparks
for(let i=S.sparks.length-1;i>=0;i--){
const s=S.sparks[i];
s.x+=s.vx; s.y+=s.vy; s.vy+=0.06; s.life-=0.02;
if(s.life<=0){ S.sparks.splice(i,1); continue; }
ctx.fillStyle=hexA(s.c.startsWith('var')?COL.amber:s.c, s.life);
ctx.fillRect(s.x,s.y,2.4,2.4);
}
// torch fog
if(S.torch && S.running){
const radius=CS*3.4;
const fog=ctx.createRadialGradient(S.px,S.py,radius*0.35,S.px,S.py,radius);
fog.addColorStop(0,'rgba(0,0,0,0)');
fog.addColorStop(1,'rgba(0,0,0,0.94)');
ctx.fillStyle=fog;
ctx.fillRect(0,0,W,H);
}
ctx.restore();
}
function roundRect(x,y,w,h,r){
ctx.beginPath();
ctx.moveTo(x+r,y);
ctx.arcTo(x+w,y,x+w,y+h,r);
ctx.arcTo(x+w,y+h,x,y+h,r);
ctx.arcTo(x,y+h,x,y,r);
ctx.arcTo(x,y,x+w,y,r);
ctx.closePath();
}
function hexA(hex,a){
hex=(hex||'#22d3ee').trim();
if(hex.startsWith('rgb')) return hex.replace(/rgba?\(([^)]+)\)/, (m,p)=>{
const parts=p.split(',').slice(0,3).join(',');
return 'rgba('+parts+','+a+')';
});
let h=hex.replace('#','');
if(h.length===3) h=h.split('').map(x=>x+x).join('');
const n=parseInt(h,16);
return 'rgba('+((n>>16)&255)+','+((n>>8)&255)+','+(n&255)+','+a+')';
}
/* ============ controls ============ */
const KEYMAP={ArrowUp:0,ArrowRight:1,ArrowDown:2,ArrowLeft:3,w:0,d:1,s:2,a:3,W:0,D:1,S:2,A:3};
window.addEventListener('keydown',e=>{
const d=KEYMAP[e.key];
if(d!==undefined){ e.preventDefault(); move(d); }
});
document.querySelectorAll('.pad button').forEach(b=>{
b.addEventListener('click',()=>move(+b.dataset.dir));
});
let sx=0,sy=0;
cv.addEventListener('touchstart',e=>{ const t=e.touches[0]; sx=t.clientX; sy=t.clientY; },{passive:true});
cv.addEventListener('touchend',e=>{
const t=e.changedTouches[0];
const dx=t.clientX-sx, dy=t.clientY-sy;
if(Math.abs(dx)<22 && Math.abs(dy)<22) return;
if(Math.abs(dx)>Math.abs(dy)) move(dx>0?1:3);
else move(dy>0?2:0);
},{passive:true});
document.getElementById('startBtn').addEventListener('click',()=>{
ovStart.classList.remove('show');
S.level=1; newLevel(true);
});
document.getElementById('nextBtn').addEventListener('click',()=>{
ovWin.classList.remove('show');
newLevel(false);
S.hints=Math.min(S.hints+1,5);
updateHint();
tipLine.textContent = S.level>=6 ? 'Level '+S.level+' β torch mode makes this one brutal. Try it.' : 'Bigger grid, more keys. Keep the wall on your right hand.';
});
document.getElementById('newBtn').addEventListener('click',()=>{
ovWin.classList.remove('show');
newLevel(true);
});
const hintBtn=document.getElementById('hintBtn'), hintLabel=document.getElementById('hintLabel');
function updateHint(){ hintLabel.textContent='Hint ('+S.hints+')'; hintBtn.disabled=S.hints<=0; hintBtn.style.opacity=S.hints<=0?.45:1; }
hintBtn.addEventListener('click',()=>{
if(S.hints<=0 || !S.running) return;
S.hints--;
const goal = S.keys.length ? nearestKey() : S.exit;
S.path=bfs(S.player,goal);
S.pathUntil=performance.now()+2200;
updateHint();
tipLine.textContent = S.keys.length ? 'Green trail points to the closest key.' : 'Green trail points straight to the gate.';
});
function nearestKey(){
const d=bfsDistances(S.player);
let best=S.keys[0], bd=Infinity;
S.keys.forEach(k=>{ if(d[k]>-1 && d[k]<bd){ bd=d[k]; best=k; } });
return best;
}
const torchBtn=document.getElementById('torchBtn');
torchBtn.addEventListener('click',()=>{
S.torch=!S.torch;
torchBtn.classList.toggle('on',S.torch);
tipLine.textContent = S.torch ? 'Torch mode on β you only see what your light touches.' : 'Torch mode off β full maze visible.';
});
/* theme */
const themeBtn=document.getElementById('themeBtn'), themeIcon=document.getElementById('themeIcon');
const MOON='<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/>';
const SUN='<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M19.1 4.9l-1.4 1.4M6.3 17.7l-1.4 1.4"/>';
themeBtn.addEventListener('click',()=>{
const light=document.documentElement.getAttribute('data-theme')==='light';
document.documentElement.setAttribute('data-theme', light?'dark':'light');
themeIcon.innerHTML = light?SUN:MOON;
themeBtn.setAttribute('aria-label', light?'Switch to light mode':'Switch to dark mode');
setTimeout(readColors,60);
});
window.addEventListener('resize',()=>{ resize(); });
/* ============ boot ============ */
S.level=1;
S.cols=9; S.rows=9; S.grid=generate(); S.exit=S.cols*S.rows-1; S.keys=[]; S.running=false;
resize();
const start=cellCenter(0); S.px=S.tx=start.x; S.py=S.ty=start.y;
updateHUD(); updateHint();
ovStart.classList.add('show');
requestAnimationFrame(draw);
})();
</script>
</body>
</html>



