Projects

↩ Warp back to the vault ↩ View all projects            

Praerie's Sudoku: Case Study

Overview

Praerie's Sudoku is a single-player web Sudoku built around a vintage typewriter aesthetic, designed for solo play at whatever pace suits the player. It runs entirely in the browser with no account or backend, with every puzzle generated client-side by a from-scratch backtracking algorithm and verified to have exactly one valid solution before it reaches the player. Four difficulties (Normal, Challenging, Expert, Diabolical) vary both clue count and strike budget, with Normal and Challenging giving three strikes, Expert two, and Diabolical only one, so the more difficult tiers tighten on two axes at once.

 

Stack: React, Vite, HTML, CSS

Screenshots:

Backtracking Algorithm

The heart of the project is its puzzle generator and the validator that lets it produce uniquely-solvable boards on demand. Both phases (fill the board, then carve out clues) share the same recursive backtracking core.

 

Phase I: Building a Solved Board

 

generatePuzzleRecursive walks an empty 9×9 grid cell by cell in row-major order. At each cell it shuffles the digits 1 through 9. The shuffle is what gives every generated board its character. Without it, the algorithm would always emit the same canonical solution. It then tries each digit in turn. A small isValid helper checks the row, column, and 3×3 subgrid for conflicts; if a digit slots in cleanly, the algorithm places it and recurses to the next cell. If the recursion returns false, the algorithm rolls the placement back and tries the next digit. If every digit fails, the function returns false and lets the previous level decide what to do. With the random shuffle in place, this typically completes in single-digit milliseconds because the search tree is heavily constrained as cells fill in.

 

Phase II: Carving Out the Puzzle

 

Once a complete board exists, hideNumbers decides which cells to clear so the player gets a puzzle instead of an answer. The naïve approach (pick N random cells, blank them, check uniqueness, and retry from scratch on failure) works for easy puzzles but stalls hard on more aggressive difficulties, since the rejection rate climbs quickly and the algorithm has no memory of which removals were doomed.

 

My implementation takes a one-at-a-time approach. It shuffles every cell coordinate, then walks the list and tentatively blanks each cell. After each blanking it runs the uniqueness check; if the puzzle is still uniquely solvable it commits the removal, and if not, it restores the digit and moves on. The loop terminates either when the target hidden count is reached or when the candidate list is exhausted, which means the algorithm always produces a uniquely-solvable puzzle even if it falls a cell or two short of the requested difficulty, rather than spinning indefinitely or producing an ambiguous board.

 

Uniqueness with an Early Bail

 

confirmUniqueSolution calls findAllSolutions, which is itself a backtracking solver looking for any board satisfying the constraints. The crucial optimization is the early bail: as soon as the solution count reaches two, the recursion checks if (allSolutions.length > 1) return at both function entry and after every recursive call, unwinding the entire search tree without exploring its tail. Without this short-circuit, uniqueness verification on the hardest difficulties balloons from a few milliseconds to tens of seconds; the algorithm would otherwise enumerate the full solution space when all it actually needs to know is "two or more." The combined effect is that even Diabolical (60 cells hidden and comfortably above the worst-case theoretical limit of 17 visible clues) generates reliably in under 50 milliseconds.

 

Smart Hints

 

The same machinery powers the hint button. Rather than picking any empty cell, the algorithm builds a snapshot of the current board state, computes the candidate count for every empty cell (how many digits could legally fit there given what the player has already placed), and ranks cells by fewest candidates with proximity to the currently-selected cell as a tiebreaker. The result is that hints land where the player was probably already looking, on cells with one or two possibilities, exactly where a paused human would look for a wedge, rather than a random scatter that feels arbitrary.

Design Decisions

Background Swaps and Blurring

 

Each background is a curated Unsplash photo chosen for peak coziness: citrus candles, dried herbs, a sleeping cat, the Cliffs of Moher, and so on. The image cycles via a top-right button the player can press at any time, with the choice persisted to localStorage so the room reads the same on return visits. The swap itself is implemented with two layered fixed-position <div> elements: when the user picks a new background, the new image is preloaded with a vanilla Image() object, then painted onto whichever layer is currently hidden, and finally the active flag flips so opacity transitions cross-fade the two layers over 0.7 seconds. The preload step is what avoids the brief blank between images that an unbuffered swap would expose on first paint.

 

Sitting on top of the layers is a CSS-variable-controlled blur slider (filter: blur(var(--bg-blur))), so the player can pull focus away from the photo and onto the puzzle without ever leaving the page. Default blur is moderate; players who want a clean wallpaper can drop it to zero, and players who want pure ambient color can crank it up. Each background carries its photographer attribution in a small bottom-left card that updates with the image.

 

Typewrite Sound Effects

 

The audio palette is small and intentional: a soft click for a digit entry, a brighter ding for a correct guess, a low oops for an invalid one, a slightly different click for UI controls, and a satisfied two-note resolution for puzzle completion. Each sample is played by cloning the source element with audio.cloneNode().play() so rapid input never cuts a sound off. A quick burst of correct entries produces a stuttering chorus of dings rather than a single truncated note. Volumes are deliberately low; the effects are meant to be atmospheric. 

 

Meaningful Session Records

 

The records system is deliberately scoped to the device. After every organic solve (explicitly excluding ones that used the SOLVE button) the game records the time, moves, hints used, and strikes used into a per-difficulty bucket in localStorage, keeping the best of each metric independently so a player can simultaneously hold their fastest Normal and their fewest-moves Challenging without one shadowing the other. A small panel in the right column shows per-difficulty solve counts at a glance, and a copy-to-clipboard button emits a formatted text block the player can paste anywhere.

Future Improvements

Two natural extensions exist if the project were to grow beyond solo play. First, an optional account system: a backend-as-a-service like Supabase would make this nearly turn-key, providing email and OAuth sign-in, a Postgres record of player progress, and Row-Level-Security policies that keep each player's data private by default. With auth in place, the records panel becomes a cross-device-synced history rather than a per-browser sandbox, and a partially-solved puzzle can be resumed on a different machine without losing the elapsed time.

 

Second, a community leaderboard: a public completions table keyed by difficulty, sortable by best time, with the player's chosen display name attached. The honest part of building that would be acknowledging the limits of an honor-system leaderboard for a client-generated puzzle. Anyone with five minutes and a browser console can submit a one-second solve, so meaningful enforcement would need either server-side puzzle verification via an edge function, or simply living with a friendly "weekly fastest" board where the social cost of cheating is the punishment. The UI for it has already been sketched: a swap-in view that replaces the board with a leaderboard table, a back-arrow to return to play, and filter chips by difficulty.

 

Beyond those, I plan to implement a daily seeded puzzle (same puzzle for every player on the same calendar day), a mobile-responsive layout with touch-friendly controls, and a small set of alternate themes. None of these ameliorations would change the simple core: a board, a cup of tea, and a puzzle that knows it has exactly one answer.

↩ Warp back to the vault ↩ View all projects