DEV Community

SEN LLC
SEN LLC

Posted on

Building Minesweeper With a Pure-Function Game Engine and BFS Flood Fill

Building Minesweeper With a Pure-Function Game Engine and BFS Flood Fill

The game logic is five exported functions with no DOM dependencies. createBoard places mines with a safe zone around the first click, revealCell does BFS flood fill on empty regions, and checkWin just 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

Screenshot

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

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]);
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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

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

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

Series

This is entry #35 in my 100+ public portfolio series.

Top comments (0)