DEV Community

Anson Chan
Anson Chan

Posted on

Building a Slitherlink Puzzle Engine in Rust + WebAssembly

Most puzzle websites generate puzzles with simple random placement. We built a proper constraint-based engine in Rust that generates, solves, and rates Slitherlink puzzles on a 10-level difficulty scale — then compiles to WebAssembly for browser use.

Why Rust?

Slitherlink generation is computationally expensive. A 10×10 grid has 220 possible edges. The engine needs to:

  1. Generate a valid closed loop
  2. Place number clues
  3. Verify the puzzle has exactly one solution
  4. 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 10-50x speedup and the option to compile to WASM 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:

·───·   ·───·───·
│       │       │
·   ·───·   ·   ·
│   │       │   │
·   ·   ·───·   ·
│   │   │       │
·───·   ·───·───·
Enter fullscreen mode Exit fullscreen mode

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 │
·───·   ·───·───·
Enter fullscreen mode Exit fullscreen mode

Then selectively remove some numbers to create the puzzle. More numbers removed = potentially harder puzzle, 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:

  1. Constraint propagation: Apply all known patterns (corner rules, adjacent 3-3, vertex degree) exhaustively
  2. Backtracking: When propagation stalls, pick an undecided edge, try both states (line/no-line), propagate each branch
  3. Uniqueness check: If both branches lead to valid solutions, the puzzle has multiple solutions — reject it
solve(puzzle):
    propagate all constraints
    if contradiction → no solution
    if all edges decided → one solution found
    pick undecided edge E
    try E = line:
        solutions_a = solve(puzzle + E=line)
    try E = no_line:
        solutions_b = solve(puzzle + E=no_line)
    return solutions_a + solutions_b
    // if total > 1, puzzle is ambiguous — reject
Enter fullscreen mode Exit fullscreen mode

Phase 4: Difficulty Rating (10 Levels)

This is where it gets interesting. 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 = the puzzle's difficulty.

fn rate_puzzle(puzzle: &Puzzle) -> u8 {
    let mut max_level = 1;
    let mut state = SolverState::new(puzzle);

    loop {
        // Try techniques from simplest to most complex
        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
}
Enter fullscreen mode Exit fullscreen mode

Compiling to WebAssembly

The Rust engine compiles to WASM via wasm-pack:

wasm-pack build --target web --release
Enter fullscreen mode Exit fullscreen mode

The resulting .wasm binary is ~200KB 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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: 200KB gzipped
  • Solve time (WASM, 10×10): < 100ms
  • Database: 3000+ puzzles, 1.9MB total in D1

Try It

Play the puzzles at slitherlinks.com — the difficulty ratings are real. Level 1 feels gentle; Level 8 will make you sweat.

Top comments (0)