DEV Community

Cover image for Let's have a blast of fun! How to build a Minesweeper clone for the CLI with NodeJS πŸ’£πŸ”
Pascal Thormeier
Pascal Thormeier

Posted on

Let's have a blast of fun! How to build a Minesweeper clone for the CLI with NodeJS πŸ’£πŸ”

(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 |
+----------+
Enter fullscreen mode Exit fullscreen mode

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 |
|          |
|          |
|          |
+----------+
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
))
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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--
  }
}
Enter fullscreen mode Exit fullscreen mode

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 |
+----------+
Enter fullscreen mode Exit fullscreen mode

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')
      })
  )
}
Enter fullscreen mode Exit fullscreen mode

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️⃣ ',
}
Enter fullscreen mode Exit fullscreen mode

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')
}
Enter fullscreen mode Exit fullscreen mode

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)
}
// ...
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)
      }
    })
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Then we render the field initially:

renderField(playerX, playerY)
Enter fullscreen mode Exit fullscreen mode

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
})
Enter fullscreen mode Exit fullscreen mode

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)
  }
})
Enter fullscreen mode Exit fullscreen mode

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)
  }
})
Enter fullscreen mode Exit fullscreen mode

Aaaand we're done!

I wanna play, too!

You can, actually! I made it open source:

GitHub logo thormeier / minesweeper.js

Minesweeper, but on the CLI!

Minesweeper JS

A simple emoji based Minesweeper clone, playable on the CLI!

A screenshot of the Minesweeper.js 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 to 8
  • --height=number - Height of the field, defaults to 8
  • --mines=number - Number of mines to place on the board, defaults to 10

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!

Buy me a coffee button

Top comments (8)

Collapse
 
yjdoc2 profile image
YJDoc2

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?

Collapse
 
thormeier profile image
Pascal Thormeier

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. :)

Collapse
 
jack94507501 profile image
Jack

wow brilliant

Collapse
 
thormeier profile image
Pascal Thormeier

Thank you so much! 😊

Collapse
 
coderlegi0n profile image
CoderLegion

Informative post

Collapse
 
thormeier profile image
Pascal Thormeier

Thank you, glad you liked it! :)

Collapse
 
waylonwalker profile image
Waylon Walker

super cool project

Collapse
 
thormeier profile image
Pascal Thormeier

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. πŸ˜€