(Cover photo by Flickr user FolsomNatural)
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?
Let's have a look at a 5 by 5 field with 3 mines:
+----------+
|0 0 0 0 0 |
|2 2 1 1 1 |
|m m 1 1 m |
|2 2 1 1 1 |
|0 0 0 0 0 |
+----------+
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:
+----------+
|0 0 0 0 0 |
|2 2 1 1 1 |
| |
| |
| |
+----------+
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.
The app should be callable like so:
node minesweeper.js --width=10 --height=10 --mines=20
This should result in a 10x10 playing field with 10 randomly placed mines.
We'll use some regular expressions to parse out these arguments:
const getArg = (args, name) => {
const match = args.match(new RegExp('--' + name + '=(\\d+)'))
if (match === null) {
throw new Error('Missing argument ' + name)
}
return parseInt(match[1])
}
let width = 0
let height = 0
let mines = 0
try {
const args = process.argv.slice(2).join(' ')
width = getArg(args, 'width')
height = getArg(args, 'height')
mines = getArg(args, 'mines')
if (width < 1 || height < 1) {
throw new Error('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.
Building the field
Where were we? Right.
Now we create a little utility function:
const getNeighbouringCoords = (x, y) => [
[y - 1, x - 1],
[y - 1, x],
[y - 1, x + 1],
[y, x + 1],
[y, x - 1],
[y + 1, x - 1],
[y + 1, x],
[y + 1, x + 1],
].filter(([y, x]) => (
y >= 0 && x >= 0 && x < width && y < height
))
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"
const createMatrix = v => Array(width).fill([]).map(
() => Array(height).fill(v)
)
const field = createMatrix(0)
// We'll overwrite this matrix later, hence `let`
let uncoveredField = createMatrix(false)
const flaggedField = 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:
while (mines > 0) {
const mineX = Math.round(Math.random() * (width - 1))
const mineY = Math.round(Math.random() * (height - 1))
if (field[mineY][mineX] !== 'm') {
field[mineY][mineX] = 'm'
getNeighbouringCoords(mineX, mineY)
.filter(([y, x]) => field[y][x] !== 'm')
.forEach(([y, x]) => {
field[y][x]++
})
mines--
}
}
Let's test that:
+----------+
|0 1 2 2 1 |
|0 1 m m 1 |
|0 1 2 3 2 |
|0 0 0 1 m |
|0 0 0 1 1 |
+----------+
Works like a charm!
Check if the player has won
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:
const checkIfWon = () => {
return flaggedField.every(
(row, y) => row.every(
(cell, x) => {
return (cell && field[y][x] === 'm')
|| (!cell && field[y][x] !== 'm')
})
)
}
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:
const characterMap = {
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:
const renderField = (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.
// ...
for (let y = 0; y < height; y++) {
let row = 'π§±'
for (let x = 0; x < width; x++) {
// ...
}
row += 'π§±'
console.log(row)
}
// ...
To finish the rendering, we now only need to add the different states for every x and y coordinates:
for (let y = 0; y < height; y++) {
let row = 'π§±'
for (let x = 0; x < width; x++) {
if (x === playerX && y === playerY) {
row += '\x1b[47m\x1b[30m'
}
if (flaggedField[y][x]) {
row += 'π©'
} else if (uncoveredField[y][x]) {
row += characterMap[field[y][x]]
} else {
row += ' '
}
if (x === playerX && y === playerY) {
row += '\x1b[0m'
}
}
row += 'π§±'
console.log(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:
const uncoverCoords = (x, y) => {
// Uncover the field by default
uncoveredField[y][x] = true
const neighbours = 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:
let playerX = 0
let playerY = 0
let hasLost = false
let hasWon = false
Then we render the field initially:
renderField(playerX, playerY)
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:
const readlineModule = 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 stuff
if (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 wall
if (key.name === 'right' && playerX < width - 1) {
playerX++
}
// Do not move past left wall
if (key.name === 'left' && playerX > 0) {
playerX--
}
// Do not move past down wall
if (key.name === 'down' && playerY < height - 1) {
playerY++
}
// Do not move past up wall
if (key.name === 'up' && playerY > 0) {
playerY--
}
// Uncovering fields
if (key.name === 'return') {
uncoverCoords(playerX, playerY)
// The player seems to have found a mine
if (field[playerY][playerX] === 'm') {
hasLost = true
// Uncover all fields in case the player has lost
uncoveredField = Array(height).fill([]).map(() => Array(width).fill(true))
}
}
// Placing a flag
if (key.name === 'space') {
flaggedField[playerY][playerX] = !flaggedField[playerY][playerX]
hasWon = checkIfWon()
}
}
// Show the player what just happened on the field
renderField(playerX, playerY)
if (hasLost) {
console.log('Lost :(')
}
if (hasWon) {
console.log('Won :)')
}
if (key.name === 'c' && key.ctrl) {
process.exit(0)
}
})
Aaaand we're done!
I wanna play, too!
You can, actually! I made it open source:
thormeier / minesweeper.js
Minesweeper, but on the CLI!
Minesweeper JS
A simple emoji based Minesweeper clone, playable on the CLI!
Usage
Download by cloning this repository, start it by running node minesweeper.js
or executing npx minesweeper-cli.js
Arguments
-
--width=number
- Width of the field, defaults to8
-
--height=number
- Height of the field, defaults to8
-
--mines=number
- Number of mines to place on the board, defaults to10
Detailed explanation
See my post over on dev.to/thormeier
!
License
MIT
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.
If you want to support my efforts, you can offer me a coffee β or follow me on Twitter π¦! You can also support me directly via Paypal!
Top comments (8)
Hey amazing post!!! Really like your writing style, it's fun to read :) I don't know how you get the ideas for the post, but thank you for your posts, they're really great π also, thanks for open sourcing it :)
Sorry for a small nitpick, but shouldn't the probability of mines be num mines/width*height, and not the other way around?
I'm glad you liked it! Have fun playing and examining it.
Don't be sorry, you're absolutely right! Corrected, thank you for spotting it. :)
wow brilliant
Thank you so much! π
Informative post
Thank you, glad you liked it! :)
super cool project
Thank you very much! I hope you enjoy playing - if you've got an idea for a feature, don't hesitate to open a pull request. π