First published: Oct. 15, 2023 | Permalink | RSS
None
n
n n n How to make Minesweeper part 2
nThis is part 2 of 3 and I recommend starting with To help keep our Note: Refactoring is a specific action in which the functionality of our code stays the same, but we change then code to (ideally) be more readable or more efficient. At the end of any refactor, nothing should be differentn from the user perspective. A great candidate here is to take our callback code in the And the Note: The And now we should add one last bit of infrastructure before adding in gameplay. We want to be able to reset then board without refreshing the whole page. Let's start that by adding a new button to the page by addingn Now let's set up the reset logic. At the top of Next we'll go down to where we select and declare the board and addn Then, just above our At the top of our And finally we will add an event listener to the Note: On looking at this again, it may make more sense to call Refresh and check to make sure the reset button does what we expect. Right now the two ways to see if it'sn working is to see the squares get rearranged. If there are any green squares from clicking on the board, thosen should also be cleared from the board during the reset. Let's add some actual gameplay features! The one we'll start with is getting a game over, which is notn the most fun feature, but is one that we'll need a lot going forward. We already have ann In Minesweeper, there's only two reasons why the game would be over: We don't have the ability to flag bombs yet, but the player is able to click them. What we will want to do isn check if the square that was clicked has a "bomb" class. DOM objects have a Once the game is over, we want to make the board stay put and no longer be clickable. So we can add a check ton Refresh and check and let's test a few things now. Click some smiley faces, and these should still turnn green. Click a bomb and it should "explode". Then click more smiley faces or bombs to confirm that non click actions are happening now that the game is over. Finally click the reset button to reset the game and ben able to click in the game again. A key feature in minesweeper is to check how many bombs are next to the square you just clicked. This helps then player know which square to click next or where a bomb might be. Each square that's not on an edge has eightn neighbors, and we can visualize how to access those neighbors by displaying their array index numbers instead ofn the emojis. We do still want to keeo track of where the bombs are, so let's add the following to the CSS file: Now instead of adding emojis let's change the line in Also rather than adding the When we refresh and check we should see the squares numbered 0-99 with 20 randomly-placed pink squares. Now that we can see the numbers, we can examine how we can use those numbers to check each of the square'sn neighbors. Let's look at square 12, it's neighbors are (top to bottom, left to right):n 1, 2, 3, 11, 13, 21, 22, 23n In our case, the difference between each and 12 is:n -11, -10, -9, -1, +1, +9, +10, +11 Every board with 10 squared squares will follow this pattern, but for us wanting to choose any size in then future, we can make the top and bottom neighbors relative to the Let's add a There are three steps to this function: And then of course we have to call When we refresh and check now, we should be able to click any non-pink square and be shown the number of pinkn squares next to each clicked square. Our function to check neighbors is working pretty well, but if you can find a board configuration where a squaren on the left edge has a pink square in the right-most square of the row above or a square on the right has a pinkn square on the left-most square of the row below, you may notice that the count has too many bombs in it. From the user perspective a square on an edge only has five neighbors, and a corner square only has three. Butn our array-based method has a drawback: It doesn't know where the edges are. An array has no concept ofn edges. It is only a line of numbers we tricked into forming rows. So we will have to add a way to check then edges in our Because we are asking the DOM for elements with specific IDs (the However, for square "90" its left neighbors are 79, 89, & 99, which all exist, but are all over onn the right side of the board, and for our gameplay need to not be checked. For the purposes of this tutorial andn showing the concept we will check for all the edges. This will also make our loop skip unnecessary checks andn make it more efficient, even if by a little. So let's define some edges. Top and bottom will be pretty easy since those are rows made of consecutiven numbers and we have a constant defined for how big our rows are. The top row isn Likewise we know that the bottom row indeces ( So we can add to our The right and left edges will also be kind of paired so let's look at the numbers for our 10x10 grid: The "ones" digit of each number has the same value. This looks like multiples of 10 (and 0) and numbersn that are each 1 off of multiples of 10. For multiples of any number we can check the Note: Those parens in So once these four get added we will want to change our We'll be changing Most of my trepidation with this constant is that it looks pretty intimidating at first glance, but I did give itn some whitespace here to make it a little clearer what's what. What I like about it is how it gets used inn our loop for each neighbor, because we'll be using destructuring! Instead of just Note: On second look, the Now with a refresh and check we should see accurate counts of pink squares when we click on the non-pink squares.n We should check corners and edges to make sure they work as expected. One of the more satisfying parts about playing minesweeper is clicking on an empty square with no bombsn surrounding it because it then reveals all its neighbors and if those neighbors are empty then each ofn those neaighbors' neighbors are revealed and then each of THOSEn neaighbors' neighbors are revealed and so on. When we see a pattern like that it's probablyn a good case for recursion! Note: Recursion can be a scary concept and in our case the recursion will be a little removed but is still there.n One thing to remember with recursion is that we want to avoid infinite loops and thus need an exit condition.n Luckily we already have one built in, and I'll explain below. Let's add the following chunk after the What does this loop do? If the count for this square is 0, loop over each of its neighbors, if we should checkn them, and run Above I mentioned we have a buit in exit condition for our recursion, which is that in Refresh and check by clicking on a square with no pink squares around it so we can see all those 0s appear!n Groups of 0s should now show up like little seas with continents of bombs with shores of 1s and 2s. Eagle-eyed learners will notice that we have two nearly identical loops. stepping through neighbors and doingn something to them. The principle of DRY coden (Don't repeat yourself) tells us this loop should be its own function that gets called multiplen times to make our code easier to read, debug, and develop in the future. Let's start by writing out what code is shared in both loops: We do still want access to Where we want to "do stuff" to the neighbor, we will use a So instead of our two loops, let's replace them with: And let's break this down. First we call Like I wrote up above, refactoring means changing our code but that the functionality remains the same as beforen we changed anything. So let's refresh and check and make sure that everything is still working as before.n Now that we have the ability to show empty areas of the board with our recursive checks, let's make the boardn a little less noisy. Rather than showing To do this let's cut So now, we will only print the Another bit of visual overload we can remove is our When we refresh and check we should see blank squares but still be able to identify our bombs by the pink squaresn they're sitting in. Now the game should be much easier to parse. Also if the black-text-on-green-squaresn bother you, you can also add Next it makes sense to me to add some difficulty settings to our game. Yes, this may seem early since our playern cannot actually win yet, so why add multiple difficulties? To make testing easier too. Later when we add flags,n 20 flags are a lot to place over and over. Different implementations of the game had different settings but for a board of 100, it seems to me to make sensen that "easy" have 10 bombs, "medium" have 20, and "hard" have 40. But on differentn sized boards those may be way too many or too little, so let's set this up with a ratio instead. An object make sense to store these values so instead of setting Now in our loop where we populate If we refresh and check everything should work the same, except that when we hit reset, will will have too manyn extra squares! This is because that loop is just pushing new values into the array and the array isn'tn actually be reset to empty. We got away with this before since we were also scrambling the array withn To fix this we just have to add Okay now that we have the ability to build different difficulties, let's add a way for the player to choosen their difficulty. In the HTML (which we haven't touched in a while), lets add the following between then reset button and the board Then back in our So now we have the buttons and a way to access them, so we'll write a simple function that takes in a string,n sets the Finally we have to call Let's do a refresh and check to make sure that our reset button still works, we can click squares and rest asn many times as we like. Then each of the difficulty buttons also reset the game and we can see they change then number of bombs (by roughly counting the pink squares). Finish the gameplay and style the board in part 3! Add the
createBoard function from getting too cluttered, we can start refactoring out somen functionality into its own function.addEventListener to its own function. Son we will add the following above our createBoard function.n const handleClick = (squareObj) => {n const clickedClass = "clicked";n if (!squareObj.classList.contains(clickedClass)) {n squareObj.classList.add(clickedClass);n }n };n addEventListener line will be:n square.addEventListener("click", () => handleClick(square));n clickedClass declaration is also likely a good candidate for a global constant or an classes object if we find ourselves adding other classes later. Especially if we create biggern boards, we want to be mindful of memory efficiency, and not be redeclaring the same string every loop.n Add reset feature and game over if click bomb
n <button id="reset">Reset</button> between the <h1> andn board.n app.js: we'll addn let isGameOver = false; to the bottom of our constants block. We'll use this more in the nextn section.n const reset = document.getElementById("reset");n handleClick function, we'll add in our reset function:n const resetGame = () => {n isGameOver = false;n board.innerHTML = "";n };n createBoard function, we will call resetGame(); to ensure we clearn the board before building it again.reset DOM object just before we create the board atn the bottom.n reset.addEventListener("click", createBoard);n createBoard at the bottom of then resetGame function and have the onClick callback be resetGame instead.n This will need some rearranging but is very likely worth the work for a little clarity.n
n Add game over condition and bomb to click handler
n isGameOver boolean, and our reset function already sets it back to false but wen haven't set it to true anywhere yet.n n
n classListn object and we can check if a string is among those classes with the classList.contains() method. Son at the top of handleClick let's add:n if (squareObj.classList.contains("bomb")) {n isGameOver = true;n squareObj.innerHTML = "💥";n }n handleClick to check if the game is over, and in that case return from the function early,n effectively stopping the click action. At the top of handleClick addn if (isGameOver) return;n Add ability to check neighbors
n n .bomb {n background-color: pink;n }n createBoard where we declare then square's innerHTML to: square.innerHTML = `${i}`;i as a class; we know these will be unique to each square and would be an good candidate to set the squares' id attributes to that i value. Under ourn square.innerHTML line let's add: square.id = `${i}`;n width of the board. For example ton get 1 from an index of 12 we would say it is index - width - 1 and extrapolate ourn other neighbor values from that.checkNeighbors function above the handleClick function that looks liken this and then break that down:n const checkNeighbors = (squareObj) => {n const index = parseInt(squareObj.id);n const neighborIndexes = [n index - width - 1,n index - width,n index - width + 1,n index - 1,n index + 1,n index + width - 1,n index + width,n index + width + 1,n ];n let count = 0;n n for (let neighborId of neighborIndexes) {n const neighbor = document.getElementById(`${neighborId}`);n if (neighbor && neighbor.classList.contains("bomb")) {n count++;n }n }n n squareObj.innerHTML = `${count}`;n };n n
n count when that neighbor has a bombinnerHTML of the clicked square to be the countcheckNeighbors as part of our handleClick function.n So we'll add checkNeighbors(squareObj); within the if block where we check if then square is not clicked.
n Fix counting for squares at edges
n checkNeighbors function.neighbor in the loop above) if itn doesn't exist then it will return undefined. Naturally, an undefined objectn won't have a "bomb" and will not increment our count. For example, if we're onn square "94" it's nonexistent lower neighbors would be 103, 104, & 105 and would returnn undefined when we try to getElementById for those values.n 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, so we know that they are all less than 10. But like above, we wantn our boards to be variably sized and don't want to update this check every time, so we can use then width constant. We know our square is on the top edge if index < width.n 90, 91, 92, 93, 94, 95, 96, 97, 98, 99) are all withinn one width of 100. We are storing 100 in the constant boardSize that we haven'tn used in a while. So we know a square is in the bottom row if index >= boardSize - width.checkNeighbors function after we define our index but before wen define the neighborIndexes the following:n const topEdge = index < width;n const bottomEdge = index >= boardSize - width;n n Left: 0, 10, 20, 30, 40, 50, 60, 70, 80, 90n Right: 9, 19, 29, 39, 49, 59, 69, 79, 89, 99n n const leftEdge = index % width === 0;n const rightEdge = (index + 1) % width === 0;n rightEdge are key if you are using Prettier for formatting because it lovesn autoformatting to index + (1 % width) which will never work how you want and only lead ton frustration. But also because modulo comes before addition in the order of operations and the parens help given you control.neighborIndexes to include our edge checks.n While I feel like this next part was pretty clever, clever solutions make me nervous:Everyone knows that debugging is twice as hard as writing a program in the first place. So if you're as clever as you can be when you write it, how will you ever debug it?n - Kernighan's leverneighborIndexes to be a 2D array (an array full of arrays), each inner arrayn will be a pair of values being: the edge check logic and the index of that neighbor. In this case we'll alson rename the constant to neighborEdgesAndIndexes though this likely still needs a better name.n const neighborEdgesAndIndexes = [n [ !leftEdge && !topEdge , index - width - 1 ],n [ !topEdge , index - width ],n [ !rightEdge && !topEdge , index - width + 1 ],n [ !leftEdge , index - 1 ],n [ !rightEdge , index + 1 ],n [ !leftEdge && !bottomEdge , index + width - 1 ],n [ !bottomEdge , index + width ],n [ !rightEdge && !bottomEdge, index + width + 1 ],n ];n neighborId,n we'll have shouldCheck and neighborId and make it very easy to skip if we should not check.n for (let [shouldCheck, neighborId] of neighborEdgesAndIndexes) {n if (!shouldCheck) continue;n const neighbor = document.getElementById(`${neighborId}`);n if (neighbor && neighbor.classList.contains("bomb")) {n count++;n }n }n shouldCheck value is a bit of a double negative how we're using it.n Rather than all those !s in the arrays and the ! in the if, we couldn remove all the !s and rename shouldCheck to shouldNotCheck orn shouldSkip.n
n Add recursive calls if square is zero
n for loop where we increment the count:n if (count === 0) {n for (let [shouldCheck, neighborId] of neighborEdgesAndIndexes) {n if (!shouldCheck) continue;n const neighbor = document.getElementById(`${neighborId}`);n handleClick(neighbor);n }n }n handleClick on that neighbor. handleClick will check if the neighbor hasn already been clicked and if not, will run checkNeighbors on it. This is our recursive call:n checkNeighbors will call handleClick on those neighbors it's checking which willn call checkNeighbors which will call handleClick and so on until all the 0s and theirn neighbors have been clicked.n handleClick,n this only calls checkNeighbors if the square hasn't been clicked. If it has been, then we endn the function there. This prevents a square checking all eight of its neighbors and then those neighbors checkingn the original square that checked them causing an infinite loop.
n Refactor our repeated loops into own function
n n for (let [shouldCheck, neighborId] of neighborEdgesAndIndexes) {n if (!shouldCheck) continue;n const neighbor = document.getElementById(`${neighborId}`);n // Some stuff we want to do to the neighborn }n neighborEdgesAndIndexes so we should keep our new function insiden checkNeighbors and I think we should declare our new function in between where we declaren neighborEdgesAndIndexes and where we declare count:n n const doForEachNeighbor = (callBack) => {n for (let [shouldCheck, neighborId] of neighborEdgesAndIndexes) {n if (!shouldCheck) continue;n const neighbor = document.getElementById(`${neighborId}`);n callBack(neighbor);n }n };n callBack function or one thatn we give as an argument to our new function. And when we call doForEachNeighbor, we will have to setn a parameter that will accept neighbor as an argument in our callbacks. This is starting to soundn like recursion again, but it really is just an oddity of callback functions.n doForEachNeighbor((neighborObj) => {n if (neighborObj.classList.contains("bomb")) {n count++;n }n });n if (count === 0) {n doForEachNeighbor((neighborObj) => handleClick(neighborObj));n }n doForEachNeighbor and our anonymous callback accepts an neighborObj parameter and checks if that neighborObj contains a bomb class. If itn does, it increments the count. Once that's complete, if count is 0, we calln doForEachNeighbor again, but this time, we pass in an anonymous function that also accepts an neighborObj parameter and calls handleClick on that neighborObj.n Show number only if count is greater than 0
n 0 in squares that have no bombs as neighbors, let'sn only show the count if the count is greater than 0. So then these empty areas willn also be visually empty and the board will be easier to look at.squareObj.innerHTML = `${count}`; from the bottom ofn checkNeighbors. We'll then change our if (count === 0) block to be:n n if (count > 0) {n squareObj.innerHTML = `${count}`;n } else {n doForEachNeighbor((neighborObj) => handleClick(neighborObj));n }n count if it actually indicates a nearby bomb and otherwise just letn the squares be empty. This will cut down on visual overload to the player.index values since we only needed that to writen and debug checkNeighbors. So remove square.innerHTML = `${i}`; from then createBoard function.n color: white; to the .clicked block in the CSS.
n Add difficulty settings
n bombCount as 20,n let's declare: n const bombCount = { n easy: boardSize * .1,n medium: boardSize * .2,n hard: boardSize * .4 n };n let difficulty = "easy";n squareValues we'll check ifn i < bombCount[difficulty]. Also this loop is a little odd sitting between our constants and ourn DOM elements, especially since we will want to create the array more than just on loading now. We will want ton build this array every time we reset the game, so let's add it to the end of resetGame!n shuffleValues everytime the board was created.n squareValues.length = 0; right before the loop which effectivelyn deletes all the elements in the squareValues array.div:n <button id="easy">Easy</button>n <button id="medium">Medium</button>n <button id="hard">Hard</button>n js file, we'll add the following after we declare board andn reset:n n const easy = document.getElementById("easy");n const medium = document.getElementById("medium");n const hard = document.getElementById("hard");n difficulty to that string, and immediately creates the board anew:n const setDifficultyAndReset = (difficultyStr) => {n difficulty = difficultyStr;n createBoard();n };n setDifficultyAndReset whenever those buttons are clicked: so between then reset.addEventListener and the createBoard call at the bottom of the jsn file add:n n easy.addEventListener("click", () => setDifficultyAndReset("easy"));n medium.addEventListener("click", () => setDifficultyAndReset("medium"));n hard.addEventListener("click", () => setDifficultyAndReset("hard"));n
n Finish your game!
n