Background
I never understood as a kid how this game worked, I used to open this game up in Win95 IBM PC, and just click randomly around until I hit a mine. Now that I do, I decided to recreate the game with Svelte.
Lets break it down - How the game works ?
Minesweeper is a single player board game with a goal of clearing all tiles of the board. The player wins if he clicks all the tiles on the board without mines and loses if he clicks on a tile that holds a mine.
Game starts with all tiles concealing what is beneath it, so its a generally a lucky start to the game, you could click on either a empty tile or a mine. In case its not a empty tile or a mine, it could hold a count of mine that are present adjacent to the currently clicked tile.
As an example here 1 at the top left indicates there is 1 mine in of its adjacent cells. Now what do we mean by adjacent cells ? All cells that surround a cell become its adjacent cells. This allows players to strategize about clicking on which tile next. In case a user is not sure whether there is a mine or not underneath a tile, he can flag it by right clicking on the tile.
Thinking about the game logic
The game is pretty simple at first glance, just give a board of n x m rows and keep switching the cell state to display the content that each cell holds. But there is a case where if multiple empty cells are connected and you click on it, the game should keep opening adjacent cells if they are empty too, that gives its iconic ripple effect.
Gets quite tricky building all these conditions in, so lets break down into smaller tasks at hand.
- Create a board state - a n x m array (2d array)
- A cell inside a board could be / can hold: a mine, a count, or empty.
- A cell can be: clicked / not clicked.
- A cell can be: flagged / not flagged.
- Represent cell with these properties: row, col, text, type(mine, count, empty), clicked(clicked / not clicked), flagged (flagged / not flagged).
- Finally we could represent a game state like this: on, win, lose
- We also need to have a way to define bounds, while we create that ripple, we don't want the ripple to open up mines too! So we stop open tiles once we hit a boundary of mine!
- A game config: row, cols, mine count -> This could help us add difficulty levels to our game easily. Again this is optional step.
- This game is just calling a bunch of functions...on click event.
Creating board state
Creating a board is simple task of creating a 2d array given that we know the number of rows and columns. But along with that we also want to put mines at random spots in the board and annotate the board with the mine count in the adjacent cells of mine.
We create a list of unique random indices to put mines at, below function is used to do that,
function uniqueRandomIndices(
row_count: number,
col_count: number,
range: number,
) {
const upper_row = row_count - 1;
const upper_col = col_count - 1;
const idxMap = new Map();
while (idxMap.size !== range) {
const rowIdx = Math.floor(Math.random() * upper_row);
const colIdx = Math.floor(Math.random() * upper_col);
idxMap.set(`${rowIdx}_${colIdx}`, [rowIdx, colIdx]);
}
return [...idxMap.values()];
}
So here is function that we use to create board state
function createBoard(
rows: number,
cols: number,
minePositions: Array<Array<number>>,
) {
const minePositionsStrings = minePositions.map(([r, c]) => `${r}_${c}`);
let boardWithMines = Array.from({ length: rows }, (_, row_idx) =>
Array.from({ length: cols }, (_, col_idx) => {
let cell: Cell = {
text: "",
row: row_idx,
col: col_idx,
type: CellType.empty,
clicked: CellClickState.not_clicked,
flagged: FlagState.not_flagged,
};
if (minePositionsStrings.includes(`${row_idx}_${col_idx}`)) {
cell.type = CellType.mine;
cell.text = CellSymbols.mine;
}
return cell;
}),
);
return boardWithMines;
}
Once we do have a board with mines in random spots, we will end up with a 2d array. Now the important part that makes it possible to play at all, adding mine count to adjacent cells of a mine. For this we have couple of things to keep in mind before we proceed with it.
We have to work within the bounds for any given cell, what are these bounds ?
Bounds here simply means range of rows and cols through which we can iterate through to get all adjacent cells. We need to make sure these bounds never cross the board, else we will get an error or things might not work as expected.
So adjacent cells means each cell that touches current cell on sides or vertices. All the red cells are adjacent cells to the green cell in the middle as per the figure below.
const getMinIdx = (idx: number) => {
if (idx > 0) {
return idx - 1;
}
return 0;
};
const getMaxIdx = (idx: number, maxLen: number) => {
if (idx + 1 > maxLen - 1) {
return maxLen - 1;
}
return idx + 1;
};
const getBounds = (row_idx: number, col_idx: number) => {
return {
row_min: getMinIdx(row_idx),
col_min: getMinIdx(col_idx),
row_max: getMaxIdx(row_idx, rows),
col_max: getMaxIdx(col_idx, cols),
};
};
function annotateWithMines(
boardWithMines: Cell[][],
minePositions: number[][],
) {
for (let minePosition of minePositions) {
const [row, col] = minePosition;
const bounds = getBounds(row, col);
const { row_min, row_max, col_min, col_max } = bounds;
for (let row_idx = row_min; row_idx <= row_max; row_idx++) {
for (let col_idx = col_min; col_idx <= col_max; col_idx++) {
const adjacentCell = boardWithMines[row_idx][col_idx];
switch (adjacentCell.type) {
case CellType.count:
const currentCount: number =
+boardWithMines[row_idx][col_idx].text;
boardWithMines[row_idx][col_idx].text = `${currentCount + 1}`;
break;
case CellType.empty:
boardWithMines[row_idx][col_idx].text = "1";
boardWithMines[row_idx][col_idx].type = CellType.count;
break;
case CellType.mine:
break;
}
}
}
}
return boardWithMines;
}
We now have a board with mines and now we need to display this board with some html/ CSS,
<div class="grid" style="--rows:{rows};--cols:{cols};--cell-size:{cellSize}">
{#each board as rows}
{#each rows as cell}
<button
tabindex={cell.clicked === CellClickState.clicked ? -1 : 0}
on:mousedown={cell.clicked === CellClickState.not_clicked &&
game_state === GameState.on
? handleCellClick
: _noop}
on:contextmenu|preventDefault
data-row={cell.row}
data-col={cell.col}
class="cell"
class:gameover={game_state !== GameState.on}
class:clicked={cell.clicked === CellClickState.clicked}
>{getCellContent(cell)}</button
>
{/each}
{/each}
</div>
<style>
.grid {
--border-width: 1px;
display: grid;
grid-template-rows: repeat(var(--rows), var(--cell-size));
grid-template-columns: repeat(var(--cols), var(--cell-size));
place-content: center;
background-color: #333;
color: #fff;
width: calc(
(var(--cols) * var(--cell-size)) + var(--border-width) * var(--cols)
);
height: calc(
(var(--rows) * var(--cell-size)) + var(--border-width) * var(--rows)
);
}
.cell {
border: var(--border-width) solid #fff;
overflow-wrap: break-word;
display: flex;
justify-content: center;
align-items: center;
background-color: grey;
font-size: 20px;
}
.cell.gameover {
pointer-events: none;
}
.cell:not(.clicked):hover {
background-color: rgb(30 147 117 / 57%);
cursor: pointer;
}
.cell.clicked {
background-color: lightgrey;
}
</style>
This renders a grid on page, and certain styles applied conditionally if a cell is clicked, flagged. You get a good old grid like in the screenshot below
Cell and the clicks...
Cell and its state is at the heart of the game. Let's see how to think in terms of various state a cell can have
A cell can have:
- Empty content
- Mine
- Count - adjacent to a mine
A cell can be:
- Open
- Close
A cell can be:
- Flagged
- Not Flagged
export enum CellType {
empty = 0,
mine = 1,
count = 2,
}
export enum CellClickState {
clicked = 0,
not_clicked = 1,
}
export enum CellSymbols {
mine = "💣",
flag = "🚩",
empty = "",
explode = "💥",
}
export enum FlagState {
flagged = 0,
not_flagged = 1,
}
export type Cell = {
text: string;
row: number;
col: number;
type: CellType;
clicked: CellClickState;
flagged: FlagState;
};
Rules for a cell:
- On left click, click a cell opens and on a right click cell is flagged.
- A flagged cell cannot be opened but only unflagged and then opened.
- A click on empty cell should open all its adjacent empty cells until it hits a boundary of mine cells.
- A click on cell with mine, should open all the mine with cell and that end the game
With this in mind, we can proceed with implementing a click handler for our cells
const handleCellClick = (event: MouseEvent) => {
switch (event.button) {
case ClickType.left:
handleLeftClick(event);
break;
case ClickType.right:
handleRightClickonCell(event);
break;
}
return;
};
Simple enough to understand above function calls respective function mapped to each kind of click , left / right click
const explodeAllMines = () => {
for (let [row, col] of minePositions) {
board[row][col] = {
...board[row][col],
text: CellSymbols.explode,
};
}
};
const setGameLose = () => {
game_state = GameState.lose;
};
const handleMineClick = () => {
for (let [row, col] of minePositions) {
board[row][col] = {
...board[row][col],
clicked: CellClickState.clicked,
};
}
setTimeout(() => {
explodeAllMines();
setGameLose();
}, 300);
};
const clickEmptyCell = (row_idx: number, col_idx: number) => {
// recursively click adjacent cells until:
// 1. hit a boundary of mine counts - is it same as 3 ?
// 2. cells are already clicked
// 3. cells have mines - same as 1 maybe
// 4. cells that are flagged - need to add a flag feature as well
if (board[row_idx][col_idx].type === CellType.count) {
return;
}
const { row_min, row_max, col_min, col_max } = getBounds(row_idx, col_idx);
// loop over bounds to click each cell within the bounds
for (let r_idx = row_min; r_idx <= row_max; r_idx++) {
for (let c_idx = col_min; c_idx <= col_max; c_idx++) {
const cell = board[r_idx][c_idx];
if (
![CellType.mine].includes(cell.type) &&
cell.flagged !== FlagState.flagged &&
cell.clicked === CellClickState.not_clicked
) {
board[r_idx][c_idx] = { ...cell, clicked: CellClickState.clicked };
clickEmptyCell(r_idx, c_idx);
}
}
}
}
const handleLeftClick = (event: MouseEvent) => {
if (event.target instanceof HTMLButtonElement) {
const row_idx = Number(event.target.dataset.row);
const col_idx = Number(event.target.dataset.col);
const cell = board[row_idx][col_idx];
if (
cell.clicked === CellClickState.not_clicked &&
cell.flagged === FlagState.not_flagged
) {
board[row_idx][col_idx] = {
...cell,
clicked: CellClickState.clicked,
};
switch (cell.type) {
case CellType.mine:
handleMineClick();
break;
case CellType.empty:
clickEmptyCell(row_idx, col_idx);
break;
case CellType.count:
break;
default:
break;
}
}
} else {
return;
}
};
Left click handler - handles most of the game logic, it subdivided into 3 sections based on kind of cell the player clicks on:
Mine cell is clicked on
If a mine cell is clicked we call handleMineClick() function, that will open up all the mine cells, and after certain timeout we display an explosion icon, we stop the clock and set the game state to lost.Empty cell is clicked on
If a empty cell is clicked on we need to recursively click adjacent empty cells until we hit a boundary of first counts. As per the screenshot, you could see when I click on the bottom corner cell, it opens up all the empty cells until the first boundary of counts.Count cell is clicked on
Handling count cell is simply revealing the cell content beneath it.
Game state - Final bits and pieces
Game Difficulty can be configured on the basis of ratio of empty cells to mines, if the mines occupy 30% of the board, the game is too difficult for anyone to play, so we set it up incrementally higher up to 25%, which is still pretty high
export const GameDifficulty: Record<GameModes, GameConfig> = {
baby: { rows: 5, cols: 5, mines: 2, cellSize: "40px" }, // 8% board covered with mines
normal: { rows: 9, cols: 9, mines: 10, cellSize: "30px" }, // 12% covered with mines
expert: { rows: 16, cols: 16, mines: 40, cellSize: "27px" }, // 15% covered with mines
"cheat with an AI": { rows: 16, cols: 46, mines: 180, cellSize: "25px" }, // 25% covered with mines - u need to be only lucky to beat this
};
Game state is divided into 3 states - win, lose and on
export enum GameState {
on = 0,
win = 1,
lose = 2,
}
export type GameConfig = {
rows: number;
cols: number;
mines: number;
cellSize: string;
};
We also add a timer for the game, that start as soon as the game starts, I have separated it in a timer.worker.js, but it might be an overkill for a small project like this. We also have a function to find the clicked cells count, to check if user has clicked all the cells without mines.
let { rows, cols, mines, cellSize } = GameDifficulty[difficulty];
let minePositions = uniqueRandomIndices(rows, cols, mines);
let game_state: GameState = GameState.on;
let board = annotateWithMines(
createBoard(rows, cols, minePositions),
minePositions,
);
let clickedCellsCount = 0;
let winClickCount = rows * cols - minePositions.length;
$: flaggedCellsCount = mines;
$: clickedCellsCount = calculateClickedCellsCount(board);
$: if (clickedCellsCount === winClickCount) {
game_state = GameState.win;
}
let timer = 0;
let intervalWorker: Worker;
let incrementTimer = () => {
timer += 1;
};
$: if (clickedCellsCount >= 1 && timer === 0) {
intervalWorker = new Worker("timer.worker.js");
intervalWorker.addEventListener("message", incrementTimer);
intervalWorker.postMessage({
type: "START_TIMER",
payload: { interval: 1000 },
});
}
$: timerDisplay = {
minute: Math.round(timer / 60),
seconds: Math.round(timer % 60),
};
$: if (game_state !== GameState.on) {
intervalWorker?.postMessage({ type: "STOP_TIMER" });
}
const calculateClickedCellsCount = (board: Array<Array<Cell>>) => {
return board.reduce((acc, arr) => {
acc += arr.reduce((count, cell) => {
count += cell.clicked === CellClickState.clicked ? 1 : 0;
return count;
}, 0);
return acc;
}, 0);
};
And we have got a great minesweeper game now !!!
This is a very basic implementation of Minesweeper, we could do more with it, for starters we could represent the board state with bitmaps, which makes it infinitely...Could be a great coding exercise. Color coding the mine count could be a good detail to have, there are so many things to do with it...this should be a good base to work with...
In case you want to look at the complete code base you could fork / clone the repo from here:
Top comments (0)