Building Minesweeper With a Pure-Function Game Engine and BFS Flood Fill
The game logic is five exported functions with no DOM dependencies.
createBoardplaces mines with a safe zone around the first click,revealCelldoes BFS flood fill on empty regions, andcheckWinjust scans for unrevealed non-mine cells. The entire engine is testable without a browser.
Minesweeper is a game every developer has played, but building one surfaces some non-obvious design decisions: how to guarantee the first click is safe, when to stop flood-filling, and how to handle the flag-vs-remaining-mines counter.
🔗 Live demo: https://sen.ltd/portfolio/minesweeper/
📦 GitHub: https://github.com/sen-ltd/minesweeper
Features:
- 4 difficulty levels (Beginner 9×9, Intermediate 16×16, Expert 30×16, Custom)
- First-click safety guarantee
- BFS flood fill on empty cells
- Keyboard support (arrows, Space, F)
- Timer and best times (localStorage)
- Classic Win95-style 3D borders
- Japanese / English UI
- Zero dependencies, 24 tests
First-click safety
The classic problem: the player's first click should never hit a mine. The standard solution is to generate the board after the first click, marking a safe zone:
export function createBoard(rows, cols, mineCount, safeRow = null, safeCol = null) {
// Build safe zone: the clicked cell + its 8 neighbors
const safeSet = new Set();
if (safeRow !== null && safeCol !== null) {
for (let dr = -1; dr <= 1; dr++) {
for (let dc = -1; dc <= 1; dc++) {
const nr = safeRow + dr;
const nc = safeCol + dc;
if (nr >= 0 && nr < rows && nc >= 0 && nc < cols) {
safeSet.add(nr * cols + nc);
}
}
}
}
// Place mines only in non-safe positions
let eligible = positions.filter(([r, c]) => !safeSet.has(r * cols + c));
// Fisher-Yates shuffle, take first mineCount...
}
The safe zone is 9 cells (3×3), not just the clicked cell. This means the first click always reveals a region, not just a single number — a much better player experience.
BFS flood fill
When you reveal a cell with 0 mine neighbors, Minesweeper auto-reveals all connected empty cells. This is a classic BFS:
const queue = [[row, col]];
const visited = new Set();
while (queue.length > 0) {
const [r, c] = queue.shift();
const cur = newBoard[r][c];
if (cur.revealed || cur.flagged || cur.mine) continue;
cur.revealed = true;
revealedCells.push({ row: r, col: c });
if (cur.neighbors === 0) {
// Enqueue all 8 neighbors
for (let dr = -1; dr <= 1; dr++) {
for (let dc = -1; dc <= 1; dc++) {
// bounds check + visited check...
queue.push([nr, nc]);
}
}
}
}
The key detail: we only continue the flood from cells where neighbors === 0. Numbered cells (1-8) get revealed but don't expand the frontier. This naturally creates the "revealed region bordered by numbers" that Minesweeper players expect.
Immutable board updates
Every mutating function (revealCell, toggleFlag) returns a new board:
const newBoard = board.map(r => r.map(c => ({ ...c })));
This shallow-clone-per-cell pattern means the caller can always compare old vs new board state, and undo is trivial (just keep the previous board reference). It also makes the functions pure — same input always produces the same output structure.
Win condition
Win detection is refreshingly simple:
export function checkWin(board) {
for (const row of board) {
for (const cell of row) {
if (!cell.mine && !cell.revealed) return false;
}
}
return true;
}
You win when every non-mine cell is revealed. Flags don't matter — you don't need to flag all mines to win. This matches the original Windows Minesweeper behavior.
Tests
24 test cases on node --test:
test('createBoard: safe zone has no mines', () => {
const board = createBoard(9, 9, 10, 4, 4);
for (let dr = -1; dr <= 1; dr++) {
for (let dc = -1; dc <= 1; dc++) {
assert.strictEqual(board[4 + dr][4 + dc].mine, false);
}
}
});
test('revealCell: flood fill reveals connected empty region', () => {
const board = createBoard(9, 9, 0, null, null); // no mines
const result = revealCell(board, 0, 0);
assert.strictEqual(result.revealedCells.length, 81); // entire board
});
test('revealCell: hitting a mine returns hitMine true', () => {
// Create board with known mine placement...
const result = revealCell(board, mineRow, mineCol);
assert.strictEqual(result.hitMine, true);
});
Series
This is entry #35 in my 100+ public portfolio series.
- 📦 Repo: https://github.com/sen-ltd/minesweeper
- 🌐 Live: https://sen.ltd/portfolio/minesweeper/
- 🏢 Company: https://sen.ltd/

Top comments (0)