Building Minesweeper in JavaScript: Part 5
We’ve now got it so that when the player clicks a mine, we reveal all mines across the board, signaling the end of the game. But what happens if the player clicks a space that isn’t a mine? In the the previous post, we laid out the two possible outcomes depending on the spaces that surround the space itself. The first of those outcomes is easier than the second, so we’ll focus on that next. We can represent it as pseudocode like this:
- If any of the spaces surrounding the space is a mine:
- Show the space as uncovered
- Count the surrounding mines and show them inside the space
Read over this, but don’t worry about fully deciphering this at the moment. For
now, let’s store this information in minesweeper.js
so we have a placeholder:
...
for (let rowIndex = 0; rowIndex < 9; rowIndex++) {
const row = $("<tr>");
board.append(row);
for (let columnIndex = 0; columnIndex < 9; columnIndex++) {
const space = $("<td>");
const possibleMineLocation = (rowIndex * 9) + columnIndex;
const isMine = (mineLocations.indexOf(possibleMineLocation) !== -1);
if (isMine) {
mines.push(space);
}
row.append(space);
space.on("click", () => {
if (isMine) {
mines.forEach(mine => {
mine.addClass("mine");
});
- }
+ } else {
+ // If any of the spaces surrounding the space is a mine:
+ // Show the space as uncovered
+ // Count the surrounding mines and show them inside the space
+ }
});
}
}
Uncovering spaces
Before we dive into implementing these new changes, let’s talk about how they’ll affect the HTML and CSS.
So far, our board is made up of a series of td
elements. Whenever the player
clicks on a mine, a mine
class is added to any spaces that are mines, and the
space receives a black background.
Now we’re introducing this concept of “uncovering” a space that isn’t a mine. In
a typical game of Minesweeper, these spaces are pushed in visually. We could
recreate this with CSS, but we don’t want to go overboard. For right now, let’s
say that these spaces receive a gray background instead of a black one. In order
to apply this style, let’s give uncovered spaces an uncovered
class. We’ll
modify minesweeper.css
like this:
#board {
height: 200px;
left: 50%;
margin-left: -100px;
margin-top: -100px;
position: absolute;
top: 50%;
width: 200px;
}
td {
border: 1px solid black;
cursor: pointer;
}
-td:not(.mine):hover {
+td:not(.uncovered):hover {
background-color: #ddd;
}
+.uncovered {
+ background-color: gray;
+ color: white;
+}
+
.mine {
background-color: black;
}
Now, whenever we write code, one thing that we should do constantly is to put ourselves in the shoes of someone who is looking over our code for the first time and imagine how they might interpret it. While we wouldn’t expect them to see what’s in our head just by reading it, we should help them gain as much of an understanding as possible. Giving consistent and accurate names to various items in our code will go a long way in achieving this.
In this case, our CSS mentions two categories of things: “uncovered” things and “mines”. We never explicitly mention that these “mines” are also supposed to be uncovered, so our code paints an incomplete picture of reality and could be confusing. Let’s address that briefly:
#board {
height: 200px;
left: 50%;
margin-left: -100px;
margin-top: -100px;
position: absolute;
top: 50%;
width: 200px;
}
td {
border: 1px solid black;
cursor: pointer;
}
td:not(.uncovered):hover {
background-color: #ddd;
}
-.uncovered {
+.uncovered:not(.mine) {
background-color: gray;
color: white;
}
-.mine {
+.uncovered.mine {
background-color: black;
}
There – that’s a little better.
Finally, in uncovering a space where one or more mines surround it, we’ll need
to add a count inside of the space. We don’t want to get too far ahead of
ourselves, but it pays to think about what the HTML will look like. The simplest
way to do this is to set the content of the td
element to the number itself.
When we implement the code for this later, we’ll discover that none of the td
s
(which are empty right now) have consistent dimensions and will stretch once
more content is added to them. We need to freeze those dimensions so that they
are exactly 1/9 of the board. In addition, the number that
we add will need to be centered within the space. Let’s take care of both of
those issues now:
#board {
height: 200px;
left: 50%;
margin-left: -100px;
margin-top: -100px;
position: absolute;
top: 50%;
width: 200px;
}
td {
border: 1px solid black;
cursor: pointer;
+ width: 11.1111111%;
+ height: 11.1111111%;
+ text-align: center;
+ vertical-align: center;
}
td:not(.uncovered):hover {
background-color: #ddd;
}
.uncovered:not(.mine) {
background-color: gray;
color: white;
}
.uncovered.mine {
background-color: black;
}
Because we’ve changed the definition of the mine
class and added a new class,
our board is now broken. To fix this, we need to adjust our JavaScript code to
match:
...
for (let rowIndex = 0; rowIndex < 9; rowIndex++) {
const row = $("<tr>");
board.append(row);
for (let columnIndex = 0; columnIndex < 9; columnIndex++) {
const space = $("<td>");
const possibleMineLocation = (rowIndex * 9) + columnIndex;
const isMine = (mineLocations.indexOf(possibleMineLocation) !== -1);
if (isMine) {
mines.push(space);
+ space.addClass("mine");
}
row.append(space);
space.on("click", () => {
if (isMine) {
mines.forEach(mine => {
- mine.addClass("mine");
+ mine.addClass("uncovered");
});
}
});
}
}
We should see the same result as before, but this should set us up nicely for the changes we’re about to make:
Neighbors
Let’s take another look at the pseudocode:
- If any of the spaces surrounding the space is a mine:
- Show the space as uncovered
- Count the surrounding mines and show them inside the space
There are a few different pieces that we need to implement, but the first thing that pops out is the idea of spaces that surround other spaces – or something we’ll call neighbors for short. For instance, all of the grey spaces here are neighbors of the red space:
Most spaces will have 9 neighbors, except for those that live on the edge of the board. For instance, a corner space has only 3 neighbors:
Okay, so how do we find the neighbors for any space on the board? Well, remember in part 3 how we learned that we have a way to denote the location of a space using X- and Y-coordinates?
This is made possible by the loop we use to draw the spaces:
for (let rowIndex = 0; rowIndex < 9; rowIndex++) {
...
for (let columnIndex = 0; columnIndex < 9; columnIndex++) {
const space = $("<td>");
...
}
...
}
If we had a way to look up certain spaces by their coordinates, we could take a space’s coordinates and add or subtract a row or column from them in as many directions as were allowable. Then we’d have coordinates for that space’s neighbors and we could look up the spaces that belong to those coordinates.
How do we do this? We’ll have to put on our imagination hat.
Storing the board as data
What if we had an array of arrays of td
elements, where the outer array
represented the rows in the board and each inner array represented the column in
a row?
With this array, we could drill down to a space by reusing the rowIndex
and
columnIndex
of that space. For instance, if this imaginary array were stored
in a variable called spaces
, then we could use spaces[3][5]
to refer to the
td
highlighted in red above. And we could use spaces[2][5]
to refer to its
northern neighbor, and spaces[3][6]
to refer to its eastern neighbor, and so
on.
So let’s turn this spaces
array into reality. We’ll update minesweeper.js
so
that we define the array before the outer row loop starts. Then, each time we
build a tr
element, we add a new inner array to spaces
to represent the row.
We then add td
s to this array as we build them.
...
const mines = [];
+const spaces = [];
for (let rowIndex = 0; rowIndex < 9; rowIndex++) {
const row = $("<tr>");
+
board.append(row);
+ spaces[rowIndex] = [];
+
for (let columnIndex = 0; columnIndex < 9; columnIndex++) {
const space = $("<td>");
const possibleMineLocation = (rowIndex * 9) + columnIndex;
const isMine = (mineLocations.indexOf(possibleMineLocation) !== -1);
if (isMine) {
mines.push(space);
space.addClass("mine");
}
+
+ spaces[rowIndex][columnIndex] = space;
row.append(space);
space.on("click", () => {
if (isMine) {
mines.forEach(mine => {
mine.addClass("uncovered");
});
} else {
// If any of the spaces surrounding the space is a mine:
// Show the space as uncovered
// Count the surrounding mines and show them inside the space
}
});
}
}
Gathering neighbors
Now that we have a data structure that mimics the board itself, we can use it to look up the neighbors for a space.
There’s code we need to write, but we’re not certain what it’s going to look
like, so we’ll have to sketch something out. (Don’t put this in minesweeper.js
just yet.) Let’s start by saying:
const north = spaces[rowIndex - 1][columnIndex];
const south = spaces[rowIndex + 1][columnIndex];
const west = spaces[rowIndex][columnIndex - 1];
const east = spaces[rowIndex][columnIndex + 1];
const northwest = spaces[rowIndex - 1][columnIndex - 1];
const northeast = spaces[rowIndex - 1][columnIndex + 1];
const southwest = spaces[rowIndex + 1][columnIndex - 1];
const southeast = spaces[rowIndex + 1][columnIndex + 1];
const neighbors = [
north,
south,
west,
east,
northwest,
northeast,
southwest,
southeast
];
That’s good, but as we mentioned earlier, a space may not always have 9 neighbors, as it depends on whether it is in the middle or at an edge; each time we access a different row or column, we need to consider that it may or may not exist. So let’s add in some checks:
const previousRow = spaces[rowIndex - 1];
const currentRow = spaces[rowIndex];
const nextRow = spaces[rowIndex + 1];
const neighbors = [];
const west = currentRow[columnIndex - 1];
const east = currentRow[columnIndex + 1];
if (west != null) {
neighbors.push(west);
}
if (east != null) {
neighbors.push(east);
}
if (previousRow != null) {
const north = previousRow[columnIndex];
const northwest = previousRow[columnIndex - 1];
const northeast = previousRow[columnIndex + 1];
neighbors.push(north);
if (northwest != null) {
neighbors.push(northwest);
}
if (northeast != null) {
neighbors.push(northeast);
}
}
if (nextRow != null) {
const south = nextRow[columnIndex];
const southwest = nextRow[columnIndex - 1];
const southeast = nextRow[columnIndex + 1];
neighbors.push(south);
if (southwest != null) {
neighbors.push(southwest);
}
if (southeast != null) {
neighbors.push(southeast);
}
}
Here we’re using value != null
whenever we want to check
that a row or column is present. In JavaScript, it’s generally best to use ===
whenever we want to check whether two things are equal, so why aren’t we doing
that here? In this case, value != null
is a shortcut for
saying value !== undefined && value !== null
.
Now our code works for spaces located anywhere. But that’s a lot of code just to look for neighbors, isn’t it? Isn’t there some way that we can make it shorter?
There is! The first thing we can do is to apply a technique involving the ||
operator (also called the “or” operator). Say that we are setting a variable to
a value, but it is possible that the value is null
. In that case, we want to
assign an alternate value. We could use a conditional to accomplish this:
let someVariable = someValue;
if (someVariable == null) {
someVariable = "someOtherValue";
}
But if we know that someVariable
is not a number, we can also use ||
to
achieve the same thing:
const someVariable = someValue || "someOtherValue";
So we can modify our code to ensure that previousRow
and nextRow
are at
least set to empty arrays. That would obviate our need to check whether
previousRow
or nextRow
exists before we use it:
const previousRow = spaces[rowIndex - 1] || [];
const currentRow = spaces[rowIndex];
const nextRow = spaces[rowIndex + 1] || [];
const west = currentRow[columnIndex - 1];
const east = currentRow[columnIndex + 1];
const north = previousRow[columnIndex];
const northwest = previousRow[columnIndex - 1];
const northeast = previousRow[columnIndex + 1];
const south = nextRow[columnIndex];
const southwest = nextRow[columnIndex - 1];
const southeast = nextRow[columnIndex + 1];
const neighbors = [];
if (west != null) {
neighbors.push(west);
}
if (east != null) {
neighbors.push(east);
}
if (north != null) {
neighbors.push(north);
}
if (northwest != null) {
neighbors.push(northwest);
}
if (northeast != null) {
neighbors.push(northeast);
}
if (south != null) {
neighbors.push(south);
}
if (southwest != null) {
neighbors.push(southwest);
}
if (southeast != null) {
neighbors.push(southeast);
}
The second thing we can do is to replace all of the checks for null
we’re
making with something more succinct. We’ll start by completely removing those
checks:
const previousRow = spaces[rowIndex - 1] || [];
const currentRow = spaces[rowIndex];
const nextRow = spaces[rowIndex + 1] || [];
const north = previousRow[columnIndex];
const south = nextRow[columnIndex];
const west = currentRow[columnIndex - 1];
const east = currentRow[columnIndex + 1];
const northwest = previousRow[columnIndex - 1];
const northeast = previousRow[columnIndex + 1];
const southwest = nextRow[columnIndex - 1];
const southeast = nextRow[columnIndex + 1];
const neighbors = [
north,
south,
west,
east,
northwest,
northeast,
southwest,
southeast
];
Now we need to address the null
values that may appear in neighbors
. We
don’t want a bunch of if
statements like we had before, so what do we do?
We could loop through all of the neighbors and build a new array that consist
only of ones that are not null:
const previousRow = spaces[rowIndex - 1] || [];
const currentRow = spaces[rowIndex];
const nextRow = spaces[rowIndex + 1] || [];
const north = previousRow[columnIndex];
const south = nextRow[columnIndex];
const west = currentRow[columnIndex - 1];
const east = currentRow[columnIndex + 1];
const northwest = previousRow[columnIndex - 1];
const northeast = previousRow[columnIndex + 1];
const southwest = nextRow[columnIndex - 1];
const southeast = nextRow[columnIndex + 1];
const neighborsWithPossibleNulls = [
north,
south,
west,
east,
northwest,
northeast,
southwest,
southeast
];
const neighbors = [];
neighborsWithPossibleNulls.forEach(neighbor => {
if (neighbor != null) {
neighbors.push(neighbor);
}
});
It turns out that the pattern that we’ve written – to select a portion of some
array that matches a certain condition, discarding the rest that does not match
that condition – is common to many programming languages. In JavaScript, we can
do it with the filter
method, which is available on any array.
Let’s do that and take this opportunity to combine some of the other lines:
const previousRow = spaces[rowIndex - 1] || [];
const nextRow = spaces[rowIndex + 1] || [];
const currentRow = spaces[rowIndex];
const neighborsWithPossibleNulls = [
currentRow[columnIndex - 1],
currentRow[columnIndex + 1],
previousRow[columnIndex - 1],
previousRow[columnIndex],
previousRow[columnIndex + 1],
nextRow[columnIndex - 1],
nextRow[columnIndex],
nextRow[columnIndex + 1]
];
const neighbors = neighborsWithPossibleNulls.filter(neighbor => {
return neighbor != null;
});
Isn’t that nice?
Now we can place it into minesweeper.js
above the pseudocode we added earlier:
...
for (let rowIndex = 0; rowIndex < 9; rowIndex++) {
const row = $("<tr>");
board.append(row);
for (let columnIndex = 0; columnIndex < 9; columnIndex++) {
const space = $("<td>");
const possibleMineLocation = (rowIndex * 9) + columnIndex;
const isMine = (mineLocations.indexOf(possibleMineLocation) !== -1);
if (isMine) {
mines.push(space);
space.addClass("mine");
}
row.append(space);
space.on("click", () => {
if (isMine) {
mines.forEach(mine => {
mine.addClass("uncovered");
});
} else {
+ const previousRow = spaces[rowIndex - 1] || [];
+ const nextRow = spaces[rowIndex + 1] || [];
+ const currentRow = spaces[rowIndex];
+ const neighborsWithPossibleNulls = [
+ currentRow[columnIndex - 1],
+ currentRow[columnIndex + 1],
+ previousRow[columnIndex - 1],
+ previousRow[columnIndex],
+ previousRow[columnIndex + 1],
+ nextRow[columnIndex - 1],
+ nextRow[columnIndex],
+ nextRow[columnIndex + 1]
+ ];
+ const neighbors = neighborsWithPossibleNulls.filter(neighbor => {
+ return neighbor != null;
+ });
// If any of the spaces surrounding the space is a mine:
// Show the space as uncovered
// Count the surrounding mines and show them inside the space
}
});
}
}
Determining neighboring mines
Now that we have a way to get a list of neighbors for a space, let’s take another look at our pseudocode:
- If any of the spaces surrounding the space (aka the neighbors) is a mine:
- Show the space as uncovered
- Count the surrounding mines and show them inside the space
Do you notice any similarities between the conditional in the first line and the last step? We are asking whether any neighbors is a mine, and yet later we are counting those exact neighbors. So if we establish a count first, we can use in the rest of the steps:
- Count the neighboring mines, storing it as
numNeighboringMines
- If
numNeighboringMines
is greater than 0:- Show the space as uncovered
- Show
numNeighboringMines
inside the space
Let’s update minesweeper.js
to make sure this new perspective is close at
hand:
...
for (let rowIndex = 0; rowIndex < 9; rowIndex++) {
const row = $("<tr>");
board.append(row);
for (let columnIndex = 0; columnIndex < 9; columnIndex++) {
const space = $("<td>");
const possibleMineLocation = (rowIndex * 9) + columnIndex;
const isMine = (mineLocations.indexOf(possibleMineLocation) !== -1);
if (isMine) {
mines.push(space);
space.addClass("mine");
}
row.append(space);
space.on("click", () => {
if (isMine) {
mines.forEach(mine => {
mine.addClass("uncovered");
});
} else {
const previousRow = spaces[rowIndex - 1] || [];
const nextRow = spaces[rowIndex + 1] || [];
const currentRow = spaces[rowIndex];
const neighborsWithPossibleNulls = [
currentRow[columnIndex - 1],
currentRow[columnIndex + 1],
previousRow[columnIndex - 1],
previousRow[columnIndex],
previousRow[columnIndex + 1],
nextRow[columnIndex - 1],
nextRow[columnIndex],
nextRow[columnIndex + 1]
];
const neighbors = neighborsWithPossibleNulls.filter(neighbor => {
return neighbor != null;
});
- // If any of the spaces surrounding the space is a mine:
- // Show the space as uncovered
- // Count the surrounding mines and show them inside the space
+ // Count the neighboring mines, storing it as `numNeighboringMines`
+ // If `numNeighboringMines` is greater than 0
+ // Show the space as uncovered
+ // Show `numNeighboringMines` inside the space
}
});
}
}
All right – with that out of the way, we can start from the top. We need to
count the neighboring spaces that are also mines, but to do that we need to
find the neighboring spaces that are also mines. In other words, we need to
start with neighbors
and select a smaller portion of it. Doesn’t this seem
like something we’ve done before? Yes – we can use filter
again:
const neighboringMines = neighbors.filter(neighbor => {
// is the neighbor a mine?
});
Now the problem is what exactly to put inside the function. How do we know whether a space is a mine?
To answer this appropriately, we need some way to distinguish mines from
non-mines. In fact, it turns out we already do: we mark mines visually using a
CSS class. So in theory, we could ask whether the space has a mine
class
(using the hasClass
method available in
jQuery):
const neighboringMines = neighbors.filter(neighbor => {
return neighbor.hasClass("mine");
});
While this works – and it is not unusual to see this approach out in the wild
– it isn’t the best idea to use CSS classes in this manner. Why? Because this
isn’t how CSS was intended to be used. CSS exists solely to change the
appearance of elements on the page. The class we added to each mine is a tag we
attach to the td
element so that the browser knows to draw it using a black
background. We just so happened to call this tag “mine”, but we could have
called it anything. In addition, if we make use of CSS like this, it may create
problems for us. We may decide down the road that we want to change how we style
mines, and if we tangle CSS and JavaScript together, then that kind of change
may be harder to make.
So we still need to tag our element somehow as being a mine, but only do so
within JavaScript. There are a couple different methods we can use, but the one
that fits best with our code at present is to use jQuery’s
data
method. This method allows us to
take a key-value pair and associate it with an element on the page so that we
can look up the value by the key later. With that in mind, we can modify
minesweeper.js
like this:
...
for (let rowIndex = 0; rowIndex < 9; rowIndex++) {
const row = $("<tr>");
board.append(row);
for (let columnIndex = 0; columnIndex < 9; columnIndex++) {
const space = $("<td>");
const possibleMineLocation = (rowIndex * 9) + columnIndex;
const isMine = (mineLocations.indexOf(possibleMineLocation) !== -1);
if (isMine) {
mines.push(space);
space.addClass("mine");
+ space.data("isMine", true);
}
...
}
}
Now we can revise how we are building the list of neighboring mines like this:
const neighboringMines = neighbors.filter(neighbor => {
return neighbor.data("isMine");
});
Lastly, we can fit it into minesweeper.js
:
...
for (let rowIndex = 0; rowIndex < 9; rowIndex++) {
const row = $("<tr>");
board.append(row);
for (let columnIndex = 0; columnIndex < 9; columnIndex++) {
const space = $("<td>");
const possibleMineLocation = (rowIndex * 9) + columnIndex;
const isMine = (mineLocations.indexOf(possibleMineLocation) !== -1);
if (isMine) {
mines.push(space);
space.addClass("mine");
space.data("isMine", true);
}
row.append(space);
space.on("click", () => {
if (isMine) {
mines.forEach(mine => {
mine.addClass("uncovered");
});
} else {
const previousRow = spaces[rowIndex - 1] || [];
const nextRow = spaces[rowIndex + 1] || [];
const currentRow = spaces[rowIndex];
const neighborsWithPossibleNulls = [
currentRow[columnIndex - 1],
currentRow[columnIndex + 1],
previousRow[columnIndex - 1],
previousRow[columnIndex],
previousRow[columnIndex + 1],
nextRow[columnIndex - 1],
nextRow[columnIndex],
nextRow[columnIndex + 1]
];
const neighbors = neighborsWithPossibleNulls.filter(neighbor => {
return neighbor != null;
});
+ const neighboringMines = neighbors.filter(neighbor => {
+ return neighbor.data("isMine");
+ });
// Count the neighboring mines, storing it as `numNeighboringMines`
// If `numNeighboringMines` is greater than 0
// Show the space as uncovered
// Show `numNeighboringMines` inside the space
}
});
}
}
Finishing up
We’ve made a lot of progress in this post, and we can combine everything we’ve done here to finish up this piece of the game:
...
for (let rowIndex = 0; rowIndex < 9; rowIndex++) {
const row = $("<tr>");
board.append(row);
for (let columnIndex = 0; columnIndex < 9; columnIndex++) {
const space = $("<td>");
const possibleMineLocation = (rowIndex * 9) + columnIndex;
const isMine = (mineLocations.indexOf(possibleMineLocation) !== -1);
if (isMine) {
mines.push(space);
space.addClass("mine");
space.data("isMine", true);
}
row.append(space);
space.on("click", () => {
if (isMine) {
mines.forEach(mine => {
mine.addClass("uncovered");
});
} else {
const previousRow = spaces[rowIndex - 1] || [];
const nextRow = spaces[rowIndex + 1] || [];
const currentRow = spaces[rowIndex];
const neighborsWithPossibleNulls = [
currentRow[columnIndex - 1],
currentRow[columnIndex + 1],
previousRow[columnIndex - 1],
previousRow[columnIndex],
previousRow[columnIndex + 1],
nextRow[columnIndex - 1],
nextRow[columnIndex],
nextRow[columnIndex + 1]
];
const neighbors = neighborsWithPossibleNulls.filter(neighbor => {
return neighbor != null;
});
const neighboringMines = neighbors.filter(neighbor => {
return neighbor.data("isMine");
});
- // Count the neighboring mines, storing it as `numNeighboringMines`
+ const numNeighboringMines = neighboringMines.length;
- // If `numNeighboringMines` is greater than 0
+
+ if (numNeighboringMines > 0) {
- // Show the space as uncovered
+ space.addClass("uncovered");
- // Show `numNeighboringMines` inside the space
+ space.text(numNeighboringMines);
+ }
}
});
}
}
Time to finally take a look at what this produces! Try clicking on a space that’s close to a mine:
What’s next
Our next task is to finish up the core logic of the game by implementing what happens when a space that does not neighbor any mines is clicked.