Hands up - who has played this absolute classic for hours on end? π I remember playing it as a kid. There's a myriad of different implementations, even 3D versions of it. Heck, I even play it today from time to time. So why not build our own version on the CLI using Node?
I also want to give a shoutout to @krystofex who supported me via buymeacoffee! β Thank you very much for your support, it is highly appreciated and I already enjoyed a delicious coffee on my way home the other day! βΊοΈ
Setting the stage
This will be a CLI app, as lean as possible, no external dependencies. That should work reasonably well, given that most things, like argument parsing and displaying things in a structured manner in the CLI, work out of the box pretty nicely.
Let's first have a look at the game rules, though.
Minesweeper is usually played on a square field. 10x10, 60x60, you get the drill. A certain number of so called "mines" is placed randomly onto the field. The player now has to flag all these mines and only these mines. For this, they can place flags onto the field where they think a mine is located. To figure out where the mines are, the player can uncover fields. By doing so, they can see how many adjacent fields have mines. Uncovering a field with no adjacent mine uncovers all neighbours with no adjacent mines, too. What does that mean exactly, though?
The mines are marked with m, the numbers show how many neighbours have a mine. All 8 surrounding cells count as neighbours. When the game starts out, none of these are visible. The player then choses to uncover the top left cell. What they'll see is this:
By uncovering a field with no adjacent mines, all neighbours that are not mines are uncovered, until a cell has a neighbouring mine.
If the player accidentally uncovers a mine, they lose the game. If they manage to flag all mines correctly, they win the game. The simplicity of this is what's really making it addictive. "I almost managed to win last time, this time I'll make it!" - right? Also, the game feels kind of unfair from time to time. The chances of the player randomly hitting a mine are number of mines / width * height. In a standard small 10 by 10 setup with 8 mines, that's an 8% chance of hitting a mine. Pretty slim, huh? Well, until you manage to hit a mine on the first move for the third loving time in a row, for goodness sake, why is it doing this to me??
Ok, I might have played it a bit too often. I need to calm down, we're here to build it, not necessarily to win it.
Parsing arguments
Ok, the heart rate has gone down.
To figure out how large the field should be and how many mines we should place, we're going to use console arguments.
This should result in a 10x10 playing field with 10 randomly placed mines.
We'll use some regular expressions to parse out these arguments:
constgetArg=(args,name)=>{constmatch=args.match(newRegExp('--'+name+'=(\\d+)'))if(match===null){thrownewError('Missing argument '+name)}returnparseInt(match[1])}letwidth=0letheight=0letmines=0try{constargs=process.argv.slice(2).join('')width=getArg(args,'width')height=getArg(args,'height')mines=getArg(args,'mines')if(width<1||height<1){thrownewError('Field size must be positive')}}catch(e){console.error(e)process.exit(1)}
Since all our arguments are numeric, we can perfectly use \d+ and the arguments name as a regular expression, parse out the number and use that. The only thing we need to care about is that we don't want 0 for either the width or the height - that wouldn't make much sense anyways, would it?. We do allow for 0 mines, though. Easy mode. Juuust to calm the nerves. For. A. Little. Bit.
This will give us an array of up to 8 coordinate pairs for given X and Y coordinates. This will be useful later on. We can use it to determine which fields to uncover and where to set those numbers we've seen before.
Then we need some way to keep the data. There's essentially three kinds of matrices we're going to need:
One to keep track where those pesky mines are (and the numbers around them)
One to keep track which fields the player has uncovered so far
And lastly, one to keep track which fields the player has flagged as "contains a mine"
constcreateMatrix=v=>Array(width).fill([]).map(()=>Array(height).fill(v))constfield=createMatrix(0)// We'll overwrite this matrix later, hence `let`letuncoveredField=createMatrix(false)constflaggedField=createMatrix(false)
Next, we'll place the mines. For this we generate some random X/Y coordinates. We skip if there's already a mine there to make sure that the player gets the full amount of fun.
Once a mine is set, we increase all the neighbouring cells by 1. This will generate the characteristic number patterns:
To figure out if the player has won, we need to compare the flags set by the player with the positions of the mines. This means, that if there's a flag at a position where there's no mine, the player hasn't won. We can use every for this:
What this does is that it reduces every row to either true or false depending if every field matches the condition or not. All the rows are then reduced to a single boolean by simply asking "are all rows true".
Rendering the field
This will be a bit tricky. A cell can have one of three possible states: Covered, uncovered and flagged. An uncovered cell can either be 0, any number from 1 to 8, or a mine. A cell can also be where the cursor currently is.
We're going to use emoji to display the field. First, let's define which emojis we're going to use for the uncovered cells:
constcharacterMap={m:'π£',// I kinda developed an aversion to that emoji.0:'β¬',1:'1οΈβ£ ',2:'2οΈβ£ ',3:'3οΈβ£ ',4:'4οΈβ£ ',5:'5οΈβ£ ',6:'6οΈβ£ ',7:'7οΈβ£ ',8:'8οΈβ£ ',}
Next, we define a function to render the field. It should clear the CLI output first and already render the top and bottom walls:
constrenderField=(playerX,playerY)=>{console.clear()console.log('π§±'.repeat(width+2))// ...console.log('π§±'.repeat(width+2))console.log('Press ENTER to uncover a field, SPACE to place a flag')}
Then we need to loop over the playing field. We can already add the left and right wall to every row.
You might've noticed the two if statements with the weird characters. \x1b[47m gives the CLI a white background for the following text, \x1b[30m makes the following text black. For most CLIs out there, that essentially means inverting the standard color. This is used as an indicator to where the player's cursor currently is. \x1b[0m is used to reset these settings, making sure that only the current cell is colored differently.
Uncovering the field
This one will be even trickier. The game rule says that every empty field with no adjacent mines should be uncovered. This can result in any possible shape, really. Such as circles, for example. We would therefore need to find a way around those.
Ideally, the uncovering would kind of "spread" around. And a field would first uncover itself and then ask its neighbour to uncover if it could. Sounds like recursion, right?
It absolutely does! This little function does exactly what we want it to do by recursively asking its neighbours to uncover:
constuncoverCoords=(x,y)=>{// Uncover the field by defaultuncoveredField[y][x]=trueconstneighbours=getNeighbouringCoords(x,y)// Only if the field is a 0, so if it has no adjacent mines,// ask its neighbours to uncover.if(field[y][x]===0){neighbours.forEach(([y,x])=>{// Only uncover fields that have not yet been uncovered.// Otherwise we would end up with an infinite loop.if(uncoveredField[y][x]!==true){// Recursive call.uncoverCoords(x,y)}})}}
Now, for the last part, we need...
User input
Home stretch! Almost there. We can soon enjoy the little bomb emoji telling us that we're unlucky for the thirteenth time in a row, why am I so goshdarn unlucky??
Let's define the controls first: Navigating the cursor can be done via keyboard. A press on enter would trigger the uncovering, a press on space would place and remove a flag.
In order to know if we still accept keyboard input, we need to keep track if the user has won or lost the game. Also, we need to keep track of the cursor coordinates:
To get the users keyboard input, we can use Node's built-in readline module. readline allows us to "convert" key stroke events to events on process.stdin. We then listen to the standard input's key stroke events (that's usually done when using "raw mode") and react to those:
constreadlineModule=require('readline')readlineModule.emitKeypressEvents(process.stdin)process.stdin.setRawMode(true)process.stdin.on('keypress',(character,key)=>{// Do stuff})
However, since the standard input is in raw mode, Ctrl+C to terminate the current script doesn't work. Holding Ctrl and pressing C is also considered a key stroke. We therefore need our own implementation of that:
// ...process.stdin.on('keypress',(character,key)=>{// More stuffif(key.name==='c'&&key.ctrl){process.exit(0)}})
The key object tells us the name of the key pressed in lower case and has flags for if Ctrl or Shift have been pressed.
Now, let's add all of the arrow keys, space bar and enter inputs:
process.stdin.on('keypress',(character,key)=>{if(!hasLost&&!hasWon){// Do not move past right wallif(key.name==='right'&&playerX<width-1){playerX++}// Do not move past left wallif(key.name==='left'&&playerX>0){playerX--}// Do not move past down wallif(key.name==='down'&&playerY<height-1){playerY++}// Do not move past up wallif(key.name==='up'&&playerY>0){playerY--}// Uncovering fieldsif(key.name==='return'){uncoverCoords(playerX,playerY)// The player seems to have found a mineif(field[playerY][playerX]==='m'){hasLost=true// Uncover all fields in case the player has lostuncoveredField=Array(height).fill([]).map(()=>Array(width).fill(true))}}// Placing a flagif(key.name==='space'){flaggedField[playerY][playerX]=!flaggedField[playerY][playerX]hasWon=checkIfWon()}}// Show the player what just happened on the fieldrenderField(playerX,playerY)if(hasLost){console.log('Lost :(')}if(hasWon){console.log('Won :)')}if(key.name==='c'&&key.ctrl){process.exit(0)}})
You can also play by executing npx minesweeper-cli.js
Enjoy!
I hope you enjoyed reading this article as much as I enjoyed writing it! If so, leave a β€οΈ or a π¦! I write tech articles in my free time and like to drink coffee every once in a while.