Minesweeper Guide

How Does Minesweeper Work?

Most explanations of Minesweeper stop at "the numbers tell you how many mines are adjacent." That's the rules, not the machine. This page is about the machine: how the board gets built, what happens in the milliseconds after you click, and why a game that looks like pure logic sometimes forces you to flip a coin.

The engine on this site is written from scratch in about 500 lines of plain JavaScript, no dependencies. Everything below describes what that code actually does, so the examples are real rather than illustrative.

The board is empty when the page loads

Here's the thing that surprises people who've never implemented Minesweeper: when you load the page, there are no mines anywhere.

The board starts as a flat array of cells, each one carrying four fields:

{ mine: false, revealed: false, flagged: false, adjacent: 0 }

Note that mine and flagged are separate booleans rather than one status enum. They have to be — a cell can be flagged and not a mine (you guessed wrong) or a mine and not flagged. Those two facts are orthogonal, and collapsing them into a single "state" field is a bug waiting to happen at endgame, when you need to render incorrect flags differently from correct ones.

The grid is stored as a one-dimensional array, with index = y * cols + x. A 2D array of arrays would work too, but a flat array with index arithmetic keeps the memory contiguous and makes the flood fill below cheaper to write.

Mines don't exist until your first click. Which brings us to the interesting part.

First-click safety: why mines are placed *after* you click

In the original 1990 Microsoft implementation, you could lose on move one. Click a cell, hit a mine, game over, zero information gained. Modern implementations universally consider this a design defect, because a loss that carried no decision isn't a loss — it's a coin flip that wasted your time.

The fix is deferred placement: generate the mines only once the player has committed to a first cell, then guarantee that cell is safe by construction.

There's a naive way to do this, and it's a trap:

// DON'T DO THIS
        while (minesPlaced < mineCount) {
          const spot = randomCell();
          if (spot === firstClick || board[spot].mine) continue; // reroll
          board[spot].mine = true;
          minesPlaced++;
        }

This is "scatter randomly, reroll on collision." It works fine on a 9×9 beginner board with 10 mines. It degrades badly on expert, which packs 99 mines into 480 cells — a density above 20%. As the board fills, an increasing share of your random draws land on cells that are already mined, so each successive placement takes more attempts. Worse, the loop has no upper bound on iterations. It terminates with probability 1 but no guaranteed bound, which is exactly the kind of thing that produces a rare, unreproducible hang.

The correct approach inverts the problem. Instead of rejecting bad placements, build a candidate pool that contains only legal positions, then draw from it:

function placeMines(board, safeX, safeY, rng) {
          const random = rng || Math.random;

          // 1. Mark the clicked cell AND its 8 neighbours as off-limits.
          const safe = {};
          safe[idx(board, safeX, safeY)] = true;
          for (const n of neighbors(board, safeX, safeY)) safe[n.i] = true;

          // 2. Every cell not marked safe is a candidate.
          const candidates = [];
          for (let c = 0; c < board.cells.length; c++) {
            if (!safe[c]) candidates.push(c);
          }

          // 3. Partial Fisher-Yates: shuffle only the first mineCount slots.
          const mineCount = Math.min(board.mines, candidates.length);
          for (let k = 0; k < mineCount; k++) {
            const j = k + Math.floor(random() * (candidates.length - k));
            [candidates[k], candidates[j]] = [candidates[j], candidates[k]];
            board.cells[candidates[k]].mine = true;
          }
        }

Two details worth pulling out.

It's a partial shuffle. A full Fisher-Yates pass over all 480 cells would work, but you only need the first 99 positions to be correct. Stopping early is O(mines) instead of O(cells), and the distribution of that prefix is identical to a full shuffle's prefix. Each swap draws j from the *remaining* range [k, length), which is the detail that keeps the shuffle unbiased — the common off-by-one of drawing j from the whole array produces a subtly skewed distribution that's notoriously hard to notice by eye.

It excludes nine cells, not one. Protecting only the clicked cell is enough to avoid an instant loss, but it often leaves you staring at a lone 4 with nothing to deduce from. By excluding all eight neighbours as well, the clicked cell is mathematically guaranteed to have adjacent === 0, which means the flood fill below always triggers and your first click always opens a usable region. That's not a nicety — it's the difference between a game that starts with a decision and one that starts with a guess.

The fallback Math.min(board.mines, candidates.length) handles a degenerate case: a custom board where mines outnumber legal candidates. Standard difficulties never approach this, but a board configured with, say, 80 mines in a 9×9 grid would otherwise loop past the end of the array.

Once mines are down, a single pass computes every cell's neighbour count. Counting during placement instead would mean re-scanning regions repeatedly; one sweep at the end is simpler and touches each cell once.

Flood fill: the cascade, and why it uses an explicit stack

When you open a cell with zero adjacent mines, the board opens a whole region. That cascade is a flood fill, and the rule driving it is one line: a cell with adjacent === 0 expands to its neighbours; a numbered cell is opened but does not expand. Numbers are the walls of the region.

The textbook implementation is recursive, and on this board size the textbook is wrong:

function reveal(board, x, y) {
          const start = board.cells[idx(board, x, y)];
          if (start.revealed || start.flagged) return 0;

          const stack = [{ x, y }];
          let opened = 0;

          while (stack.length) {
            const cur = stack.pop();
            const cell = board.cells[idx(board, cur.x, cur.y)];

            // Dedupe on pop, not on push — the same cell can be
            // reached by several paths before it's ever processed.
            if (cell.revealed || cell.flagged) continue;

            cell.revealed = true;
            opened++;

            if (!cell.mine && cell.adjacent === 0) {
              for (const n of neighbors(board, cur.x, cur.y)) {
                if (!board.cells[n.i].revealed) stack.push({ x: n.x, y: n.y });
              }
            }
          }
          return opened;
        }

On an expert board, a lucky opening click can cascade through several hundred cells in one connected region. A recursive version would nest that many frames deep on the JavaScript call stack. Desktop browsers have headroom for it, but the limit is neither large nor consistent across engines, and mobile browsers are tighter. An explicit array as the stack moves that growth onto the heap, where a few hundred small objects are unremarkable. The function is also iterative all the way down, so there's no tail-call assumption baked in — JavaScript engines largely don't implement tail-call elimination regardless of what the spec says.

The dedupe-on-pop comment matters more than it looks. In a grid, a cell is frequently pushed several times before it's processed, because multiple neighbours all see it as unvisited. Checking revealed at push time doesn't help, since none of those pushes have processed it yet. Checking at pop time is the correct guard and it's cheaper — one condition at a single site instead of a filter on every push.

Flags act as walls too. A flagged cell is skipped by the cascade, which means a misplaced flag can stop an expansion that should have continued. That's intentional and matches classic behaviour: the engine trusts your flags.

Chording, precisely

Chording is the speed technique: on an already-open number, if the count of adjacent flags equals that number, open every adjacent cell that isn't flagged.

function chord(board, x, y) {
          const cell = board.cells[idx(board, x, y)];
          if (!cell.revealed || cell.mine || cell.adjacent <= 0) return 0;

          const nb = neighbors(board, x, y);
          const flags = nb.filter(n => board.cells[n.i].flagged).length;
          if (flags !== cell.adjacent) return 0;   // mismatch: change nothing

          let opened = 0;
          for (const n of nb) {
            const t = board.cells[n.i];
            if (!t.flagged && !t.revealed) opened += reveal(board, n.x, n.y);
          }
          return opened;
        }

The critical property is the early return on mismatch. If the flag count doesn't equal the number, the engine does *nothing at all* — no partial opening, no board mutation. A stray chord on the wrong cell must be a no-op, or the technique becomes too dangerous to use at speed.

Equally important: chording does not verify that your flags are *correct*. If you flagged the wrong cell and chord, the engine opens a real mine and you lose. It's executing your stated hypothesis. That asymmetry — the engine checks your arithmetic but not your reasoning — is what makes chording fast and risky at once. The tactical side of this is covered on the strategy page.

Winning is about the safe cells, not the mines

function isWon(board) {
          for (const c of board.cells) {
            if (!c.mine && !c.revealed) return false;
          }
          return true;
        }

That's the whole win condition: every non-mine cell is revealed. Flags appear nowhere in it.

You can win having placed zero flags. You can win with flags scattered incorrectly across the board, as long as they never blocked you from opening a safe cell. The mine counter in the corner is a UI convenience that subtracts placed flags from total mines — it isn't tracking correctness, and it happily displays negative numbers when you over-flag. The rules page lists the misconceptions this generates.

Why some boards genuinely cannot be solved

This is the part players take personally, so it's worth stating without hedging: on standard Minesweeper, some positions are unsolvable by logic. Losing them is not a skill failure.

The classic shape is a 50/50 at the board's edge:

 1  1
         ?  ?
        [wall]

Two covered cells, both bounded by the wall, and every constraint touching them says "exactly one of these two is a mine." No amount of reasoning distinguishes them, because the information that would distinguish them does not exist in the board state. If those are the last two cells and the global mine count doesn't break the tie, it's a coin flip.

This happens because the game generates a random board and never checks it's solvable. Deferred placement guarantees your *first* click is safe; it guarantees nothing about move forty. Some implementations do offer a "no-guess" mode, which works by running a solver during generation and rejecting boards that require guessing — genuinely nicer to play, but no longer standard Minesweeper, and it makes generation dramatically more expensive since a rejected board means starting over.

When you're stuck without a certainty, the move is to minimise expected loss rather than to keep staring. The global mine count is real information and often breaks ties that local constraints can't — that calculation is worked through on the strategy page.

Minesweeper is NP-complete (with a caveat worth stating)

In 2000, Richard Kaye published "Minesweeper is NP-complete" in *The Mathematical Intelligencer* (vol. 22, no. 2, pp. 9–15). The proof constructs Boolean logic gates — AND, OR, NOT — out of Minesweeper configurations, then wires them into circuits, reducing SAT to Minesweeper.

The caveat matters, and it's routinely dropped in casual retellings. What Kaye proved is that the Minesweeper Consistency Problem is NP-complete: given a partially uncovered board, decide whether *any* mine arrangement is consistent with the numbers shown. That is not the same question as "what should I click next." It remains open whether optimal play, in general, requires solving an NP-complete problem — there could exist an approach that plays well without deciding consistency.

What it does tell you practically: there's no known efficient general algorithm that resolves arbitrary Minesweeper positions. Real solvers handle the overwhelming majority of positions with cheap local pattern matching, escalate to constraint-satisfaction over the frontier when that stalls, and fall back to probability estimation when even that can't produce a certainty. The hard instances are rare in practice, which is why solvers work well despite the complexity result.

For a board this small the theory is mostly a curiosity — 480 cells doesn't strain anything. It's a good reminder, though, that "small grid, simple rules" and "computationally easy" are unrelated properties.

Putting it together

The full lifecycle of a click on a fresh board:

  1. Click lands, engine checks board.minesPlaced — false.
  2. placeMines() builds the candidate pool excluding the clicked cell and its 8 neighbours, partial-shuffles, writes 99 mines, sweeps once to compute adjacency counts.
  3. Timer starts. The clicked cell is guaranteed adjacent === 0.
  4. reveal() pushes the cell, pops it, opens it, sees zero, pushes its neighbours, and iterates until the stack drains — several hundred cells in one region.
  5. settle() checks hitMine() (false — guaranteed), then isWon() (false), then repaints.

Every click after that skips step 2 and runs 4 and 5. That's the entire engine. The rules are simple enough to state in a paragraph; the interesting engineering is all in generation and cascade.

If you want to play the version described here, the board at the top of the home page runs this exact code. If you want the rules stated formally rather than as implementation, that's the rules page.


*Reference: Kaye, R. (2000). "Minesweeper is NP-complete." The Mathematical Intelligencer, 22(2), 9–15.*