Number Trail is a path-filling puzzle built entirely in plain HTML, CSS, and a single JavaScript module with no frameworks and no bundler. The player draws one continuous path that visits every cell of a square grid exactly once, touching numbered clue cells in ascending order.
You can play the game here:
The core constraint is a Hamiltonian path problem: find a path through a graph that visits every vertex exactly once. The numbered clues force the path through fixed waypoints in a fixed sequence, which dramatically shrinks the search space and makes the puzzle tractable.
This post walks through the full implementation: puzzle file format, board rendering, wall gradients, drag interaction, path validation, and random puzzle generation with Warnsdorff’s heuristic.
A Hamiltonian path in a graph is a path that visits every vertex exactly once. A Hamiltonian cycle visits every vertex and returns to the starting vertex. Both are named after William Rowan Hamilton, who studied the problem of finding such cycles on the vertices of a dodecahedron in the 1850s.
Deciding whether a Hamiltonian path exists in a given graph is NP-complete. Richard Karp listed it among his 21 NP-complete problems in 1972. This means no polynomial-time algorithm is known for the general case, and all known exact solvers scale exponentially in the worst case.
Grid graphs
A grid graph places vertices at integer coordinates (i, j) and connects horizontally and vertically adjacent pairs with edges. A full rectangular grid always has a Hamiltonian path: traverse the first row left to right, the second right to left, and alternate direction for each subsequent row. This is known as boustrophedon (snake) traversal and is the fallback in the puzzle generator.
Once walls are introduced, some edges are removed and the graph becomes a proper subgraph of the grid. Itai, Papadimitriou, and Szwarcfiter (1982) proved that deciding whether a Hamiltonian path exists in a grid graph with holes is NP-complete in general.[04] The puzzle sidesteps this by placing walls only on edges that are not part of the known solution path, so solvability is guaranteed by construction rather than decided at runtime.
Warnsdorff’s rule and the Knight’s Tour
Warnsdorff’s rule originates with the Knight’s Tour: find a sequence of knight moves that visits every square of a chessboard exactly once. The knight’s tour is itself a Hamiltonian path problem on the knight’s graph, where vertices are board squares and edges connect squares a knight move apart.
H.C. von Warnsdorff described his heuristic in 1823: from the current square, always move to the unvisited square that has the fewest onward unvisited moves. The intuition is that vertices with few exits become harder to reach as the path grows, so they should be visited sooner. Delaying them risks creating isolated vertices that cannot be reached from the end of the path.
The rule generalizes to any graph. At each step, rank the unvisited neighbors by their degree in the subgraph induced by all remaining unvisited vertices, then extend the path to the neighbor with the minimum degree. Ties are broken randomly to produce variety across runs.
Warnsdorff’s rule is a greedy heuristic, not a complete algorithm. It can fail: on small grids or from certain start positions, it may reach a dead end with unvisited vertices remaining. The generator handles this by trying up to four corner starting positions and several random seeds before falling back to the snake path. In practice the rule succeeds on nearly all grid sizes encountered in the puzzles (5x5 through 10x10).
Each puzzle is a plain-text file with three sections: metadata key-value pairs, a grid block, and a walls block.
id: puzzle_042
title: Crossroads
description: Eight walls. Four clues. A tight squeeze.
grid:
1 . . . . . .
. . . . . . .
. 3 . . . . .
. . . . . . .
. . . . 2 . .
. . . . . . .
. . . . . . 4
walls:
2,1 2,2
4,3 5,3
0,5 1,5
3,4 4,4
The grid is a space-separated square matrix. Dots are empty cells; integers are clue cells that the path must visit in ascending order. Walls are listed as pairs of adjacent row,col coordinates. A wall on 2,1 2,2 blocks movement between row 2 column 1 and row 2 column 2.
This format is intentionally simple: no binary encoding, no JSON, no schema. Puzzles can be authored in any text editor, checked into version control, and diffed line by line.
The parser is a small state machine. It reads the file line by line, switches between meta, grid, and walls modes on the section headers, and rejects anything that looks malformed before any rendering happens.
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue; // skip blank lines and comments
if (/^grid:$/i.test(line)) { section = "grid"; continue; }
if (/^walls:$/i.test(line)) { section = "walls"; continue; }
if (section === "meta") {
const match = line.match(/^([a-z0-9_-]+)\s*:\s*(.+)$/i);
if (match) meta[match[1].toLowerCase()] = match[2].trim();
} else if (section === "grid") {
gridLines.push(line);
} else {
wallLines.push(line);
}
}
After collecting the lines, the grid is validated to be square and all clue numbers from 1 to max must be present and unique. Walls are checked to reference in-bounds cells that are adjacent.
Walls are stored as a Set<string> where each key is a canonical edge identifier:
function wallKey(a, b) {
const fa = `${a.r},${a.c}`;
const fb = `${b.r},${b.c}`;
// canonical order so A->B and B->A produce the same key
return fa < fb ? `${fa}|${fb}` : `${fb}|${fa}`;
}
The board is a CSS grid whose column and row counts are set by a single CSS custom property:
.board {
--size: 5; /* overridden in JS to match the loaded puzzle */
display: grid;
grid-template-columns: repeat(var(--size), 1fr);
grid-template-rows: repeat(var(--size), 1fr);
border: 3px solid var(--wall);
background: var(--wall); /* 1px gap between cells shows through as grid lines */
gap: 1px;
}
Setting background: var(--wall) on the container and gap: 1px means the grid lines between cells are rendered as the 1-pixel gap showing through the container background. No borders on individual cells are needed for the grid lines themselves.
Cell font size scales with the grid size using a fluid clamp:
.cell {
/* dividing by --size keeps numbers proportional as the grid grows */
font-size: clamp(1rem, calc(18vw / var(--size)), 2.25rem);
}
Dividing by --size keeps the clue numbers filling roughly the same proportion of each cell as the puzzle gets larger.
Each cell div is rendered from scratch when a new puzzle loads. Clue cells get a <span> child for the circular badge; every cell also gets four <i> children for the path line arms (discussed below).
Path line arms
The path is rendered as a connected line through cells using a dot in the cell center plus four directional arms that extend toward adjacent cells in the path.
.cell.in-path::before {
left: 50%; top: 50%;
width: 42%; height: 42%; /* wide enough to overlap and conceal the arm endpoints */
border-radius: 999px;
background: var(--path);
transform: translate(-50%, -50%);
}
/* arms span 50% of the cell: from the edge to the center dot */
.cell .line-arm.n,
.cell .line-arm.s {
left: 50%; width: 42%; height: 50%;
transform: translateX(-50%);
}
.cell .line-arm.e,
.cell .line-arm.w {
top: 50%; height: 42%; width: 50%;
transform: translateY(-50%);
}
Each arm is 50% tall (or wide), so it runs from the cell edge to the center. The dot is 42% wide and centered, which is wide enough to cover the arm endpoints and produce a seamless line.
Which arms are visible is controlled by data-line-* attributes set during updateBoard():
function setLineData(cellEl, cell, index) {
delete cellEl.dataset.lineN;
// ...clear all four directions...
const neighborsInPath = [
state.path[index - 1], // predecessor
state.path[index + 1], // successor
].filter(Boolean);
for (const neighbor of neighborsInPath) {
const dr = neighbor.r - cell.r;
const dc = neighbor.c - cell.c;
// draw an arm toward each neighbor in the path
if (dr === -1) cellEl.dataset.lineN = "true";
else if (dr === 1) cellEl.dataset.lineS = "true";
else if (dc === -1) cellEl.dataset.lineW = "true";
else if (dc === 1) cellEl.dataset.lineE = "true";
}
}
Each cell looks at its predecessor and successor in state.path and draws arms toward both. This makes backtracking and extending both update correctly: the entire board redraws on every state change, but since there are no layout operations involved (just class and data-attribute toggles), this is fast enough to be imperceptible.
Drag and click
Path drawing supports two input modes: drag (mouse or touch) and click. Both converge on tryVisit(cell, mode).
For drag, mousedown on a cell starts a drag, then mousemove on the window calls visitPoint(clientX, clientY). That maps screen coordinates back to a grid cell:
function cellFromPoint(clientX, clientY) {
const rect = boardEl.getBoundingClientRect();
return {
// normalize pointer to [0,1] within the board, then scale to row/column index
r: Math.min(size - 1, Math.floor(((clientY - rect.top) / rect.height) * size)),
c: Math.min(size - 1, Math.floor(((clientX - rect.left) / rect.width) * size)),
};
}
Dividing by rect.height and rect.width normalizes the pointer position to [0, 1], then multiplying by size gives the grid row and column. Clamping to size - 1 handles rare cases where the pointer is exactly on the border.
Touch is handled identically via touchmove, with event.changedTouches[0] providing the coordinates.
After a drag that moved at least one cell, the next click event on the cell under the pointer would otherwise double-count the final cell. The fix is a one-shot suppressClick flag:
function endDrag() {
if (state.dragging && state.dragMoved) {
state.suppressClick = true;
// defer reset past the click event that fires after mouseup
window.setTimeout(() => { state.suppressClick = false; }, 0);
}
state.dragging = false;
}
The setTimeout(..., 0) defers the flag reset until after the click event fires in the same event loop turn.
Backtracking
If the user revisits a cell already in the path, tryVisit truncates rather than refusing:
const existingIndex = state.path.findIndex((step) => sameCell(step, cell));
if (existingIndex !== -1) {
// drag only allows one-step backtrack to prevent accidental large truncations
if (mode === "click" || existingIndex === state.path.length - 2) {
truncatePath(existingIndex);
recomputeClueIndex(); // re-scan path to find the highest clue reached
} else {
setStatus("The path cannot cross itself.", "bad");
}
return;
}
During drag the user can only backtrack by one step at a time (the length - 2 check). This avoids accidental large truncations when dragging past an existing cell. Click allows any backtrack target, which is more intentional.
recomputeClueIndex re-scans the truncated path to find the highest clue number reached, keeping the validation logic correct after any backtrack.
The puzzle is solved when the path fills every cell and the clues appear in ascending order. These are checked in validatePath:
function validatePath() {
let expected = 1;
for (const cell of state.path) {
const clue = cellClue(cell);
if (clue === 0) continue; // empty cell, not a clue
if (clue !== expected) {
return { valid: false, message: "Clues are out of order." };
}
expected += 1;
}
// expected now equals maxClue + 1 if every clue was visited
if (expected <= state.puzzle.maxClue) {
return { valid: false, message: "Not all clue cells were reached." };
}
return { valid: true };
}
This only runs when the path length equals the total cell count. Partial paths are not validated. The incremental state.clueIndex maintained during interaction is for status messages, not for solving; validatePath re-checks the full path from scratch to be safe.
The game includes a random puzzle generator that produces valid puzzles on the fly using Warnsdorff’s heuristic.
Hamiltonian path via Warnsdorff’s heuristic
Warnsdorff’s rule is a heuristic for finding Hamiltonian paths on grids. At each step it greedily moves to the unvisited neighbor with the fewest onward unvisited neighbors. This tends to avoid dead ends that would require backtracking.
// Attempt a Hamiltonian path on a size×size grid starting at (0,0) using
// Warnsdorff's heuristic: at each step prefer the neighbor with the fewest
// onward moves. Returns the path as [[r,c], …] with size x size entries,
// or null if the walk hits a dead end (caller is expected to retry).
function _warnsdorffPath(size) {
// vis[r][c] = 1 once cell (r,c) has been added to the path
const vis = Array.from({ length: size }, () => new Uint8Array(size));
// Warnsdorff degree: number of still-unvisited neighbors reachable from (r,c).
// A lower degree means the neighbor is harder to reach later, so we visit it
// sooner to avoid cutting off parts of the grid.
const deg = (r, c) => _RNG_DIRS.reduce((n, [dr, dc]) => {
const nr = r + dr, nc = c + dc; // candidate neighbour coordinate
return n + (
nr >= 0 && nc >= 0 && // not off the top or left edge
nr < size && nc < size && // not off the bottom or right edge
!vis[nr][nc] // not yet visited
? 1 : 0 // count it if all three hold
);
}, 0); // accumulator starts at 0 → result is in [0, 4]
// Start the path at the top-left corner
const path = [[0, 0]];
vis[0][0] = 1;
// Extend the path one cell at a time until every cell has been visited
while (path.length < size * size) {
const [r, c] = path[path.length - 1];
// Build a candidate list: for each valid unvisited neighbour compute its
// Warnsdorff degree and attach a random tiebreaker so equal-degree cells
// are chosen in a different order on each call, keeping puzzles varied.
const nexts = _RNG_DIRS
.map(([dr, dc]) => [r + dr, c + dc])
.filter(([nr, nc]) => nr >= 0 && nc >= 0 && nr < size && nc < size && !vis[nr][nc])
.map(([nr, nc]) => [deg(nr, nc), Math.random(), nr, nc])
.sort((a, b) => a[0] - b[0] || a[1] - b[1]); // minimum degree first
// random breaks ties
// No valid moves — the heuristic walked into a dead end; signal failure
if (!nexts.length) return null;
// Commit to the best candidate (lowest degree, random tiebreak)
const [,, nr, nc] = nexts[0];
vis[nr][nc] = 1;
path.push([nr, nc]);
}
return path;
}
The Math.random() tiebreaker is crucial: when multiple neighbors share the minimum degree, a random one is chosen. This keeps puzzles varied across calls. If Warnsdorff fails after 8 attempts (it can get stuck on smaller grids), the generator falls back to a boustrophedon (snake) traversal, which always succeeds.
Placing clues
Clue cells are sampled at roughly equal intervals along the path:
// Between 15% and 35% of cells become clues
const nClues = Math.max(3, Math.round(total * (0.15 + Math.random() * 0.20)));
const cidxs = [];
for (let i = 0; i < nClues; i++) {
// evenly spaced indices along the path
const idx = Math.round(i * (total - 1) / (nClues - 1));
cidxs.push(idx);
}
This distributes clues evenly from the start to the end of the Hamiltonian path, so they always appear in ascending order along the solution.
Placing walls
Walls are added only on edges that are not part of the solution path. This guarantees the puzzle always has at least one valid solution: the original Hamiltonian path can still be traced regardless of which non-path edges are blocked.
// Record every edge that the solution path uses.
// These can never become walls
const pathEdgeSet = new Set();
for (let i = 0; i + 1 < path.length; i++) {
pathEdgeSet.add(wallKey({ r: path[i][0], c: path[i][1] },
{ r: path[i+1][0], c: path[i+1][1] }));
}
// Collect candidate non-path edges, shuffle, take a random prefix
const wallPool = [];
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++)
// only right and down avoids counting each edge twice
for (const [dr, dc] of [[0, 1], [1, 0]]) {
const nr = r + dr, nc = c + dc;
if (nr < size && nc < size) {
const k = wallKey({ r, c }, { r: nr, c: nc });
// only non-solution edges are eligible
if (!pathEdgeSet.has(k)) wallPool.push(k);
}
}
A Fisher-Yates shuffle on wallPool followed by taking the first nWalls elements gives uniformly random wall placement.
Each puzzle has a stable id field from its metadata. Scores are stored in localStorage under numberTrail.bestTimes.<id>, keyed to that id. Up to five scores per puzzle are retained, sorted by time with perfect (no-backtrack) runs given preference at equal times.
Random puzzles get an id of random-<timestamp>, which means their scores are never persisted. Two random puzzles with the same timestamp would never be the same layout, so keying scores to the id would be meaningless.
The 100 puzzles were generated with the script below. It uses the same Warnsdorff path algorithm described above and the same wall-placement guarantee: walls only on non-solution edges. Every puzzle has at least one valid solution by construction.
#!/usr/bin/env python3
"""Generate 100 Number Trail puzzles with increasing complexity."""
import random
import os
PUZZLES_DIR = "demos/number_trail/assets/puzzles"
DIRS = [(0,1),(1,0),(0,-1),(-1,0)]
def warnsdorff(size, start, seed):
# seeded RNG so puzzles are reproducible but varied across seeds
rng = random.Random(seed)
visited = [[False]*size for _ in range(size)]
path = [start]
visited[start[0]][start[1]] = True
def deg(r, c):
# count unvisited neighbors — the Warnsdorff degree
return sum(
1 for dr, dc in DIRS
if 0 <= r+dr < size and 0 <= c+dc < size and not visited[r+dr][c+dc]
)
while len(path) < size*size:
r, c = path[-1]
nexts = []
for dr, dc in DIRS:
nr, nc = r+dr, c+dc
if 0 <= nr < size and 0 <= nc < size and not visited[nr][nc]:
nexts.append((deg(nr,nc), rng.random(), nr, nc)) # random float breaks degree ties
if not nexts:
return None # dead end; caller tries other starts/seeds
nexts.sort()
_, _, nr, nc = nexts[0]
visited[nr][nc] = True
path.append((nr, nc))
return path
def snake_path(size):
"""Fallback: guaranteed Hamiltonian boustrophedon path from (0,0)."""
path = []
for r in range(size):
# alternate direction each row to form a continuous snake
cols = range(size) if r % 2 == 0 else range(size-1, -1, -1)
for c in cols:
path.append((r, c))
return path
def find_path(size, seed):
# try all four corners first; corner starts tend to succeed more reliably
corners = [(0,0),(0,size-1),(size-1,0),(size-1,size-1)]
for start in corners:
p = warnsdorff(size, start, seed)
if p:
return p
# if every corner fails, vary the seed up to 30 times before giving up
for extra in range(30):
p = warnsdorff(size, (0,0), seed + extra + 1)
if p:
return p
return snake_path(size) # always succeeds on a full rectangular grid
def get_clue_indices(path_len, n_clues):
n_clues = max(2, min(n_clues, path_len))
seen = set()
result = []
for i in range(n_clues):
# evenly space clues from path start to path end
idx = round(i * (path_len-1) / (n_clues-1))
if idx not in seen:
seen.add(idx)
result.append(idx)
return sorted(result)
def make_grid_str(path, clue_indices, size):
grid = [['.']*size for _ in range(size)]
for clue_num, pidx in enumerate(clue_indices, 1):
r, c = path[pidx]
grid[r][c] = str(clue_num)
return '\n'.join(' '.join(row) for row in grid)
def pick_walls(path, size, n, seed):
if n == 0:
return []
rng = random.Random(seed + 9999) # offset avoids correlation with the path seed
# record every edge the solution path uses; these cannot become walls
path_edges = set()
for i in range(len(path)-1):
a, b = path[i], path[i+1]
path_edges.add((min(a,b), max(a,b)))
# enumerate only right and down neighbors to avoid counting each edge twice
candidates = []
for r in range(size):
for c in range(size):
for dr, dc in [(0,1),(1,0)]:
nr, nc = r+dr, c+dc
if 0 <= nr < size and 0 <= nc < size:
edge = (min((r,c),(nr,nc)), max((r,c),(nr,nc)))
if edge not in path_edges:
candidates.append(((r,c),(nr,nc)))
rng.shuffle(candidates)
return candidates[:n]
def make_filename(num, title):
slug = (title.lower()
.replace(' ','_').replace("'",'')
.replace(',','').replace('.','').replace('/',''))
return f"{num:03d}_{slug}.txt"
def make_puzzle(num, size, n_clues, n_walls, seed, title, desc):
path = find_path(size, seed)
cidxs = get_clue_indices(len(path), n_clues)
grid_str = make_grid_str(path, cidxs, size)
walls = pick_walls(path, size, n_walls, seed)
wall_lines = '\n'.join(f"{r1},{c1} {r2},{c2}" for (r1,c1),(r2,c2) in walls)
pid = f"puzzle_{num:03d}"
content = (
f"id: {pid}\n"
f"title: {title}\n"
f"description: {desc}\n"
f"\ngrid:\n{grid_str}\n"
f"\nwalls:\n{wall_lines}\n"
)
return content
# (num, size, n_clues, n_walls, seed, title, description)
PUZZLE_SPECS = [
(1, 5, 10, 0, 101, "First Steps", "A 5x5 starter with ten guides. No walls."),
(2, 5, 9, 0, 102, "Stepping Out", "Follow nine guideposts across the small grid."),
# ... 98 more entries following the same pattern ...
(100,10, 5,14, 610, "Ultimate Trail","Five clues. Fourteen walls. The hardest."),
]
if __name__ == "__main__":
os.makedirs(PUZZLES_DIR, exist_ok=True)
generated = []
for spec in PUZZLE_SPECS:
num, size, n_clues, n_walls, seed, title, desc = spec
fname = make_filename(num, title)
content = make_puzzle(num, size, n_clues, n_walls, seed, title, desc)
fpath = os.path.join(PUZZLES_DIR, fname)
with open(fpath, 'w') as f:
f.write(content)
generated.append(fname)
print(f"[{num:3d}/100] {fname}")
# write list.txt so the runtime knows which files to load
list_path = os.path.join(PUZZLES_DIR, "list.txt")
with open(list_path, 'w') as f:
for fname in generated:
f.write(fname + '\n')
Each entry in PUZZLE_SPECS is a tuple of (number, size, clues, walls, seed, title, description). The seed is fixed per puzzle so regenerating the script always produces identical files.
The puzzles span five grid sizes (5x5 through 10x10) with increasing clue density and wall count:
| Range | Size | Clue range | Walls |
|---|---|---|---|
| 001-020 | 5x5 | 10 to 3 | 0-8 |
| 021-040 | 6x6 | 12 to 4 | 0-10 |
| 041-060 | 7x7 | 14 to 4 | 0-12 |
| 061-080 | 8x8 | 16 to 5 | 0-14 |
| 081-100 | 9x9-10x10 | 18 to 5 | 0-14 |
Every puzzle has at least one valid solution by construction. The puzzle list is a plain list.txt file loaded at runtime. Adding a new puzzle requires only dropping a new .txt file in the puzzles directory and adding one line to list.txt, with no code changes required.