TWISTEdBRACKETS

Building Minesweeper in React, Part 1: Client-Side Architecture

Published

16 July 2026

This is Part 1 of 2. It covers how the game is built and why the current architecture has a hard ceiling when it comes to competitive features. Part 2 will cover moving game authority to the server and building a real scoreboard. That post is coming soon.


Minesweeper has rules that fit in a sentence. Reveal every cell that isn't a mine without detonating one. The engineering underneath is more interesting: a safe first click, efficient flood fill across large boards, synthesised audio with no asset files. But the most interesting question isn't how to build the game. It's what building it as a pure client-side React component means for anything competitive.

Play a game first. Then we'll look at the code, and then at why the code itself is the problem.

Game settings

010
000

Left-click to reveal ยท Right-click or long-press to flag ยท Keyboard: F ยท First click is always safe

The board data model

The board is a 2D array of cell objects. Each cell carries four fields:

ts
type Cell = {  isMine: boolean;  isRevealed: boolean;  isFlagged: boolean;  adjacentMines: number;};

That's the entire persistent state per cell. adjacentMines is computed once at mine-placement time so reveal logic never has to count neighbours again; it reads the pre-computed value.

js
function createBoard(rows, cols) {  return Array.from({ length: rows }, () =>    Array.from({ length: cols }, () => ({      isMine: false,      isRevealed: false,      isFlagged: false,      adjacentMines: 0,    }))  );}

No mines exist yet. That happens on the first click.

Safe first click

Classic Minesweeper lets you explode on the opening move. The fix: defer mine placement until after the first click, then exclude the clicked cell and its eight neighbours from candidacy.

js
function placeMines(board, mines, firstRow, firstCol) {  const rows = board.length;  const cols = board[0].length;
  const forbidden = new Set();  for (let dr = -1; dr <= 1; dr++) {    for (let dc = -1; dc <= 1; dc++) {      const r = firstRow + dr, c = firstCol + dc;      if (r >= 0 && r < rows && c >= 0 && c < cols) {        forbidden.add(r * cols + c);      }    }  }
  const candidates = [];  for (let r = 0; r < rows; r++) {    for (let c = 0; c < cols; c++) {      if (!forbidden.has(r * cols + c)) candidates.push([r, c]);    }  }  const shuffled = candidates.sort(() => Math.random() - 0.5);
  const next = JSON.parse(JSON.stringify(board));  for (let i = 0; i < Math.min(mines, shuffled.length); i++) {    const [r, c] = shuffled[i];    next[r][c].isMine = true;  }
  // Bake in adjacent counts โ€” O(1) per cell during reveal  for (let r = 0; r < rows; r++) {    for (let c = 0; c < cols; c++) {      if (!next[r][c].isMine) {        let count = 0;        for (let dr = -1; dr <= 1; dr++) {          for (let dc = -1; dc <= 1; dc++) {            const nr = r + dr, nc = c + dc;            if (nr >= 0 && nr < rows && nc >= 0 && nc < cols && next[nr][nc].isMine) count++;          }        }        next[r][c].adjacentMines = count;      }    }  }
  return next;}

Flood fill

Revealing a zero-count cell should cascade outward, revealing all connected empty cells and stopping at numbered borders. A recursive implementation hits the call-stack limit on large boards with big open areas. The fix is an explicit stack:

js
function floodReveal(board, row, col) {  const rows = board.length;  const cols = board[0].length;  const next = JSON.parse(JSON.stringify(board));  const stack = [[row, col]];  const visited = new Set();
  while (stack.length > 0) {    const [r, c] = stack.pop();    const key = r * cols + c;    if (visited.has(key)) continue;    visited.add(key);
    if (r < 0 || r >= rows || c < 0 || c >= cols) continue;    const cell = next[r][c];    if (cell.isRevealed || cell.isFlagged || cell.isMine) continue;
    cell.isRevealed = true;
    if (cell.adjacentMines === 0) {      for (let dr = -1; dr <= 1; dr++) {        for (let dc = -1; dc <= 1; dc++) {          if (dr === 0 && dc === 0) continue;          stack.push([r + dr, c + dc]);        }      }    }  }
  return next;}

Numbered cells reveal but don't push neighbours. The fill stops exactly at the boundary, which is the game rule.

Sound effects without audio files

All sounds are synthesised at interaction time using the Web Audio API. No .mp3 or .ogg files are loaded. The core primitive is an oscillator routed through a gain node with an exponential ramp-down:

js
function playTone(ctx, frequency, type, duration, gain = 0.15) {  const osc = ctx.createOscillator();  const gainNode = ctx.createGain();  osc.connect(gainNode);  gainNode.connect(ctx.destination);  osc.type = type;  osc.frequency.setValueAtTime(frequency, ctx.currentTime);  gainNode.gain.setValueAtTime(gain, ctx.currentTime);  // Can't ramp to zero โ€” log(0) is undefined  gainNode.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration);  osc.start(ctx.currentTime);  osc.stop(ctx.currentTime + duration);}

Reveal tones use a musical scale indexed by adjacent mine count: the more danger nearby, the higher the pitch. Win plays a four-note ascending fanfare. Lose plays two descending sawtooth tones.


The client-side problem

Everything above works. The game is playable, reasonably polished, and runs entirely in the browser. That last part is where things get interesting.

What "client-side only" actually means

When the entire game runs in JavaScript in the browser, the browser controls everything. The board state lives in useState. The mine positions live in that state. The timer lives in that state. The win condition is evaluated in that state.

Open DevTools right now and inspect the React component tree. You'll see the board array. Every cell has an isMine field. The mine positions are sitting there, completely visible, before you've revealed a single cell.

This is not a bug. It's a direct consequence of the architecture. A client-side game has no secrets. All logic and all state are delivered to and executed by the client. You can see everything. You can modify everything. You can pause setTimeout, manipulate state through the console, or use a browser extension to read and alter any value in memory.

For a single-player toy this is completely fine. The person cheating is only cheating themselves. But the moment you want a scoreboard (a list of the fastest completions that players can compare themselves against), this architecture makes every score meaningless.

Why a scoreboard can't be trusted from the client

Imagine adding a scoreboard to this component. When a player wins, their time gets submitted to an API endpoint:

js
if (checkWin(next)) {  setStatus("won");  submitScore({ time: seconds, rows, cols, mines });}

Any player who knows JavaScript can run submitScore({ time: 1, rows: 9, cols: 9, mines: 10 }) in the console. No game needed. Score submitted. The leaderboard is corrupted in seconds.

You might think you can validate on the server by checking that the time is "plausible" or that the game configuration is valid. But you cannot validate what you weren't part of. The server never saw the board. It never saw which cells were revealed in which order. It only received a number the client decided to send. Any threshold you enforce, a motivated cheater will learn and respect exactly well enough to slip through.

There's no patch for this at the client layer. The client is the player. You cannot trust the player to report their own score honestly. This is not a React problem or a JavaScript problem. It's a fundamental property of where the computation happens.

What game architects mean by "server authority"

Real competitive games treat the server as the only authoritative source of game state. The client sends inputs like "I clicked cell (3, 4)", and the server decides what those inputs mean. The server holds the board. The server places the mines. The server tracks time. The server evaluates the win condition. The client renders what the server tells it.

In this model there's nothing for a cheater to read out of DevTools because the browser never receives the mine positions until the game is over. There's nothing to submit because the server is already running the timer. A forged score submission is immediately rejected because the server knows what the actual game state was.

This is a different architecture entirely. The game loop looks roughly like:

text
Client                          Serverโ”€โ”€โ”€โ”€โ”€โ”€                          โ”€โ”€โ”€โ”€โ”€โ”€new game request         โ†’      generate board, store in session                         โ†      return board dimensions (no mine positions)render empty grid
click (row, col)         โ†’      validate move, apply to authoritative state                         โ†      return revealed cells for this clickrender revealed cells
...
                         โ†      game over: reveal full board + record timesubmit score             โ†’      server already has the time โ€” ignore client claim

The server never trusts the client's time. It records when the first reveal happened and when the win condition was met, both internally. The score that goes on the leaderboard comes from the server's clock, not a number the client sent.

What would need to change

Moving this game to a server-authoritative architecture requires replacing all of the state management described above:

  • createBoard and placeMines move to the server and never return isMine fields to the client
  • floodReveal runs on the server; the client receives only the cells that should now be visible
  • checkWin runs on the server; the client receives a win notification rather than computing it locally
  • The timer starts on the server at the moment of the first reveal API call
  • Score submission is replaced by the server recording the completion time automatically

The client's job shrinks to: render what the server says, and forward input events. The game logic stays in one place (the server), and that place is not accessible to players.

Part 2 of this series will build exactly that: a server-side game session API, authoritative state, and a real leaderboard where every score can be trusted.

Try it

Here's the full component with the configuration panel. All of the code described above is running in your browser right now.

Game settings

010
000

Left-click to reveal ยท Right-click or long-press to flag ยท Keyboard: F ยท First click is always safe

The element documentation page is at /elements/game-design/minesweeper. The component source is at src/components/elements/Minesweeper.js.


Up next: Minesweeper Part 2: Server Authority and a Real Scoreboard (coming soon)