I often see front-end developers get stuck in “UI-only” mode, building layouts but rarely exercising game-state logic, recursion, and interaction design together. Minesweeper is a perfect antidote. It forces you to model a grid-based world, handle tricky edge cases, and build a tight event loop that feels responsive. If you can ship a clean Minesweeper clone, you’ve proven you can handle non-trivial state, DOM performance, and UX feedback in a single-page app.
You’re going to build a fully playable game using plain HTML, CSS, and JavaScript. I’ll show you a robust data model, a safe mine placement strategy, a zero-magic render loop, and optional quality-of-life features like flags, timers, and difficulty presets. You’ll walk away with a complete runnable project plus patterns you can reuse in any grid-based app.
Game Model First: The Data Structure That Makes Everything Easy
The biggest trap is mixing UI state with game logic. I always model Minesweeper as a 2D array of cell objects. Every cell tracks: whether it has a mine, whether it’s revealed, whether it’s flagged, and how many adjacent mines it has. This isolates the logic from the DOM so you can test and reason about it without touching the UI.
A minimal cell structure looks like this:
- isMine: boolean
- revealed: boolean
- flagged: boolean
- count: number (adjacent mines)
I also store a global game state:
- rows, cols, mines
- revealedCount
- gameOver
- startTime, timerId
That’s enough to handle all win/lose conditions without fragile DOM checks. When you click a cell, you mutate the data model, then re-render the UI. Simple, predictable, easy to debug.
HTML Structure: A Clean, Minimal Surface
I keep the HTML lean and let the grid be generated by JavaScript. You’ll need a container for controls, a stats bar, and a board element. Here’s a clean HTML scaffold you can paste into index.html:
Minesweeper
Beginner (8×8, 10 mines)
Intermediate (12×12, 25 mines)
Expert (16×16, 45 mines)
I use a select for difficulty and a button for reset. You can swap this for custom inputs later, but presets are good for a blog-scale project.
CSS That Balances Clarity and Feedback
Minesweeper is all about visual feedback: revealed tiles, flagged tiles, numbers with different colors, and a clear grid layout. I prefer CSS Grid, because it lets you set the board size dynamically by updating CSS variables from JavaScript.
Use this styles.css:
:root {
–bg: #0f1a24;
–panel: #122033;
–accent: #37c6f4;
–text: #e6f2ff;
–cell: #1a2b40;
–cell-revealed: #24374e;
–cell-border: #314a63;
}
- { box-sizing: border-box; }
body {
margin: 0;
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
background: radial-gradient(circle at top, #1a2b40 0%, #0f1a24 55%, #0c141d 100%);
color: var(–text);
min-height: 100vh;
display: grid;
place-items: center;
}
.app {
width: min(900px, 95vw);
background: var(–panel);
padding: 24px;
border-radius: 16px;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.4);
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
h1 {
margin: 0;
font-size: 1.8rem;
}
.controls {
display: flex;
gap: 12px;
}
select, button {
background: #1c2f45;
color: var(–text);
border: 1px solid #2e4966;
padding: 8px 12px;
border-radius: 8px;
font-size: 0.95rem;
}
button {
cursor: pointer;
transition: transform 0.1s ease, background 0.2s ease;
}
button:hover { background: #23405b; }
button:active { transform: scale(0.98); }
.status {
margin: 16px 0 20px;
display: grid;
grid-template-columns: repeat(3, auto);
gap: 18px;
font-size: 1rem;
}
.board {
display: grid;
gap: 4px;
background: #0e1723;
padding: 10px;
border-radius: 12px;
user-select: none;
}
.cell {
width: var(–cell-size, 34px);
height: var(–cell-size, 34px);
background: var(–cell);
border: 1px solid var(–cell-border);
border-radius: 6px;
display: grid;
place-items: center;
font-weight: 700;
font-size: 0.95rem;
cursor: pointer;
transition: background 0.15s ease, transform 0.08s ease;
}
.cell:hover { transform: translateY(-1px); }
.cell.revealed {
background: var(–cell-revealed);
cursor: default;
}
.cell.mine {
background: #642b2b;
}
.cell.flagged {
background: #1c334a;
}
.cell[data-count="1"] { color: #7fd7ff; }
.cell[data-count="2"] { color: #7dffb0; }
.cell[data-count="3"] { color: #ffd36e; }
.cell[data-count="4"] { color: #ff9b6e; }
.cell[data-count="5"] { color: #ff7f7f; }
.cell[data-count="6"] { color: #ff6ee7; }
.cell[data-count="7"] { color: #c2a9ff; }
.cell[data-count="8"] { color: #ffffff; }
That’s a clean, high-contrast theme with enough feedback to make the game playable even at higher difficulty.
Core JavaScript: Board Creation and Mine Placement
The most subtle bug I see is faulty mine placement and adjacency counting. You need deterministic boundaries and correct neighbor loops. I also make sure mines are placed first, then counts computed, and only then the board is rendered.
Here’s a full script.js you can run as-is:
const boardEl = document.getElementById("board");
const newGameBtn = document.getElementById("newGame");
const difficultySelect = document.getElementById("difficulty");
const timerEl = document.getElementById("timer");
const flagsEl = document.getElementById("flags");
const minesEl = document.getElementById("mines");
let rows = 8;
let cols = 8;
let mines = 10;
let board = [];
let revealedCount = 0;
let flagsUsed = 0;
let gameOver = false;
let timerId = null;
let startTime = null;
const directions = [
[-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1],];
function parseDifficulty(value) {
const [r, c, m] = value.split("x").map(Number);
return { r, c, m };
}
function resetState() {
board = [];
revealedCount = 0;
flagsUsed = 0;
gameOver = false;
clearInterval(timerId);
timerId = null;
startTime = null;
timerEl.textContent = "0";
}
function createBoard() {
for (let r = 0; r < rows; r++) {
const row = [];
for (let c = 0; c < cols; c++) {
row.push({
isMine: false,
revealed: false,
flagged: false,
count: 0,
});
}
board.push(row);
}
}
function placeMines() {
let placed = 0;
while (placed < mines) {
const r = Math.floor(Math.random() * rows);
const c = Math.floor(Math.random() * cols);
if (!board[r][c].isMine) {
board[r][c].isMine = true;
placed++;
}
}
}
function computeCounts() {
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
if (board[r][c].isMine) continue;
let count = 0;
for (const [dr, dc] of directions) {
const nr = r + dr;
const nc = c + dc;
if (nr >= 0 && nr = 0 && nc < cols) {
if (board[nr][nc].isMine) count++;
}
}
board[r][c].count = count;
}
}
}
function renderBoard() {
boardEl.innerHTML = "";
boardEl.style.gridTemplateColumns = repeat(${cols}, var(--cell-size, 34px));
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const cell = document.createElement("div");
cell.className = "cell";
cell.dataset.row = r;
cell.dataset.col = c;
const model = board[r][c];
if (model.revealed) {
cell.classList.add("revealed");
if (model.isMine) {
cell.classList.add("mine");
cell.textContent = "💣";
} else if (model.count > 0) {
cell.textContent = model.count;
cell.dataset.count = model.count;
}
} else if (model.flagged) {
cell.classList.add("flagged");
cell.textContent = "🚩";
}
boardEl.appendChild(cell);
}
}
flagsEl.textContent = String(flagsUsed);
minesEl.textContent = String(mines);
}
function startTimer() {
if (timerId) return;
startTime = Date.now();
timerId = setInterval(() => {
const elapsed = Math.floor((Date.now() – startTime) / 1000);
timerEl.textContent = String(elapsed);
}, 1000);
}
function revealCell(r, c) {
if (gameOver) return;
if (r < 0 |
c >= cols) return;
const cell = board[r][c];
if (cell.revealed || cell.flagged) return;
cell.revealed = true;
revealedCount++;
if (cell.isMine) {
endGame(false);
return;
}
if (cell.count === 0) {
for (const [dr, dc] of directions) {
revealCell(r + dr, c + dc);
}
}
}
function toggleFlag(r, c) {
if (gameOver) return;
const cell = board[r][c];
if (cell.revealed) return;
if (cell.flagged) {
cell.flagged = false;
flagsUsed–;
} else {
cell.flagged = true;
flagsUsed++;
}
}
function checkWin() {
const totalCells = rows * cols;
const safeCells = totalCells – mines;
return revealedCount === safeCells;
}
function endGame(won) {
gameOver = true;
clearInterval(timerId);
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
if (board[r][c].isMine) board[r][c].revealed = true;
}
}
renderBoard();
setTimeout(() => {
alert(won ? "You cleared the board!" : "Boom! You hit a mine.");
}, 50);
}
function handleClick(e) {
const target = e.target.closest(".cell");
if (!target) return;
startTimer();
const r = Number(target.dataset.row);
const c = Number(target.dataset.col);
if (e.button === 2) {
toggleFlag(r, c);
} else {
revealCell(r, c);
if (!gameOver && checkWin()) endGame(true);
}
renderBoard();
}
function initGame() {
resetState();
createBoard();
placeMines();
computeCounts();
renderBoard();
}
boardEl.addEventListener("contextmenu", (e) => e.preventDefault());
boardEl.addEventListener("mousedown", handleClick);
newGameBtn.addEventListener("click", initGame);
difficultySelect.addEventListener("change", () => {
const { r, c, m } = parseDifficulty(difficultySelect.value);
rows = r;
cols = c;
mines = m;
initGame();
});
initGame();
This is compact but safe. A few details worth noting:
- I use
mousedownto support right-click for flags. - I block the browser context menu on the board.
- Flood-fill recursion is OK at small board sizes; if you scale to 30×30+, consider an explicit queue to avoid stack overflow.
The Reveal Algorithm: Why Flood-Fill Works
When you click a cell with zero adjacent mines, Minesweeper reveals nearby cells automatically. That’s a classic flood-fill. I keep it recursive for clarity, but I always guard against re-entry:
- stop if out of bounds
- stop if already revealed
- stop if flagged
Think of it like spilling water on a tabletop with ridges. The water spreads until it hits a ridge (a numbered cell). Flood-fill handles that naturally.
If you want to avoid recursion, you can switch to a stack or queue. Here’s a short iterative version you can swap in:
function revealCellIterative(startR, startC) {
const stack = [[startR, startC]];
while (stack.length) {
const [r, c] = stack.pop();
if (r < 0 |
c >= cols) continue;
const cell = board[r][c];
if (cell.revealed || cell.flagged) continue;
cell.revealed = true;
revealedCount++;
if (cell.isMine) {
endGame(false);
return;
}
if (cell.count === 0) {
for (const [dr, dc] of directions) {
stack.push([r + dr, c + dc]);
}
}
}
}
I still prefer the recursive version for readability, but for very large boards the iterative approach is safer.
Common Mistakes and How I Avoid Them
Here are the issues I see most often when people build Minesweeper the first time, along with the fixes I rely on:
- Off-by-one bounds: I always gate neighbors with
nr >= 0 && nr < rowsandnc >= 0 && nc < cols. - Double-counting mines: I compute counts only for non-mine cells.
- Clicking flagged tiles: I block reveal if flagged, otherwise you can instantly lose by accident.
- Revealing after game over: I guard every action with
if (gameOver) return;. - Mines placed twice: I use a loop that checks
isMinebefore placing. - Win condition based on flags: I don’t do that. I use revealed safe cells only, because flags can be wrong.
If you want a stricter style, you can also prevent placing a flag once flagsUsed === mines, but I don’t recommend it for beginner players; let them experiment.
Traditional vs Modern Workflow (2026 Context)
Even with plain JS, you can use modern tooling to make your build and test loop faster. Here’s how I compare a minimal classic setup against a modern lightweight one:
Traditional
Recommendation
—
—
Basic text editor
Modern for faster refactor and hints
No build step
Skip build for a tutorial, but use Vite for modules
Manual clicks
Add a tiny smoke test later
Global variables
Keep globals here, refactor later
None
Use AI for edge-case review, not logicIf you’re teaching or learning, plain files are perfect. If you’re shipping a personal project, I’d move this into a Vite project in minutes.
Performance Considerations Without Overengineering
Minesweeper is small, but there are still some realistic performance concerns:
- Rendering 256 cells is trivial, but 1600 cells (40×40) starts to feel heavy if you re-render everything on each click.
- DOM redraws typically cost 10–20ms at 1600 cells, which can feel sluggish on lower-end devices.
If you scale up, you can:
- Update only the cells that changed rather than re-rendering the board.
- Store DOM references in a 2D array and mutate text/classes directly.
- Use
requestAnimationFramefor batch updates when revealing a large empty area.
For the tutorial-size boards, full re-rendering is simple and safe, and the performance cost is negligible. But it’s worth knowing how to optimize if you ever build a larger grid-based app.
A Safer First-Click Rule (Optional, But Great UX)
Classic Minesweeper guarantees the first click is always safe. It’s a small change that dramatically improves the feel of the game, especially for new players. You can implement this by delaying mine placement until the first click. Then you exclude the clicked cell (and optionally its neighbors) from mine placement.
Here’s the idea in plain language:
- Start with an empty board (no mines).
- When the player clicks the first cell, place mines randomly, but never on that cell.
- Compute counts and proceed normally.
In code, it looks like this pattern:
let firstClick = true;
function handleClick(e) {
const target = e.target.closest(".cell");
if (!target) return;
const r = Number(target.dataset.row);
const c = Number(target.dataset.col);
if (firstClick) {
placeMinesWithExclusion(r, c);
computeCounts();
firstClick = false;
}
startTimer();
if (e.button === 2) {
toggleFlag(r, c);
} else {
revealCell(r, c);
if (!gameOver && checkWin()) endGame(true);
}
renderBoard();
}
function placeMinesWithExclusion(excludeR, excludeC) {
let placed = 0;
while (placed < mines) {
const r = Math.floor(Math.random() * rows);
const c = Math.floor(Math.random() * cols);
if ((r === excludeR && c === excludeC) || board[r][c].isMine) continue;
board[r][c].isMine = true;
placed++;
}
}
I also recommend excluding the 8 neighboring cells so the first click opens a clean area. That tends to feel fair and helps reveal a meaningful chunk of the board early.
Input Handling: Right-Click, Long-Press, and Mobile
Desktop Minesweeper relies on right-click to flag. On mobile, there is no right-click, so you need a long-press or a mode toggle.
Two practical approaches:
- Long-press to flag: use
pointerdown+ timeout, cancel if the pointer moves too far. - Toggle mode: a UI switch where the user selects “Reveal” or “Flag” mode.
I like the toggle for simplicity because long-press is more brittle. A small toggle in your topbar can set a currentMode variable that changes click behavior. If you want long-press, add this idea:
let longPressTimer = null;
boardEl.addEventListener("pointerdown", (e) => {
const target = e.target.closest(".cell");
if (!target) return;
longPressTimer = setTimeout(() => {
const r = Number(target.dataset.row);
const c = Number(target.dataset.col);
toggleFlag(r, c);
renderBoard();
}, 350);
});
boardEl.addEventListener("pointerup", () => {
clearTimeout(longPressTimer);
});
This is optional, but it’s a great example of input handling that mirrors real product constraints.
Robust Win/Loss Feedback Without Alerts
Browser alert() works, but it feels a little dated. You can use a modal or a status banner for a more polished experience. I like a small banner that shows “You Win” or “You Lose” with a subtle animation.
A simple approach:
- Add a status message element under the topbar.
- Update its text and class when the game ends.
Example markup:
Then in JS:
const messageEl = document.getElementById("message");
function showMessage(text, type) {
messageEl.textContent = text;
messageEl.className = message ${type};
}
function endGame(won) {
gameOver = true;
clearInterval(timerId);
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
if (board[r][c].isMine) board[r][c].revealed = true;
}
}
renderBoard();
showMessage(won ? "You cleared the board!" : "Boom! You hit a mine.", won ? "win" : "lose");
}
This keeps your UI consistent and avoids blocking the browser thread.
Accessibility and Keyboard Support
A lot of game tutorials ignore accessibility, but Minesweeper is a perfect candidate for keyboard navigation. The grid is discrete, which maps cleanly to arrow keys.
At a minimum, you can:
- Set each cell to
tabindex="0"so it can be focused. - Use
aria-labelto describe the cell (e.g., “Hidden cell”, “Revealed, 2 adjacent mines”). - Add keyboard listeners for Enter (reveal) and F (flag).
Even a partial implementation makes your game more inclusive and professional. It also forces you to keep your state and UI in sync, which is good engineering practice.
Edge Cases You’ll Hit in Real Use
Here are some more nuanced edge cases that appear after actual playtesting:
- Flood-fill on a mine: If you call flood-fill on a mine accidentally, you’ll reveal too much. I avoid that by checking
isMinebefore any recursion or stack pushes. - Flag count negative: If you toggle flags incorrectly or allow flags on revealed tiles, your counter can go negative. That’s why
toggleFlagreturns immediately for revealed cells. - Timer keeps running after reset: Always clear the timer when you reset state, not just when the game ends.
- Clicking during game over: If the board is still interactive after loss, you can mutate state in unintended ways. Guard all actions with
if (gameOver) return;. - Changing difficulty mid-game: You should reset the board immediately and update the mine count; otherwise, counts will mismatch and you’ll get phantom mines.
These issues sound small, but they’re the difference between a game that feels solid and one that feels “demo-ish.”
A More Modular Architecture (When You Want to Scale)
If you want to grow this into a bigger project, I recommend splitting the logic into three modules:
1) game.js for data model and rules
2) ui.js for rendering
3) input.js for event handling
This makes it easier to unit test the logic and swap rendering implementations (e.g., Canvas). You can still keep it in one file for a tutorial, but it helps to think in layers:
- Model: board state, mine placement, counts
- Controller: how clicks turn into state changes
- View: how state is displayed in DOM
Even if you don’t split files, you can write your functions with this separation in mind.
Comparison: DOM Grid vs Canvas Grid
You can build Minesweeper using DOM elements or a single . Both work, but they optimize for different goals.
Strengths
Best Use
—
—
Easy styling, accessibility, quick to build
Tutorials, small boards
Fast rendering, smooth animation, compact
Large boards, visual effectsIf your goal is teaching or learning, DOM grid is the right default. If you want to push performance or add fancy transitions, a canvas is a fun upgrade.
Optional Features That Add Real Value
Once the core game works, a few small additions can turn it into a polished mini-project:
- Difficulty presets: Already included, but you can add a “Custom” option with manual rows/cols/mines.
- Best time tracking: Store the best time per difficulty in
localStorage. - Sound effects: Tiny click and explosion sounds enhance feedback.
- Animations: A subtle scale or fade when revealing cells can make the board feel alive.
- Undo: Store a simple stack of actions. It’s hard to get perfect but teaches state history.
Here’s a quick best-time pattern:
function updateBestTime(seconds) {
const key = best_${rows}x${cols}x${mines};
const current = Number(localStorage.getItem(key) || "0");
if (!current || seconds < current) {
localStorage.setItem(key, String(seconds));
}
}
Call it when the player wins.
Testing Strategy (Lightweight but Real)
You don’t need a full test suite for a small project, but a few logic tests can prevent regressions. If I were to test this project quickly, I’d focus on pure functions:
parseDifficultyreturns the right numbers.computeCountsgives correct adjacency numbers.checkWinreturns true only when safe cells are revealed.
If you want a tiny no-build test, you can add a hidden “debug mode” that logs the board with mines and counts. Another option is to write a Node-based script that imports your game logic if you split it into modules.
Debugging Tips That Save Time
When something feels wrong, I always add two temporary utilities:
1) A board logger that prints mines and counts in a readable format
2) A dev mode that reveals all mines without ending the game
Example logger:
function logBoard() {
const rowsText = board.map(row => row.map(cell => {
if (cell.isMine) return "*";
return String(cell.count);
}).join(" ")).join("\n");
console.log(rowsText);
}
It’s ugly, but it’s fast and it shows you if counts are correct at a glance.
Deployment: The Simplest Possible Path
Since this is plain HTML/CSS/JS, deployment is easy:
- Drag the folder into a static host.
- Or use a quick file server locally.
If you do want a minimal toolchain, Vite is the lightest path. But for a tutorial, a single index.html is perfect. The key is that you can ship this game without any build step or bundler.
Practical Scenarios: When to Use This Pattern
The same grid logic you build for Minesweeper applies to many real interfaces:
- Seating charts (like theaters or booking grids)
- Calendar day blocks
- Pixel editors
- Tactical board games
- Spreadsheet-like layouts
The important skill here isn’t Minesweeper itself—it’s the separation of data model from UI and the ability to handle grid-based interactions reliably.
When Not to Use This Pattern
If you’re building a game with high frame-rate animation or thousands of cells updated every frame, the DOM grid will struggle. In those cases, canvas or WebGL is the better choice. But for anything under a few thousand interactive cells, a DOM grid is fine and far more accessible.
Extended Code Patterns for Real Projects
Here are a few “production-grade” patterns you can incorporate without much extra work:
- Immutable updates: If you later move to React or Vue, you’ll likely want immutable board updates. Build that habit early by returning new objects instead of mutating the board directly.
- Event delegation: You already use it by listening on the board and checking
.cell. That’s good for performance and simpler than adding many listeners. - State guards: Keep the guard checks at the top of each function. It makes the flow safe and easier to read.
- Pure functions where possible:
computeCountsandcheckWincan be pure, which makes them easy to test.
A More Reliable Flood-Fill With a Queue
If you want the iterative flood-fill with clear boundaries, I recommend a queue. It makes the order of expansion more predictable and avoids deep recursion. Here’s a more explicit version that’s easier to debug:
function revealCellBFS(startR, startC) {
const queue = [[startR, startC]];
while (queue.length) {
const [r, c] = queue.shift();
if (r < 0 |
c >= cols) continue;
const cell = board[r][c];
if (cell.revealed || cell.flagged) continue;
cell.revealed = true;
revealedCount++;
if (cell.isMine) {
endGame(false);
return;
}
if (cell.count === 0) {
for (const [dr, dc] of directions) {
queue.push([r + dr, c + dc]);
}
}
}
}
Queue-based flood-fill can feel slightly slower due to shift() overhead, so if you care about speed, use a stack and pop() instead.
Minesweeper UX: What Players Expect
A subtle part of game design is meeting expectations. Here are a few behaviors that players subconsciously look for:
- First click safety: Already discussed, highly recommended.
- Quick flagging: Right-click or a single keystroke to flag, not a long menu.
- Accurate counters: If flags used exceed mines, players should see it and adjust.
- Clear end state: When the game ends, the board should show all mines.
If you implement these, your game will feel “finished” rather than “demo.”
Expansion Strategy (Applied)
Here’s how you can keep expanding this project without breaking its simplicity:
- Add a custom difficulty form: input rows, cols, mines; validate that mines < rows*cols.
- Add a “hint” button: reveal one safe cell; deduct time or track hints used.
- Add themes: a light theme and a high-contrast theme; toggle with a button.
- Add stats: games played, wins, best time, win percentage.
Each of these adds tangible value without complicating the core game logic.
Final Thoughts: Why This Project Is Worth It
Minesweeper looks simple, but it teaches the exact combination of skills that matter in real front-end work: state modeling, event handling, performance awareness, and user feedback. The core pattern you learn here—data model first, UI as a projection—is the same pattern used in complex dashboards, interactive maps, and even real-time collaborative apps.
If you complete this game and polish it with a few optional features, you’ll have a project that’s not only fun to play but also a strong demonstration of engineering fundamentals. It’s a compact project with a surprisingly rich set of lessons, and it’s a perfect stepping stone to more advanced grid-based apps.
If you want, you can take this further by migrating the model into a small module, adding a test harness, and swapping the DOM rendering with a canvas renderer. But even in its simplest form, this Minesweeper clone is an excellent training ground and a legitimate portfolio piece.


