Engineering
Building @reetesh/sudoku-engine
BitGrid masks, MRV backtracking, unique-solution generation, technique-based difficulty, and v2 variants (6×6, Sudoku X, Hyper).
I built the Sudoku game on iamreetesh.com before I extracted the logic into an npm package. That order mattered. The UI felt like the product until I spent a week fixing puzzles that looked fine but had two solutions, or hints that spoiled half the grid.
The engine now lives in its own repo: @reetesh/sudoku-engine v2.0.0 on npm, sudoku-engine on GitHub. Zero runtime dependencies. Works in Node and the browser. This site uses v2 for classic and variant modes.
What the library has to do
These were non-negotiable:
- Generate puzzles with exactly one solution
- Hit clue-count ranges per difficulty (easy through expert)
- Validate moves fast enough for every keystroke
- Solve grids quickly enough for hints and import checks
- Stay UI-free — React on the site is a consumer, not the owner
If generation and solving disagree about what a valid puzzle is, you get bugs that only show up in production. I learned that the hard way when an early generator passed hasUniqueSolution in tests but failed on symmetric layouts under load.
Public API: a 9×9 grid, not a clever internal shape
Consumers work with a plain Board:
type Board = (1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | null)[][];null is empty. Givens and player entries are digits 1–9. That is it. No bitmasks in the public API, no flat index math in app code.
Internally I convert to a BitGrid when solving or carving puzzles. Keeping the external shape boring made the React game and the npm package easier to reason about. I wrote more about that split in Lessons Learned Building a TypeScript Library.
Helpers like boardToString, stringToBoard, and createEmptyBoard exist because Sudoku strings (81 chars, . or 0 for blank) show up everywhere — puzzle banks, imports, tests.
BitGrid: where the bitmasks actually live
The old draft of this post talked about a flat 81-cell array and row/column/box sets. The shipped code is slightly different but the idea is the same: track used digits with bits, not nested loops on every check.
BitGrid keeps:
cells—Uint8Array(81)(0 = empty, 1–9 = filled)rows,cols,boxes— nineUint16Arrayentries each, one 9-bit mask per unit
Digit 5 sets bit (1 << 4). Full candidate mask for an empty cell:
const FULL_MASK = 0x1ff; // nine bits on
candidateMaskAt(row, column) {
const used = rows[row] | cols[column] | boxes[boxIndex(row, column)];
return FULL_MASK & ~used;
}place and clear update all four structures together so masks never drift from cell values. That invariant saved me from a class of "solver says valid, UI says conflict" bugs.
For hot paths — generation, counting solutions — everything runs on BitGrid. For simple checks in app code, isValidMove and getCandidates can work directly on Board using the same bitmask helpers.
Solver: MRV backtracking
The solver is textbook backtracking with MRV (minimum remaining values): pick the empty cell with the fewest candidates, try each digit, recurse.
function solveRecursive(grid: BitGrid): boolean {
const next = grid.findMrv();
if (!next) return grid.isComplete();
const { row, column, mask } = next;
for (const digit of maskToDigits(mask)) {
grid.place(row, column, digit);
if (solveRecursive(grid)) return true;
grid.clear(row, column);
}
return false;
}findMrv early-exits when it finds a cell with one candidate — no need to scan the rest of the grid.
Before solving, validateBoard rejects boards that already violate Sudoku rules (duplicate in row, column, or box). Invalid player states return { solved: false } instead of throwing. Games need that distinction.
Uniqueness: stop at two solutions
Puzzle generation depends on unique solutions. Counting every solution for a partial grid is wasteful. countSolutionsBitGrid takes a limit and bails once it hits it:
export function hasUniqueSolution(board: Board): boolean {
return countSolutions(board, 2) === 1;
}During cell removal, each candidate carve runs countSolutionsBitGrid(grid, 2). If count exceeds 1, put the digit back. That is the difference between "feels slow when generating expert" and "hangs forever."
How puzzles are generated
Pipeline:
generateSolvedBoard— fill a complete valid grid (randomised backtracking).- Remove clues until target clue count, checking uniqueness after each removal.
- Verify clue count falls in the difficulty range and
hasUniqueSolutionstill passes.
Target clue ranges (givens on the board):
| Difficulty | Givens |
|---|---|
| easy | 40–45 |
| medium | 32–39 |
| hard | 26–31 |
| expert | 22–25 |
generatePuzzle("medium") tries up to 12 attempts with a random solved board and either random or symmetric removal ({ symmetric: true } mirrors cells across the centre like newspaper puzzles).
If the fast path fails, a fallback loop relaxes targets slightly rather than returning garbage. Worst case it throws — I prefer that over shipping a broken puzzle.
Symmetric removal was trickier than random removal. Removing one cell is easy; removing a mirrored pair without breaking uniqueness required backing up both cells and rolling back together. There is a dedicated test file for this in the repo.
Two ways to measure difficulty
Clue count alone is not enough. An expert-looking 24-clue puzzle might still collapse with naked singles only.
The library exposes:
rateDifficulty(board)— clue-count buckets onlyrateDifficultyByTechniques(board)— simulates repeated "only one candidate in this cell" passes (naked singles), counts how many singles you need, tracks max candidate set size seen
// Simplified idea from rateDifficultyByTechniques
while (progressed) {
for each empty cell {
if (getCandidates(working, row, column).length === 1) {
fill it;
singlesRequired++;
}
}
}If you need many singles and candidate sets stay small, it rates easier. Few clues + large candidate sets → expert. This is not human-grade Sudoku grading (no X-Wing logic), and players still tell me some "hard" puzzles feel easy — but it beats counting givens alone for batch labelling.
Hints without spoiling the puzzle
Hints on the site use revealNext(board, solution, puzzle) — scan for the first cell where the player value differs from the solution (skipping givens), then fill that one cell from the solution.
That is intentionally dumb compared to full logical hints. It nudges without implementing human-style technique detection. revealRandom exists when you want variety. All hint helpers respect givens via isGiven(puzzle, row, column).
getCellDisplayState returns "given" | "empty" | "player" | "incorrect" so the UI can colour cells without duplicating rules.
Play helpers
applyMove(board, row, column, value, puzzle) returns { success, board } — it refuses to overwrite givens when puzzle is passed. isSolvedCorrectly(board, solution) is the win check.
isValidMove only checks Sudoku constraints (no duplicate in row/column/box). It does not check whether the move makes the puzzle unsolvable. That is deliberate: players can paint themselves into a corner; the UI shows incorrect cells against the solution when you want strict feedback.
Batch, daily, import
Useful outside the browser game:
generateBatch({ count: 100 });
dailyPuzzle("2026-05-30", "hard");
validateImportedPuzzle(puzzleFromString("530070000..."));generateBatch caps at 1000 per call and can split across difficulties. dailyPuzzle is deterministic from a date string + difficulty — same puzzle for everyone that day.
Import validation runs uniqueness + solvability so pasted strings do not crash the app.
The site pre-generates a puzzle bank with a script (scripts/generate-sudoku-puzzles.mjs) so players get instant loads; live generation is for "new game" and daily modes.
Mistakes I made
Coupling UI state into the engine early. Notes, undo stacks, and timer logic belong in React hooks (useSudoku), not npm.
Trusting clue count as difficulty. Players complained "hard felt easy." Technique rating was the fix.
Not using BitGrid everywhere at first. Mixed board-only solver paths were slower and harder to test.
Flaky tests on technique rating. CI on Node 22 caught a race in parallel vitest runs — fixed in 1.0.1. Small thing, but it eroded trust in the test suite until fixed.
Testing
Tests live in the sudoku-engine repo — solver, generator, symmetric removal, hints, import, performance, batch. prepublishOnly runs build + test before npm publish. This site keeps UI tests under src/games/sudoku/*.test.ts (storage, notes, puzzle bank — not core generation logic).
What I would test again if I started over: uniqueness and generation together, not the solver in isolation.
Size and dependencies
Roughly 13 KB minified ESM, tree-shakeable. No runtime deps. Subpath export @reetesh/sudoku-engine/sudoku if you want a smaller import surface.
Version 2.0: variant framework
@reetesh/sudoku-engine 2.1.0 adds Windoku and Jigsaw on top of the classic / Mini / Sudoku X / Hyper set. iamreetesh.com exposes all six variants in the Sudoku game UI. Classic 9×9 has the most test coverage; newer variants still get edge-case fixes as people play them.
Use createEngine for other rules:
import { createEngine } from "@reetesh/sudoku-engine";
const classic = createEngine({ variant: "classic" });
const mini = createEngine({ variant: "6x6" }); // 6×6, 2×3 boxes, digits 1–6
const sudokuX = createEngine({ variant: "diagonal" }); // Sudoku X: unique diagonals
const hyper = createEngine({ variant: "hyper" }); // Hyper: four extra 3×3 regions
const windoku = createEngine({ variant: "windoku" }); // Windoku: four inner windows
const jigsaw = createEngine({ variant: "jigsaw" }); // Jigsaw: irregular regions
mini.generatePuzzle("easy");
sudokuX.validateBoard(board);
windoku.generatePuzzle("medium");
jigsaw.generatePuzzle("easy");Batch generation with a variant:
import { SudokuEngine } from "@reetesh/sudoku-engine";
new SudokuEngine({ variant: "jigsaw" }).generateBatch({ count: 8 });Migration notes from 1.x:
Digitis nownumber(6×6 uses 1–6)generatePuzzlereturnsnullon exhaustion instead of throwing in some paths- Top-level import helpers remain classic 9×9 only
Upgrade guide: MIGRATION.md on GitHub.
Try it
- Play Sudoku on this site
- npm package
- GitHub repo — includes a small React example under
examples/react
Sudoku looks like a toy problem. It is, until you care about unique puzzles, fair difficulty, and hints that do not ruin the game. Extracting the engine forced me to get those details right once instead of patching the React layer forever.
That pattern — engine first, UI second — is the same one I use for 2048 and, at a much larger scale, Lextrix. Simple rules, fussy implementation.