Have you played Minesweeper? Make one yourself

Minesweeper might be the first computer game many students played when they first encountered computers. This time, we will create a Minesweeper game using web front-end programming, with about 140 lines of main code (excluding comments).

A nostalgic moment, the image below shows the Minesweeper game from Windows 3.1

Minesweeper on Windows 3.1

For those who have never played this game, the rules are very simple: there are only two actions, 1 to dig, and 2 to mark mines. You win by marking all the mines with flags, and you lose if you dig into a mine. The numbers in the opened squares represent the number of mines in the surrounding squares. The center square has 8 surrounding squares, edge squares have 5, and corner squares have 3.

The image below is the Minesweeper game we created, click here to experience the completed version. Left-click represents the digging action; right-click or long press represents the marking action.

Web-based Minesweeper game

Preparation Phase

This article requires a basic understanding of HTML, JavaScript, and CSS. However, don't worry; I will introduce key points during the writing process. If you lack confidence, you can refer to my previous articles for supplementary information.

You can use VSCode or Atom as your code editor. Of course, theoretically, Notepad can also be used, but it lacks syntax highlighting and helpful prompts, making it more tedious to write code.

Create a folder to store the project files, naming it js-minesweeper or another name you prefer, but try to avoid using Chinese names. Inside, create the following three files (the entire project only requires these three files):

File 1 index.html

Write the following content inside

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Minesweeper</title>
  <script src="app.js"></script>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <div class="grid"></div>
</body>
</html>

Once you save this file, you won’t need to modify it much. The first line is usually used to declare the file format; <!DOCTYPE html> tells the browser that this is a file conforming to the HTML5 standard. The HTML file has a nested structure, with the root node html containing all content, followed by the head and body nodes.

The head node defines the page's encoding format, title, and external files to be referenced.

In the body node, we define a div node with the class name grid. The entire Minesweeper game page elements will be dynamically inserted into this node using javascript code, essentially serving as a container (large frame) that holds all the elements. The size of this container depends on the number of elements inside and the definitions in css.

File 2 app.js

The main code file, which is referenced by the index.html file (see line 7 of the above code). The following sections will detail the content written within it.

File 3 style.css

The main style file used to define the color appearance of page elements. It is also referenced by the index.html file.

*, body {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
  font-family: Arial, Helvetica, sans-serif;
}
.grid {
  white-space: nowrap;
  user-select: none;
  -moz-user-select: none;
  -ms-user-select: none;
  -webkit-user-select: none;
  -webkit-touch-callout: none;
}

First, define the global appearance by removing margins, padding, and setting a sans-serif font; secondly, define the grid container to not wrap automatically, as long-press operations are needed on mobile, so the text selection feature is disabled.

Getting to the Point

Next, it's time to officially start working. First, open app.js and write the following code:

// app.js
document.addEventListener('DOMContentLoaded', () => {
  // Execute after all files have loaded, write all remaining js code inside these curly braces
});

The document refers to the entire page, and addEventListener adds an event listener function that listens for the DOMContentLoaded event. The document object will emit this event after all referenced files on the page have loaded, and then the arrow function on the right will be executed.

Declaring Global Variables

// app.js
// Continuing from the previous text, omitted some code...
const cols = 10; // Number of columns in the game board, temporarily set to 10 columns
const rows = 10; // Number of rows in the game board, temporarily set to 10 rows
const ratio = 0.2; // Ratio of mines, temporarily set to 20%, meaning 20 mines in 100 squares
const blocks = []; // Array of all squares, used to store all generated squares
let isGameOver = false; // Flag indicating whether the game is over, initially set to not over

The above code defines the constants (const) and variables (let) that will be used next. Constants are declared for values that will not change during the game, such as the mine ratio, number of rows, and number of columns; variables are declared for values that may change.

Let’s focus on the blocks array, which serves as a container for all squares. A single square will be referred to as a block in the subsequent code, with "block" meaning a piece. Thus, its container is in plural form. Each square is the basic element of this game; it may contain a mine or not, display numbers, flags, the effect of exploding mines, and have different colors.

Create Board

Next, it's time to declare a function to create the board, generating the grid of the entire board according to the given number of rows and columns. See the code below.

// app.js
// Continuation of the previous text, some code omitted...
function createBoard() {
  const grid = document.querySelector('.grid');
  for (let i = 0; i < cols * rows; i++) {
    let block = document.createElement('div');
    block.id = i;
    block.innerText = i; // Display the index in the grid
    block.addEventListener('click', clickHandler);
    block.addEventListener('contextmenu', rightClickHandler);
    grid.appendChild(block);
    blocks.push(block);
    if (i % cols == cols - 1)
      grid.appendChild(document.createElement('br'));
  }
}
createBoard();

The function is named createBoard. On line 4, it retrieves the div container grid from the HTML page, making it convenient to add the grid squares to the container in the following code; line 5 creates a loop to generate all squares, currently creating a total of 100 squares in a 10-row by 10-column grid, with the variable i ranging from 0 to 99.

Inside the loop, on line 6, it uses the createElement method to create a page element; line 7 sets the id attribute of the square to the index i; line 8 displays the index in the square, which is convenient for debugging during development, and will be removed after confirming there are no issues; lines 9 and 10 add two event handler functions to the block, with the clickHandler function handling the click event and the rightClickHandler function handling the contextmenu event. These two functions have not been declared yet and will be written in the upcoming User Operation Event Handling section; lines 11 and 12 add the squares to the HTML page's container and also to the JS code's blocks array, the former for displaying them on the page and the latter for easier control in the code later, as the order of addition matches the index, so the array index and square index are the same.

Lines 13 and 14 add a line break element to the block container after every 10 squares (depending on the value of cols). For example, when i equals 9, 9 % 10 equals 9, meaning that every time the remainder is 9, it indicates the last element of each row.

On line 17, the createBoard function is immediately called after its declaration.

Open index.html in the browser to check the running effect; what you see is still a blank page because we have not yet set the appearance for these squares. Open the style.css file in the editor and save the following content.

/* style.css */
/* 接上文,省略部分代码... */
.grid div {
  width: 35px;
  height: 35px;
  color: darkblue;
  background-color: lightblue;
  display: inline-block;
  text-align: center;
  vertical-align: middle;
  line-height: 35px;
  border: 1px dashed grey;
  cursor: pointer;
}

The above code defines the width and height of the squares as 35 pixels, the text color as dark blue, the background color as light blue, the display mode as inline-block (div elements are block elements by default and occupy a whole line), centers the text, sets the border to a 1-pixel gray dashed line, and changes the mouse pointer to a hand.

Refresh the browser, and you should see a 10×10 grid of light blue squares with dashed lines, with numbers representing their sequence.

Bomb Placement

Next, we need to randomly place a specified number of bombs in these squares. The overall idea is to first calculate the total number of bombs and the total number of non-bombs, generate these states according to their quantity and put them into the same array, and then shuffle their order.

// app.js
// Continuing from the previous text, some code omitted...
function setupBomb() {
  let amount = Math.floor(cols * rows * ratio);
  let array = new Array(cols * rows - amount).fill(false);
  array = array.concat(new Array(amount).fill(true));
  array = array.sort(() => Math.random() - 0.5);
  blocks.forEach((block, id) => {
    block.hasBomb = array[id];
    block.total = 0;
    // Calculate the total number of bombs around
    getAroundIds(id).forEach((_id) => {
      block.total += array[_id] ? 1 : 0;
    });
  });
}
setupBomb();

In the above code, line 4, amount is the total number of bombs; line 5 creates an array and fills it with non-bomb states false; line 6 fills it with bomb states true; line 7 shuffles all the elements randomly;

Starting from line 8, it iterates through all the squares, setting whether or not each square contains a bomb in the hasBomb property of each square. The forEach function will pass each square object block from blocks and its index id to the arrow function for execution;

Starting from line 10, it sets the total number of bombs around each square total, with an initial value of 0; line 12 is a custom function getAroundIds, which, as the name suggests, we expect this function to work as follows: Given any index based on the current board's row and column layout, it can return an array of indices for all surrounding squares; for example, inputting 0 would yield [1,11,10], and inputting 11 would yield [0,1,2,12,22,21,20,10]; we will implement this function later, and for now, we will assume it works smoothly. In the arrow function, we check whether these surrounding squares contain bombs; if they do, we add 1 to total, otherwise we add 0 (line 13).

getAroundIds Function

Before writing this function, we looked at the previously generated chessboard effect and noticed:

Most squares near the center of the chessboard are always surrounded by the 8 squares: upper left, upper, upper right, right, lower right, lower, lower left, and left; while the edge squares have at most 5 surrounding squares, and the corner squares have at most only 3 surrounding squares. We need to pay special attention to these situations.

There are many different ways to implement the functionality of this function; below is my version:

// app.js
// Continuing from the previous text, some code omitted...
function getAroundIds(id) {
  const ids = [];
  const notTopEdge = (id) => id >= cols;
  const notLeftEdge = (id) => id % cols != 0;
  const notRightEdge = (id) => id % cols != cols - 1;
  const notBottomEdge = (id) => id < cols * rows - cols;
  if (notTopEdge(id)) {
    if (notLeftEdge(id)) {
      ids.push(id - cols - 1); // top left
    }
    ids.push(id - cols); // top
    if (notRightEdge(id)) {
      ids.push(id - cols + 1); // top right
    }
  }
  if (notRightEdge(id)) {
    ids.push(id + 1); // right
  }
  if (notBottomEdge(id)) {
    if (notRightEdge(id)) {
      ids.push(id + cols + 1); // bottom right
    }
    ids.push(id + cols); // bottom
    if (notLeftEdge(id)) {
      ids.push(id + cols - 1); // bottom left
    }
  }
  if (notLeftEdge(id)) {
    ids.push(id - 1); // left
  }
  return ids;
}

Line 4 defines an empty array to store the function's return results. From the last line of the function body (line 33), we can see that regardless of the execution result, ids is returned;

Lines 5-8 contain some internal helper functions for auxiliary judgment aimed at improving code quality and readability. If the parameter id meets the conditions, it returns true (logical truth value). notTopEdge indicates it is not the top edge, notLeftEdge indicates it is not the left edge, notRightEdge indicates it is not the right edge, and notBottomEdge indicates it is not the bottom edge;

Starting from line 9, we determine and calculate the indices of the squares surrounding the square in a clockwise direction. For example, as long as the input square index is not the upper left corner, there will be the upper left square, and each calculated index will be inserted (push) into the result array.

User Operation Event Handling

Now it's time to handle user operation events. The first operation is to uncover a square, and the second operation is to mark a mine; these correspond to the clickHandler and rightClickHandler callback functions mentioned earlier.

clickHandler Callback Function

This is the callback function for the square click click event, used to respond to the user's mine uncovering operation. Here's the code with comments added; the logic should be quite clear.

// app.js
// Continuing from the previous text, some code omitted...
function clickHandler(e) {
  if (isGameOver) {
    return; // If the game is over, do not proceed further
  }
  let block = e.currentTarget;
  if (block.checked || block.marked) {
    return; // If already dug or marked, do not proceed further
  }
  if (block.hasBomb) {
    // This block has a bomb, game over
    isGameOver = true;
    blocks.forEach(block => {
      // Trigger each bomb, displaying an explosion emoji
      if (block.hasBomb) {
        block.classList.add("boom");
        block.innerText = '💥';
      }
    });
    setTimeout(() => {
      // Show game over prompt, asking if the player wants to play again
      if (confirm('BOOM...GAME OVER! Play Again?')) {
        // If confirmed to play again, reload the page
        location.reload();
      }
    }, 50);
  } else {
    // Case when there is no bomb in this block
    if (block.total == 0) {
      // No bombs nearby, enter a safe block for recursive checking, detailed below
      checkBlock(block);
    } else {
      // There are bombs nearby, show as dug and display the number of bombs
      block.classList.add("checked");
      block.innerText = block.total;
    }
  }
  // Mark as dug
  block.checked = true;
}

In the above code, lines 17 and 35 add two types of CSS classes to the blocks: one is boom for explosions, and the other is checked for dug blocks. Additionally, we will introduce a new class safe for blocks that are dug and have no bombs around them. The corresponding effects should be added in style.css with the following CSS code:

/* style.css */
/* Continuing from the previous text, some code omitted... */
.grid div.boom {
  background-color: red;
  font-size: larger;
  cursor: default;
}

.grid div.checked {
  background-color: darkgray;
  cursor: default;
}

.grid div.safe {
  background-color: lightgreen;
  cursor: default;
}

The approach here is to set the background color of the grid to red during an explosion, gray for the uncovered safe blocks, and light green for the surrounding uncovered safe blocks.

checkBlock Safe Block Detection

The purpose of this function is to automatically uncover all connected safe blocks whenever a safe block with no surrounding mines is dug up (when the total value is 0). This involves the use of recursion.

function checkBlock(block) {
  // The block that can enter this function is surrounded by no mines, so it is displayed as safe
  block.classList.add("safe");
  // Iterate through the surrounding blocks
  getAroundIds(Number(block.id)).forEach(id => {
    let a_block = blocks[id];
    if (a_block.checked || a_block.marked)
      return; // Skip already dug or marked blocks
    a_block.checked = true;
    if (a_block.total == 0) {
      // No mines nearby, recursively check if the surrounding blocks are also safe
      checkBlock(a_block);
    } else {
      // There are mines nearby, display as dug up and show the number of mines
      a_block.classList.add("checked");
      a_block.innerText = a_block.total;
    }
  });
}

As illustrated in the figure below, the colored numbers indicate the order in which the checkBlock function recursively checks the safe blocks. Blocks of the same color represent the same function call. Starting from the top right corner, a total of 8 recursive calls can be seen, terminating when no more qualifying safe blocks are found; the recursion order is: start -> (1,2,3), 1 -> (4,5), 2 -> (6,7,8), 6 -> (9), 9 -> (10,11), 10 -> (12,13), 13 -> (end).

Safe Block Detection Recursion

rightClickHandler Callback Function

This is the callback function for the contextmenu event triggered by right-clicking on a block, used to respond to the user's flagging operation, directly accompanied by the commented code.

function rightClickHandler(e) {
  e.preventDefault();
  if (isGameOver) {
    return; // If the game is over, do not proceed further
  }
  let block = e.currentTarget || e.target;
  if (block.checked) {
    return; // If already opened, do not proceed further
  }
  // Toggle the marking status of the block; marked becomes unmarked, unmarked becomes marked
  block.marked = !block.marked;
  block.classList.toggle('marked');
  // When marked, display an emoji flag symbol on the block
  block.innerText = block.marked ? '🚩' : '';
  if (blocks.every(block => (block.hasBomb && block.marked) || (!block.hasBomb && !block.marked))) {
    // If every bomb is marked and the unmarked ones are not bombs, declare victory
    isGameOver = true;
    setTimeout(() => {
      alert('YOU WIN!'); // Notify you that you won
    }, 50);
  }
}

All the code is now complete, and the task is accomplished! Open your browser and give it a try.