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
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:
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.
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.
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:
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:
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:
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:
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 claimThe 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:
createBoardandplaceMinesmove to the server and never returnisMinefields to the clientfloodRevealruns on the server; the client receives only the cells that should now be visiblecheckWinruns 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
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)
Similar articles

AI Liability: Who Is Responsible When AI Gets It Wrong?
I'm a software engineer, not a lawyer. But I've been watching the AI liability conversation closely, and the gap between how AI systems actually work and how our legal system assigns blame is becoming a real operational problem for anyone deploying AI.
17 July 2026

MCP Explained: How AI Models Talk to Your Tools (and What Comes Next)
MCP is the protocol that lets Claude, ChatGPT, and Gemini all talk to your GitHub repo, your database, and your calendar without three different integrations. It's not a ratified standard yet, it's a fast-moving spec under a young foundation, and it's about to change shape again.
15 July 2026

Sorting Algorithms Explained
Bubble, selection, insertion, merge, quick, and heap sort: how each one works, where it shines, where it struggles, and animated demos that run the real algorithm code on the same starting data every time.
13 July 2026

Set Membership Testing: From Arrays to Bloom Filters
Every time Chrome warns you a site might be malicious, it just answered a membership question: is this URL in a set of bad ones? Here's how that check works, from a plain array to the probabilistic Bloom filter that makes it possible at scale.
10 July 2026
