Building a Slitherlink Puzzle Engine in Rust + WebAssembly
Source: Dev.to
Why Rust?
Slitherlink generation is computationally expensive. A 10×10 grid has 220 possible edges. The engine needs to:
- Generate a valid closed loop
- Place number clues
- Verify the puzzle has exactly one solution
- Rate the difficulty based on required solving techniques
JavaScript couldn’t handle this efficiently. Java was our first attempt (it worked), but Rust gave us a 10–50× speedup and the option to compile to WebAssembly for client‑side solving.
The Architecture
Phase 1: Loop Generation
Start with an empty grid. Build a random Hamiltonian‑like path that forms a single closed loop:
·───· ·───·───·
│ │ │
· ·───· · ·
│ │ │ │
· · ·───· ·
│ │ │ │
·───· ·───·───·
The algorithm uses randomized DFS with backtracking. Key constraint: every vertex touched by the loop must have exactly degree 2 (one line enters, one leaves).
Phase 2: Clue Placement
Count the edges around each cell to generate numbers:
·───· ·───·───·
│ 2 1 │ 2 3 │
· ·───· · ·
│ 2 │ 2 2 │ 2
· · ·───· ·
│ 1 │ 1 │ 2 2 │
·───· ·───·───·
Then selectively remove some numbers to create the puzzle. More numbers removed can make the puzzle harder, but difficulty depends on which numbers remain, not just how many.
Phase 3: Uniqueness Verification
This is the expensive part. The solver must confirm exactly one solution exists by:
- Constraint propagation – apply all known patterns (corner rules, adjacent 3‑3, vertex degree) exhaustively.
- Backtracking – when propagation stalls, pick an undecided edge, try both states (line / no‑line), and propagate each branch.
- Uniqueness check – if both branches lead to valid solutions, the puzzle has multiple solutions and is rejected.
fn solve(puzzle: &Puzzle) -> usize {
propagate_all_constraints(puzzle);
if puzzle.is_contradictory() {
return 0; // no solution
}
if puzzle.is_fully_decided() {
return 1; // one solution found
}
let e = puzzle.pick_undecided_edge();
let mut count = 0;
// try edge = line
let mut p1 = puzzle.clone();
p1.set_edge(e, true);
count += solve(&p1);
// try edge = no line
let mut p2 = puzzle.clone();
p2.set_edge(e, false);
count += solve(&p2);
count // total solutions
}
If the total exceeds 1, the puzzle is ambiguous and discarded.
Phase 4: Difficulty Rating (10 Levels)
We don’t just count clues—we measure what techniques are required to solve:
| Level | Techniques Required |
|---|---|
| 1‑2 | Corner rules, 0‑elimination only |
| 3‑4 | Adjacent number patterns (3‑3, 3‑0) |
| 5‑6 | Vertex degree rule, edge patterns |
| 7‑8 | Loop closure logic, inside/outside reasoning |
| 9‑10 | Multi‑step bifurcation (trial and error) |
The rating engine simulates a human solver: apply techniques in order of complexity, track which level of technique was needed to make progress. The highest technique level used becomes the puzzle’s difficulty.
fn rate_puzzle(puzzle: &Puzzle) -> u8 {
let mut max_level = 1;
let mut state = SolverState::new(puzzle);
loop {
if state.apply_corner_rules() { max_level = max(max_level, 1); continue; }
if state.apply_adjacent_patterns() { max_level = max(max_level, 3); continue; }
if state.apply_vertex_degree() { max_level = max(max_level, 5); continue; }
if state.apply_loop_closure() { max_level = max(max_level, 7); continue; }
if state.apply_bifurcation() { max_level = max(max_level, 9); continue; }
break;
}
max_level
}
Compiling to WebAssembly
The Rust engine compiles to WASM via wasm-pack:
wasm-pack build --target web --release
The resulting .wasm binary is ~200 KB and runs in the browser for:
- Client‑side puzzle validation (anti‑cheat)
- Hint generation
- Solution verification
For batch generation (we pre‑generate 3000+ puzzles), we use the native binary:
./slitherlink-engine gen --grid-size 10 --level 6 --count 100
This outputs JSON to stdout — one puzzle per line with grid_size, clues, solution, and difficulty rating.
Production Pipeline
Rust engine (batch generate)
→ JSON puzzles
→ Import to Cloudflare D1 database
→ CF Worker API serves puzzles
→ Next.js frontend renders with Phaser 3
The database holds 3000+ puzzles across 5 grid sizes (5×5, 7×7, 8×8, 10×10, 12×12) and 10 difficulty levels. The API supports random puzzle selection filtered by size and difficulty, daily challenges, and leaderboards.
Numbers
- Generation speed: ~50 puzzles/minute for 10×10 Level 5‑6
- WASM bundle: 200 KB gzipped
- Solve time (WASM, 10×10): . The difficulty ratings are real: Level 1 feels gentle; Level 8 will make you sweat.