Build a Sudoku solver in TypeScript
A Sudoku solver needs three ideas: a table of which cells see each other, a way to work out which digits a cell can still take, and a backtracking search that always branches on the cell with the fewest options.
This tutorial builds all three in about 90 lines of TypeScript, adds a solution counter that proves a puzzle has exactly one solution by stopping at two, and ends with a short clue-removal routine. The code was run and tested for this page, and it is original to this tutorial.
What will you build?
By the end you will have one file, solver.ts, and one test file. The solver reads a puzzle as an 81-character string, finds up to a chosen number of solutions, and tells you whether the puzzle is unique. A final function removes clues from a solved grid while keeping the puzzle unique, which is the core of how a puzzle generator works.
This is a teaching version. The site’s own engine, described in how puzzles are generated and verified unique, uses the same ideas but is more general. It handles the variants, caps the number of search steps, and keeps its data in typed arrays. The code here is written for clarity, and it is not the site’s source.
What do you need?
You need Node.js 22.18 or newer. From that version Node runs TypeScript files directly by stripping the type annotations, so node solver.ts works with no build step and no packages. The code here was checked on Node 22.22 and also passes the TypeScript compiler in strict mode. It uses only plain type annotations, which is why type stripping is enough.
Step 1: how do you represent the grid?
Use a flat array of 81 numbers, with 0 meaning an empty cell. Cell number row * 9 + col holds the digit at that row and column, counting from zero. A flat array is simple to copy, which matters because a search will try thousands of positions. The parse function turns a puzzle string, with a dot for each blank, into this array and rejects strings of the wrong length.
export type Grid = number[]; // 81 cells, 0 means empty
export function parse(text: string): Grid {
if (text.length !== 81) throw new Error('A puzzle needs exactly 81 characters');
return [...text].map((ch) => (ch === '.' ? 0 : Number(ch)));
}Step 2: how do you know which cells see each other?
Two cells “see” each other when they share a row, a column or a box, and a digit placed in one rules that digit out of the other. Every cell sees exactly 20 others: eight in its row, eight in its column and four more in its box that are not already counted. Work these lists out once, when the file loads, and reuse them everywhere.
// For every cell, the 20 other cells that share its row, column or box.
const PEERS: number[][] = Array.from({ length: 81 }, (_, cell) => {
const row = Math.floor(cell / 9);
const col = cell % 9;
const peers: number[] = [];
for (let other = 0; other < 81; other++) {
if (other === cell) continue;
const r = Math.floor(other / 9);
const c = other % 9;
const sameBox = Math.floor(r / 3) === Math.floor(row / 3) && Math.floor(c / 3) === Math.floor(col / 3);
if (r === row || c === col || sameBox) peers.push(other);
}
return peers;
});The box test compares integer-divided row and column numbers: two cells are in the same box when both divide by three to the same values. This list of peers is also the only place the rules of classic Sudoku appear. To support a variant such as diagonal Sudoku, you would add the diagonal cells to the peers of cells on a diagonal, and the rest of the solver would work unchanged.
Step 3: how do you find the digits a cell can take?
A cell can hold a digit unless one of its peers already holds it. Rather than build an array of candidates for each cell, keep them in a single integer in which bit d is set when digit d is still possible. Combining the peers’ digits is then one bitwise OR per peer, and counting the result is cheap.
// Bit d of the result is set when digit d is still possible in the cell.
function candidates(grid: Grid, cell: number): number {
let used = 0;
for (const peer of PEERS[cell]) used |= 1 << grid[peer];
return ~used & 0b1111111110;
}
function bitCount(mask: number): number {
let n = 0;
for (; mask; mask &= mask - 1) n++;
return n;
}The line used |= 1 << grid[peer] sets the bit for each peer’s digit. An empty peer holds 0 and sets bit 0, which is harmless, because the final mask 0b1111111110 keeps only bits 1 to 9. Inverting used and applying the mask leaves the digits nobody has taken. bitCount counts the set bits by clearing the lowest one each time round the loop.
Step 4: how does the search work?
The solver is a backtracking search with one important choice: it always branches on the empty cell with the fewest candidates. That rule is called minimum remaining values. A cell with one candidate is forced, so the search takes it with no branching, and a cell with zero candidates is a dead end that is abandoned at once. Picking the tightest cell first keeps the search tree small, even on puzzles with few clues.
// Finds up to `limit` solutions. Two are enough to tell "unique" from "not unique".
export function solve(puzzle: Grid, limit = 2): Grid[] {
const grid = puzzle.slice();
const solutions: Grid[] = [];
for (let cell = 0; cell < 81; cell++) {
if (grid[cell] && PEERS[cell].some((p) => grid[p] === grid[cell])) return solutions; // givens clash
}
const search = (): void => {
let best = -1;
let bestMask = 0;
let bestCount = 10;
for (let cell = 0; cell < 81; cell++) {
if (grid[cell]) continue;
const mask = candidates(grid, cell);
const count = bitCount(mask);
if (count < bestCount) {
best = cell;
bestMask = mask;
bestCount = count;
if (count <= 1) break;
}
}
if (best === -1) {
solutions.push(grid.slice());
return;
}
for (let digit = 1; digit <= 9 && solutions.length < limit; digit++) {
if (!(bestMask & (1 << digit))) continue;
grid[best] = digit;
search();
}
grid[best] = 0;
};
search();
return solutions;
}
export const isUnique = (puzzle: Grid): boolean => solve(puzzle, 2).length === 1;Several details deserve attention.
- The clash check. Before searching, the function rejects givens that already contradict each other, such as two 4s in one row, by returning no solutions.
- Early exit. When a cell has one or no candidates, the scan breaks out of the loop immediately, since nothing can beat that.
- Undoing. After trying every digit in the chosen cell, the code sets that cell back to 0 so that the caller’s view of the grid is restored before it tries its next option.
- The limit. The loop stops as soon as
limitsolutions have been found.
Step 5: how do you prove a puzzle is unique?
To prove a puzzle has exactly one solution you do not need to know how many it has in total. You only need to know whether the count is one. So the solver is asked for at most two solutions. If it finds two, the puzzle is not unique. If it finds one and the search then finishes without a second, the puzzle is unique. The one-line isUnique helper at the bottom of the solver does exactly this. It is the same idea the site’s verification gate applies to every stored puzzle, and the same property a 2012 computer search had to test to show that no puzzle has fewer than 17 clues, as described in how many Sudoku puzzles exist.
Step 6: how do you test it?
A solver that has never been tested against known answers is not trustworthy. The test file uses Node’s built-in test runner, so there is nothing to install. It covers the cases that matter: a known puzzle with a known solution, an empty grid where the search must stop at the limit, a puzzle built to have two solutions, clashing givens, and the clue-removal routine from the next step. The puzzle is one taken from this site’s Easy pool, and the two-solution puzzle was made by blanking the four corners of a rectangle of digits that could be swapped.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { carve, isUnique, mulberry32, parse, solve } from './solver.ts';
const PUZZLE = '46....2...53.14..77....6.39.....7.9.8....2.5.53..6......5.4..71.4..8.9...9.....4.';
const SOLUTION = '468973215953214687712856439124537896876492153539168724285649371341785962697321548';
const TWO_SOLUTIONS = '4689..2159532146877128564391245..896876492153539168724285649371341785962697321548';
test('solves a puzzle and finds exactly one solution', () => {
const found = solve(parse(PUZZLE));
assert.equal(found.length, 1);
assert.equal(found[0].join(''), SOLUTION);
});
test('stops at the limit on a grid with many solutions', () => {
assert.equal(solve(new Array(81).fill(0), 2).length, 2);
});
test('detects a puzzle with two solutions', () => {
assert.equal(solve(parse(TWO_SOLUTIONS), 5).length, 2);
assert.equal(isUnique(parse(TWO_SOLUTIONS)), false);
});
test('rejects givens that clash', () => {
const clash = parse(PUZZLE);
clash[2] = 4; // a second 4 in row 1
assert.equal(solve(clash).length, 0);
});
test('carve keeps the puzzle unique, removes clues, and is repeatable', () => {
const a = carve(parse(SOLUTION), mulberry32(7));
const b = carve(parse(SOLUTION), mulberry32(7));
assert.deepEqual(a, b);
assert.ok(isUnique(a));
assert.ok(a.filter(Boolean).length < 40);
});
Run the tests with node --test solver.test.ts. On Node 22.22 all five pass. If you change the solver, run them again, since an error in a solver tends to show up as a wrong count and not as a crash.
Step 7: how do you turn a solver into a generator?
A generator needs the solver for one job: deciding whether a clue can be removed. The routine below starts from a solved grid, visits the cells in random order, blanks each one and puts it back if the puzzle is no longer unique. It uses a small seeded random number generator, so a given seed always produces the same puzzle, which is what makes generation testable.
// Small seeded generator so the same seed always gives the same shuffle.
export function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// Blank cells in random order, keeping each removal only if the puzzle stays unique.
export function carve(solution: Grid, random: () => number): Grid {
const puzzle = solution.slice();
const order = Array.from({ length: 81 }, (_, i) => i);
for (let i = 80; i > 0; i--) {
const j = Math.floor(random() * (i + 1));
[order[i], order[j]] = [order[j], order[i]];
}
for (const cell of order) {
const kept = puzzle[cell];
puzzle[cell] = 0;
if (!isUnique(puzzle)) puzzle[cell] = kept;
}
return puzzle;
}Starting from the solution of the puzzle used in this tutorial with seed 7, the routine produced a unique puzzle with 24 clues. That is in line with the 21 to 30 clues found in the site’s classic pools, but the comparison is loose, because this routine grades nothing and aims for no particular level. A real generator also builds a fresh random solution each time, and grades what it makes with a technique solver, as in how Sudoku difficulty is graded.
What are the common bugs when writing your own?
- Forgetting to undo. If the search does not reset a cell to 0 after trying its digits, later branches run on a grid that still holds stale digits, and the results are wrong without any error. This is the most frequent bug.
- Changing the caller’s puzzle. The solver copies its input with
slice()before it starts. Without that copy, solving a puzzle would overwrite it, and a following uniqueness check would see a solved grid. - Returning the grid itself. A found solution is pushed as a copy. Pushing the working grid would store a reference that the search later empties.
- Not stopping at the limit. Without the limit, an empty grid would search for an astronomically large number of solutions and never finish.
- Skipping the clash check. Contradictory givens give a grid with no solution, but a solver that does not look for the clash may report that a broken puzzle is solvable or may spend a long time failing.
- Getting a peer list wrong by one. A cell that is not its own peer should not appear in its list, and the list should hold exactly 20 entries. A quick check in the console, such as every
PEERS[i].lengthbeing 20, catches most errors in the box test.
What does this solver leave out?
It does not explain itself. It finds the answer by trying digits and backing out, which is different from the way a person solves, and it cannot tell you why a move is right. It has no notion of difficulty either, since a puzzle that needs advanced reasoning from a human may be trivial for a search. That gap is the reason this site builds a second solver, the technique engine, that applies the named techniques in order. The grading guide describes it, and the frequency guide shows what it found across the site’s puzzles.
How fast is it?
On the machine used to check this tutorial, solving the example puzzle took about 0.17 milliseconds on average over 100 runs, and the first call took longer, around 9 milliseconds, while the engine warmed up. Asking for two solutions of an empty grid took under 3 milliseconds. These figures depend on your hardware and on the puzzle, and puzzles with few clues and no easy moves can be much slower. They are measurements of this code only, and they are not a benchmark of the site.
The site never runs a search like this in your browser while you play. Puzzles are generated and checked offline and shipped as data, because a hard search on a phone could freeze the page.
Where can you take it next?
- Add the diagonals to the peer lists to solve diagonal Sudoku, and check the result against the diagonal puzzles on the site.
- Count all solutions of a puzzle by raising the limit, and see how the count grows as you remove clues.
- Add a cap on the number of search steps, so that the solver can report that it gave up and does not run forever.
- Write a human-style solver that applies naked singles and hidden singles before it searches. This is the first step towards grading puzzles by technique.
- Read the techniques hub for the deductions a person uses, and play a puzzle on the home page to compare your solver with your own reasoning.
More tutorials are on the guides hub.