Every time you refresh, a new random maze is generated. Draw your solution from the filled dot to the empty ring — with your cursor, finger, or stylus. Or press A and let the AI trace the path in a hand-drawn amber line.
Built as a minimalist single-screen art piece with Geist fonts, corridor-aware dead-end detection, and a mobile magnifier loupe.
- 🎲 Random maze on every refresh — recursive-backtracker, perfect mazes with exactly one solution
- ✏️ Freehand drawing — mouse, finger, or pen (unified pointer events)
- 🎯 Coverage-based solve detection — ≥90 % of your line inside the solution corridor counts; slight wobbles don't disqualify you
- 🔴 Dead-end detection — stroke turns red when your pen tip has no route to the goal
- ⚪ Off-track feedback — greys out when your path drifts too far
- 🤖 AI auto-solve — press A for a hand-drawn solution with correlated jitter, corner overshoot, and variable-speed animation
- 🔍 Mobile magnifier loupe — zoomed circular view above your finger, crosshair tracks the pen tip at maze edges
- ⏸️ Pause & resume — lift your finger to think; touch back down within a cell to continue the same stroke
- 🖼️ PNG export — save solved mazes at 2× DPI on a clean white background
- 📱 Responsive — 44 px touch targets, horizontal-scroll control strip on narrow screens, safe-area insets
- 🔠 Geist Sans + Geist Mono — minimalist typography throughout
- 📈 SEO ready — full metadata, JSON-LD (WebSite + WebApplication + Person), sitemap, robots.txt, OG + Twitter card images
- ♿ Accessible — semantic HTML, ARIA labels, keyboard shortcuts, respects
prefers-reduced-motion - ⚡ Static export — deploys as pure HTML/CSS/JS, no server needed
| Key | Action |
|---|---|
| N | New maze |
| U | Undo last stroke |
| ⌘Z / Ctrl+Z | Undo |
| C | Clear all strokes |
| A | AI auto-solve |
| E | Export as PNG (after solve) |
Cycle through by tapping the Difficulty button:
| Level | Grid | Cell size on a 640 px board |
|---|---|---|
| Easy | 15 × 15 | 42.6 px |
| Medium | 25 × 25 | 25.6 px |
| Hard | 40 × 40 | 16 px |
| Purpose | |
|---|---|
| Next.js 16 | App Router, static export |
| React 19 | UI |
| TypeScript | Types |
| Tailwind CSS 4 | Utility styling |
| Geist Sans + Mono | Typography |
| Lucide React | Icons |
| GitHub Actions | CI/CD to GitHub Pages |
Requires Node 20+ (Node 24 recommended).
# Clone
git clone https://github.com/Eeman1113/mazeology.git
cd mazeology
# Install
npm install
# Dev server → http://localhost:3000
npm run dev
# Static build → out/
npm run buildAuto-deploys to GitHub Pages on every push to main via .github/workflows/deploy.yml:
- Checkout & Node 24 setup
npm ci+npm run build(static export)- Adds
.nojekyllto skip Jekyll processing - Uploads
out/as a Pages artifact - Deploys via
actions/deploy-pages@v4
Live at eeman1113.github.io/mazeology.
A recursive-backtracker DFS carves a perfect maze — exactly one path between any two cells — in an N × N grid. Start is at (0, 0), end at (N−1, N−1).
Problem. Decide when to fire solved on a freehand drawn line — but be forgiving about small overshoots outside the corridor.
Idea. Pre-compute the unique solution cell set via BFS. As the user draws, sub-sample each segment and count how much of its length falls inside solution cells. When the pen enters the end cell AND the running ratio ≥ 0.9, it's a solve.
Data structures
Set<number> |
Cell indices on the solution path (BFS output) |
stroke.insideLen : number |
Running total of arc length that fell inside solution cells |
stroke.totalLen : number |
Running total of arc length drawn (in-bounds) |
stroke.startedInStart : boolean |
Was the pen-down inside the start cell? |
Pre-processing — solve the maze once.
solveMazePath(maze):
prev[all cells] = -1
prev[startIdx] = startIdx
queue = [startIdx]
while queue not empty:
cur = dequeue
if cur == endIdx: break
for each corridor-open neighbor n of cur:
if prev[n] == -1:
prev[n] = cur
enqueue n
# trace back from end to start
path = []
cur = endIdx
while cur != prev[cur]:
path.push(cur); cur = prev[cur]
path.push(startIdx)
return path.reverse()
solveMaze(maze):
return Set(solveMazePath(maze))
Complexity: O(V + E) where V ≤ 1600 cells, E ≤ 3200 corridor openings on Hard.
Per-segment measurement (runs on every pointer batch).
measureSegment(A, B):
len = distance(A, B)
step = max(2, cellSize / 4)
n = ceil(len / step) // sub-samples
inCount = 0
for i in 0..n-1:
t = (i + 0.5) / n // midpoint of each sub-segment
x = A.x + (B.x - A.x) * t
y = A.y + (B.y - A.y) * t
cellIdx = floor(y/cellSize)*cols + floor(x/cellSize)
if solutionSet.has(cellIdx): inCount++
return {
inside: (inCount / n) * len, // arc length inside corridor
total: len
}
# on each new pointer batch:
insideLen += result.inside
totalLen += result.total
ratio = insideLen / totalLen
Per-batch measurement pipeline.
Running counters — walkthrough of a 5-segment stroke. Ratio is only checked when a segment intersects the end cell.
| seg | Δlen | inside % | insideLen | totalLen | ratio | status |
|---|---|---|---|---|---|---|
| 1 | 48 | 100% | 48.0 | 48 | 1.000 | ✓ on-track |
| 2 | 52 | 90% | 94.8 | 100 | 0.948 | ✓ on-track |
| 3 | 30 | 50% | 109.8 | 130 | 0.845 | ⚠ off-track (grey) |
| 4 | 41 | 100% | 150.8 | 171 | 0.882 | ⚠ still off-track |
| 5 | 22 | 100% | 172.8 | 193 | 0.895 | segment intersects end AABB — not solved (ratio < 0.9) |
Threshold check at solve moment
| condition | required | met? |
|---|---|---|
startedInStart |
true |
✓ |
segment intersects endAABB |
true |
✓ |
insideLen / totalLen ≥ 0.9 |
true |
✗ 0.895 |
→ Keep drawing. The user needs to backtrack onto the corridor to lift the ratio above 0.9 before re-entering the end cell.
Once solved fires, the stroke locks — additional pointer moves on that stroke are dropped (can't "un-solve" by drawing into walls after).
Complexity per pointer batch: O(k) sub-samples where k ≈ raw events (1–20 per frame). Set.has is O(1). Well under one frame at 60 Hz on Hard.
Problem. Detect when the user's pen tip has painted itself into a corner — no reachable path to the goal that doesn't retrace already-drawn cells.
Idea. Track cells the current stroke has legally traversed. When a new cell is committed, BFS from that cell through the corridor graph — treating the trail as blocked. If the goal isn't reachable, flip deadEnd = true (monotonic per stroke).
Data structures per stroke
visitedCells : Set<number> |
Committed cells (corridor-legal path so far) |
lastCommittedCell : number |
Where we are, -1 before first commit |
candidateCell : number |
Cell being considered for commit, -1 if none |
candidateSamples : number |
Residency counter |
wasOOB : boolean |
Last raw sample landed outside the maze |
deadEnd : boolean |
Sticky flag once true |
State machine — how a raw pointer sample updates commitment.
Why each gate matters
- Residency (≥ 2 samples) — a single corner-grazing sample can't contaminate the trail.
- Corridor-legal transition (
areConnected) — if the pen physically crosses a wall to hop to a non-4-neighbor or a wall-blocked neighbor, the candidate is rejected. Prevents the "poisoned trail" where a wall-crossed pocket looks unreachable to BFS. - First-commit exception — when
lastCommittedCell == -1, no anchor exists yet, so the first legal cell commits unconditionally. - Global gate before BFS — dead-end can only fire once
visitedCells.size ≥ 3andtotalLen ≥ 2 × cellSize, so early wobbles at pen-down can't trip it.
Reachability BFS (goalReachable).
goalReachable(maze, tipIdx, visited, goalIdx):
if tipIdx == goalIdx: return true
seen = Uint8Array(cellCount)
seen[tipIdx] = 1
queue = [tipIdx]
while queue not empty:
cur = dequeue
cell = maze.cells[cur]
for each corridor-open neighbor n of cell:
if seen[n]: continue
if n != goalIdx AND visited.has(n): continue # trail blocks re-entry
seen[n] = 1
queue.push(n)
if n == goalIdx: return true
return false
Complexity: O(V + E) per commit — but throttled by the commit gate, so it runs on the order of the stroke's cell count, not per raw sample.
The reachability check — two scenarios side by side.
Complexity summary.
| Op | Cost | Frequency |
|---|---|---|
| Sample-in-cell math | O(1) |
every raw sample |
| Candidate residency | O(1) |
every raw sample |
areConnected gate |
O(1) |
every candidate promotion |
goalReachable BFS |
O(V + E) |
only on new commits after the global gate |
Per-stroke on Hard: ~50 commits × ~1600 cells ≈ 80 k neighbor visits over the whole stroke. Trivial.
Problem. Trace the true solution in ~3 s with a stroke that feels hand-drawn — not a ruler.
Idea. Take the unique solution cell sequence. Place a waypoint at each cell center, add correlated jitter so the line drifts organically. At sharp corners, push the Bézier control past the corner (overshoot) and bias the outgoing midpoint inside the turn (inside-cut). Then animate the SVG reveal on a variable-speed clock — pen slows at corners, races down straights.
Stage 1 — Solution path.
solveMazePath(maze) returns [c₀, c₁, …, cₙ] from start to end (BFS + predecessor trace).
Stage 2 — Correlated jitter (per-run seeded).
Every autoSolve() picks a fresh 32-bit seed → mulberry32 PRNG. Downstream everything (phases, amplitudes, walk noise, ±10 % duration) derives from it. Same maze, same button, different-looking stroke each time.
For each waypoint Pᵢ (cell center), displacement is a sum of three correlated components:
dxᵢ = driftAmp · sin(i · fA + φA) low-freq drift
+ tremorAmp · sin(i · fX + φC) high-freq tremor
+ walkX smoothed random walk
walkX ← walkX · 0.82 + (rng()−0.5) · 0.04 · cellSize
The walk is a leaky integrator (coefficient 0.82) — successive samples inherit ~82 % of the previous walk, giving correlated motion instead of independent noise. Same construction for dyᵢ with different frequencies/phases so it doesn't loop into a diagonal.
Hard cap: |(dxᵢ, dyᵢ)| ≤ 0.22 × cellSize. Safety rail — the line never crosses a wall. First and last waypoints get zero jitter — starts and ends stay decisive.
Independent random vs correlated jitter — both at the same amplitude. The independent series jumps around each waypoint. The correlated series inherits ~82 % of the previous walk each step, drifting smoothly like a hand.
Stage 3 — Corner geometry: overshoot + inside-cut.
At interior waypoint Pᵢ, compute unit vectors of incoming edge v̂ᵢₙ and outgoing edge v̂ₒᵤₜ. If turn angle θ = acos(v̂ᵢₙ · v̂ₒᵤₜ) > 60°:
overshoot = (0.18 + rng() * 0.12) · cellSize
ctrl = Pᵢ + v̂ᵢₙ · overshoot # push control past corner
mid = midpoint(Pᵢ, Pᵢ₊₁)
+ normalize(v̂ᵢₙ − v̂ₒᵤₜ) · 0.10 · cellSize # bias endpoint inward
The path segment through Pᵢ becomes a quadratic Bézier Q(ctrl, mid).
Corner geometry — naive vs human turn (with actual Bézier curves and vectors, not just words).
- Naive (ctrl = Pᵢ, mid = geometric midpoint): the curve arrives, halts, and turns exactly 90°. Reads as robotic.
- Human (control point pushed along
v̂ᵢₙ, endpoint biased along the inner bisector): the curve overshoots the corner slightly, then cuts back inside — the signature you see when a real hand traces a turn.
Stage 4 — Variable-speed animation.
The SVG path has stroke-dasharray = totalLength, initial stroke-dashoffset = totalLength (invisible). A requestAnimationFrame loop drives dashoffset toward 0. But not linearly — speed varies with local curvature.
Setup (once, on animation start):
1. Sample the built path at N = 220 arc-length points via getPointAtLength.
2. For each sample i, compute local turn angle from tangents:
inV = samples[i] − samples[i-1]
outV = samples[i+1] − samples[i]
angle[i] = acos(normalize(inV) · normalize(outV))
3. Map angle → speed multiplier:
norm = clamp01(angle[i] / (π/2))
ss = smoothstep(norm) # 3x² − 2x³
speed[i] = lerp(1.0, 0.35, ss) # 1.0 straight → 0.35 at 90°
speed[i] += 0.06 · sin(i · 0.15) # subtle breathing
4. Build cumulative time:
dt[i] = 1 / speed[i]
cumT[i] = Σ dt[j] for j < i # normalized to [0, 1]
Per-frame draw:
t = min(1, (now − start) / durationMs)
easedT = easeInOutQuint(t) # outer envelope
lo, hi = binarySearch(cumT, easedT) # find bracket
frac = (easedT − cumT[lo]) / (cumT[hi] − cumT[lo])
arcFrac = (lo + frac) / (N − 1)
drawn = arcFrac · totalLength
strokeDashoffset = totalLength − drawn
Speed profile along a typical solution path — pen visibly slows at corners, races down straights.
Cumulative-time map — the y-axis is how much of the animation duration must elapse to have drawn this much arc length. Steep segments (corners) advance slowly per unit of time; shallow segments (straights) advance quickly. The rAF loop binary-searches cumT for the eased t, which gives the target arc length for this frame.
Complexity summary.
| Stage | Cost |
|---|---|
| BFS solve path | O(V + E) once per maze |
| Waypoint jitter | O(N) cells |
| Path build (Bézier chain) | O(N) |
getPointAtLength × 220 samples |
O(N_samples) |
| Speed map + cumulative | O(N_samples) |
| Per-frame update | O(log N_samples) binary search |
For a Medium maze (~50 cells solution), all setup is well under 1 ms; per-frame cost is ~5 µs. Silky at 60 Hz.
On touch pointerdown, a small circular magnifier appears above the finger showing a 2.75× zoomed view around the pen tip via a second SVG with a computed sub-viewBox. The viewBox clamps to maze bounds; the crosshair inside the loupe tracks the actual pen tip's position within the clamped viewBox — so it stays glued to the head of your drawn line at the maze edges.
If your last active stroke isn't solved or dead-ended, re-touching within ~1 cell of where you lifted extends that stroke instead of starting fresh. All state (visited cells, coverage, startedInStart, dead-end status) persists.
mazeology/
├── app/
│ ├── layout.tsx # Metadata, JSON-LD, fonts
│ ├── page.tsx # Header, hidden SEO block, footer
│ ├── globals.css # Design tokens + animations
│ ├── icon.svg # Favicon (also the header mark)
│ ├── manifest.ts # PWA manifest
│ ├── robots.ts # /robots.txt
│ ├── sitemap.ts # /sitemap.xml
│ ├── opengraph-image.tsx # 1200×630 OG image
│ ├── twitter-image.tsx # 1200×600 Twitter card
│ ├── components/
│ │ ├── MazeBoard.tsx # Main interactive maze board
│ │ └── MazeMark.tsx # Header wordmark glyph
│ └── lib/
│ └── maze.ts # Generator, BFS solver, cell utils
├── public/
│ └── humans.txt # Credits
└── .github/workflows/
└── deploy.yml # Auto-deploy on push to main
Made by Eeman Majumder.
Repo: github.com/Eeman1113/mazeology