DEV Community

Cover image for Building Minesweeper in React — BFS Flood Fill, Safe First Click, and Responsive Scale-to-Fit
Shaishav Patel
Shaishav Patel

Posted on • Originally published at ultimatetools.hashnode.dev

Building Minesweeper in React — BFS Flood Fill, Safe First Click, and Responsive Scale-to-Fit

Minesweeper looks trivial to implement until you actually start. Then you run into: guaranteed safe first click, the flood-fill cascade that reveals large empty regions, and the scaling problem — the board needs to fit a phone screen without the cells getting unusably tiny.

Here's how I built Minesweeper in React with all three solved properly.

Board Representation

Each cell is a plain object:

interface Cell {
  isMine: boolean
  isRevealed: boolean
  isFlagged: boolean
  adjacentMines: number  // 0–8
}

type Board = Cell[][]
Enter fullscreen mode Exit fullscreen mode

The board is a 2D array indexed as board[row][col]. For an Easy (9×9, 10 mines) game:

function createEmptyBoard(rows: number, cols: number): Board {
  return Array.from({ length: rows }, () =>
    Array.from({ length: cols }, () => ({
      isMine: false,
      isRevealed: false,
      isFlagged: false,
      adjacentMines: 0,
    }))
  )
}
Enter fullscreen mode Exit fullscreen mode

Safe First Click — Placing Mines After the First Move

The classic Minesweeper rule: the first cell you click is always safe, and ideally reveals a large empty region. Implement this by placing mines after the first click, excluding the clicked cell and its neighbors.

function placeMines(
  board: Board,
  mineCount: number,
  safeRow: number,
  safeCol: number
): Board {
  const rows = board.length
  const cols = board[0].length

  // Cells excluded from mine placement (clicked cell + 8 neighbors)
  const excluded = new Set<string>()
  for (let dr = -1; dr <= 1; dr++) {
    for (let dc = -1; dc <= 1; dc++) {
      excluded.add(`${safeRow + dr},${safeCol + dc}`)
    }
  }

  // Collect eligible positions
  const eligible: [number, number][] = []
  for (let r = 0; r < rows; r++) {
    for (let c = 0; c < cols; c++) {
      if (!excluded.has(`${r},${c}`)) eligible.push([r, c])
    }
  }

  // Fisher-Yates shuffle, take first mineCount
  for (let i = eligible.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1))
    ;[eligible[i], eligible[j]] = [eligible[j], eligible[i]]
  }

  const newBoard = board.map(row => row.map(cell => ({ ...cell })))
  eligible.slice(0, mineCount).forEach(([r, c]) => {
    newBoard[r][c].isMine = true
  })

  return computeAdjacentCounts(newBoard)
}
Enter fullscreen mode Exit fullscreen mode

Computing Adjacent Mine Counts

After mines are placed, compute the adjacentMines value for every non-mine cell:

function computeAdjacentCounts(board: Board): Board {
  const rows = board.length
  const cols = board[0].length

  for (let r = 0; r < rows; r++) {
    for (let c = 0; c < cols; c++) {
      if (board[r][c].isMine) continue
      let count = 0
      for (let dr = -1; dr <= 1; dr++) {
        for (let dc = -1; dc <= 1; dc++) {
          if (dr === 0 && dc === 0) continue
          const nr = r + dr, nc = c + dc
          if (nr >= 0 && nr < rows && nc >= 0 && nc < cols) {
            if (board[nr][nc].isMine) count++
          }
        }
      }
      board[r][c].adjacentMines = count
    }
  }
  return board
}
Enter fullscreen mode Exit fullscreen mode

BFS Flood Fill Cascade

When you click a cell with adjacentMines === 0, the standard Minesweeper behavior is to auto-reveal all connected zero-cells and their numbered neighbors. This is a BFS traversal:

function revealFrom(board: Board, startRow: number, startCol: number): Board {
  const rows = board.length
  const cols = board[0].length
  const newBoard = board.map(row => row.map(cell => ({ ...cell })))

  const queue: [number, number][] = [[startRow, startCol]]
  const visited = new Set<string>([`${startRow},${startCol}`])

  while (queue.length > 0) {
    const [r, c] = queue.shift()!
    const cell = newBoard[r][c]

    if (cell.isFlagged || cell.isMine) continue

    cell.isRevealed = true

    // Only continue BFS from zero-cells
    if (cell.adjacentMines === 0) {
      for (let dr = -1; dr <= 1; dr++) {
        for (let dc = -1; dc <= 1; dc++) {
          const nr = r + dr, nc = c + dc
          const key = `${nr},${nc}`
          if (
            nr >= 0 && nr < rows &&
            nc >= 0 && nc < cols &&
            !visited.has(key) &&
            !newBoard[nr][nc].isRevealed
          ) {
            visited.add(key)
            queue.push([nr, nc])
          }
        }
      }
    }
  }

  return newBoard
}
Enter fullscreen mode Exit fullscreen mode

BFS (not DFS) matters here for two reasons: it reveals cells in "waves" outward from the click, which matches the visual expectation, and it avoids stack overflow on large boards that DFS recursion would risk.

Win and Loss Detection

Loss: clicked a mine → reveal all mines, game over.

Win: every non-mine cell is revealed.

function checkWin(board: Board): boolean {
  return board.every(row =>
    row.every(cell => cell.isMine || cell.isRevealed)
  )
}
Enter fullscreen mode Exit fullscreen mode

Check win after every reveal. When won, auto-flag all remaining mines:

function flagAllMines(board: Board): Board {
  return board.map(row =>
    row.map(cell => cell.isMine ? { ...cell, isFlagged: true } : cell)
  )
}
Enter fullscreen mode Exit fullscreen mode

Responsive Scale-to-Fit with ResizeObserver

The board has fixed pixel-per-cell dimensions (e.g., 32px per cell for Expert = 30×16 = 960px wide). On a 375px mobile screen, that doesn't fit.

The solution: measure the container width and apply a CSS transform: scale() to shrink the board to fit, without changing cell sizes or reflowing.

const containerRef = useRef<HTMLDivElement>(null)
const boardRef = useRef<HTMLDivElement>(null)
const [scale, setScale] = useState(1)

const CELL_SIZE = 32

useEffect(() => {
  const observer = new ResizeObserver(entries => {
    const containerW = entries[0].contentRect.width
    const boardW = cols * CELL_SIZE
    const newScale = Math.min(1, containerW / boardW)
    setScale(newScale)
  })

  if (containerRef.current) observer.observe(containerRef.current)
  return () => observer.disconnect()
}, [cols])
Enter fullscreen mode Exit fullscreen mode

Apply it in JSX:

<div ref={containerRef} style={{ width: '100%' }}>
  <div
    ref={boardRef}
    style={{
      transform: `scale(${scale})`,
      transformOrigin: 'top center',
      width: cols * CELL_SIZE,
      // Compensate height so layout doesn't collapse
      marginBottom: (scale - 1) * rows * CELL_SIZE,
    }}
  >
    {/* board cells */}
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

The marginBottom compensation is important. When scale < 1, the element's layout box stays at full size but the visual box is smaller — leaving a gap below. The negative margin (since scale - 1 is negative) pulls subsequent elements up to close the gap.

Number Colors

The classic Minesweeper color scheme for adjacent counts:

const numberColors: Record<number, string> = {
  1: '#2563eb', // blue
  2: '#16a34a', // green
  3: '#dc2626', // red
  4: '#1e3a8a', // dark blue
  5: '#991b1b', // dark red
  6: '#0891b2', // cyan
  7: '#000000', // black
  8: '#6b7280', // gray
}
Enter fullscreen mode Exit fullscreen mode

These match the original Windows Minesweeper colors players are familiar with.

Right-Click Flag on Desktop, Long-Press on Mobile

function handleCellInteraction(
  e: React.MouseEvent | React.TouchEvent,
  row: number,
  col: number
) {
  if ('button' in e && e.button === 2) {
    // Right-click = flag
    e.preventDefault()
    toggleFlag(row, col)
  }
}

// Long press for mobile
let longPressTimer: ReturnType<typeof setTimeout>

function handleTouchStart(row: number, col: number) {
  longPressTimer = setTimeout(() => toggleFlag(row, col), 500)
}

function handleTouchEnd() {
  clearTimeout(longPressTimer)
}
Enter fullscreen mode Exit fullscreen mode

On mobile, a 500ms touch hold flags/unflags the cell. Short tap reveals it. This matches how Minesweeper typically works on touch devices.

Difficulty Config

const DIFFICULTIES = {
  easy:   { rows: 9,  cols: 9,  mines: 10 },
  medium: { rows: 16, cols: 16, mines: 40 },
  hard:   { rows: 16, cols: 30, mines: 99 },
} as const
Enter fullscreen mode Exit fullscreen mode

The Expert grid (16×30) is 960px wide at 32px/cell — wider than most phones. That's exactly where the ResizeObserver scale kicks in and makes it playable on any screen.


Play it: Minesweeper → ultimatetools.io

Part of Ultimate Tools — free browser-based tools and games.

Top comments (0)