Code in Action

How to Build a Minesweeper CLI Game in Node.js

1 hrs 5 min read·Sep 27, 2025·Download source code

Welcome back to Code in Action, the series where we build practical backend projects step by step.

In this tutorial, we're going to recreate another classic command-line interface game: the Minesweeper.

Minesweeper CLI Game Preview

By the end of this tutorial, you'll learn how to:

  • Model a grid with 2D arrays: represent each cell's state (hidden, revealed, flagged, mine) cleanly.
  • Seed mines without duplicates: place N mines at random and keep the board consistent.
  • Compute neighbor counts: scan the 8 directions to fill in "adjacent mines" numbers.
  • Parse player commands from the terminal: accept inputs, validate, and handle errors gracefully.
  • Design a clear game loop: prompt → parse → update → render → repeat, with win/lose checks.
  • Render readable CLI output: print a neat grid with coordinates, symbols for flags/mines, and quick status lines.
  • Write small, testable functions: keep pure board logic separate from I/O so it's easy to extend.

Ready? Let's build!

Game Rules

At the start of the game, a fixed number of mines are randomly placed across a grid of size N by N, where each mine occupies a hidden square. The goal is to uncover all the safe squares on the grid without revealing a mine.

When you reveal a square:

  1. If it contains a mine → you lose (game over).
  2. If it does not contain a mine, it shows a number (0–8).

That number is the count of mines in the 8 surrounding squares (up, down, left, right, and diagonals). For example, if a square shows 2, exactly two of its neighbors contain mines.

You can also "flag" a square if you suspect it contains a mine.

You win either by:

  1. Revealing all safe squares.
  2. Correctly flagging all mines.

Step 0: Create the Script

Let's start by creating a new file named minesweeper.js and open it in a text editor.

touch minesweeper.js

Step 1: Create an IIFE Entry Point

Within this file, let's create the script's entry point using an Immediately Invoked Function Expression (IIFE).

(() => {
  //
})();

Step 2: Create an Empty Grid

Above the IIFE, let's define a new function named createGrid() that will be used to generate a new square grid of an arbitrary size.

function createGrid() {
  //
}

Within the function's body, let's declare a constant named size used to define the size of the grid and initialize with 6.

function createGrid() {
  const size = 6;
}

Next, let's declare a constant named grid used to represent the two-dimensional grid, and initialize it with an array of arrays, where each element of the subarrays contains an object that represents a square, where:

  • mine indicates if it holds a mine

  • adjacent indicates how many mines are around

  • revealed indicates if the square was revealed

  • flagged indicates if the square was flagged

And let's return the grid array.

function createGrid() {
  const size = 6;
  const grid = Array.from({ length: size }, () => Array.from({ length: size }, () => ({
    mine: false,
    adjacent: 0,
    revealed: false,
    flagged: false
  })));

  return grid;
}

Finally, within the entry point, let's declare a new constant named grid and initialize it with the value returned by the createGrid() function.

function createGrid() {
  /* ... */
}

(() => {
  const grid = createGrid();
})();

Note that in order the keep the code and the changes made to it as readable as possible, I'll often comment already written parts using either one of the following expressions:

  • // ...

  • /* ... */

Step 3: Parse the Grid Size from the CLI

Let's define a new function named parseGridSize() used to dynamically determine and set the size of the grid from the command-line interface.

function parseGridSize() {
  //
}

Within the function's body, let's declare a constant named size and initialize it with the value of the 3rd command-line argument of the script converted to an integer in base 10 using the parseInt() function.

function parseGridSize() {
  const size = parseInt(process.argv[2], 10);
}

Next, let's return the converted value if it is a valid number comprised between 3 and 10 included, or 6 otherwise.

function parseGridSize() {
  const size = parseInt(process.argv[2], 10);
  return isNaN(size) || size < 3 || size > 10 ? 6 : size;
}

Then, let's update the createGrid() function by:

  1. Changing its signature to include a new parameter named size.
  2. Removing the size constant from its body.
  3. Invoking the createGrid() function using the value returned by the parseGridSize() function as argument.
function parseGridSize() {
  // ...
}

function createGrid(size) {
  const grid = Array.from(/* ... */);

  return grid;
}

(() => {
  const size = parseGridSize();
  const grid = createGrid(size);
})();

Which now allows us to execute the minesweeper.js script with an additional (and optional) argument .

node minesweeper.js 9

Step 4: Place Random Mines in the Grid

Generate random number in a range

To be able to generate a random position in the grid, let's first define a new helper function named generateRandomNumber() that returns a random integer in a range of values.

function generateRandomNumber(min, max) {
  return Math.floor(Math.random() * (max - min + 1)) + min;
}

Place mines in the grid

Let's now define another function named placeMines() that randomly places N mines in the grid, where N is an integer equal to the size of the grid.

function placeMines(grid) {
  //
}

Within its body, let's define two constants named min and max and initialize them with the lowest and the highest defined indexes in the grid array.

function placeMines(grid) {
  const min = 0;
  const max = grid.length - 1;
}

Let's declare a variable named mines to count the number of mines placed in the grid and initialize it with 0.

function placeMines(grid) {
  const min = 0;
  const max = grid.length - 1;
  let mines = 0;
}

And let's use a while loop to:

  1. Generate at each iteration a random row and column number.
  2. Check if the square at this position doesn't already hold a mine.
  3. Place a mine at this position.
  4. Update the mines counter to keep track of how many mines have been placed.
function placeMines(grid) {
  const min = 0;
  const max = grid.length - 1;
  let mines = 0;

  while (mines < grid.length) {
    let row = generateRandomNumber(min, max);
    let col = generateRandomNumber(min, max);
    let square = grid[row][col];

    if (!square.mine) {
      square.mine = true;
      mines++;
    }
  }
}

Update the grid

Let's update the createGrid() function and invoke the placeMines() function within it to update the newly generated grid.

function createGrid(size) {
  const grid = Array.from(/* ... */);

  placeMines(grid);
  return grid;
}

Step 5: Compute and Place Numbers in the Grid

Compute adjacent mines

Let's define a new function named placeNumbers() that will for each square of the grid count how many adjacent squares hold a mine and update the value of its adjacent property accordingly.

function placeNumbers(grid) {
  //
}

Within its body, let's set up two nested loops that will iterate on each square of each row, column by column, and skip the squares that hold a mine.

function placeNumbers(grid) {
  for (let row = 0 ; row < grid.length ; row++) {
    for (let col = 0 ; col < grid.length ; col++) {
      let square = grid[row][col];

      if (square.mine) {
        continue;
      }
    }
  }
}

Let's declare a constant named deltas that contains a list of all the relative positions of the squares around.

function placeNumbers(grid) {
  const deltas = [
    [-1, -1], // top-left
    [-1, 0],  // top
    [-1, 1],  // top-right
    [0, 1],   // right
    [1, 1],   // bottom-right
    [1, 0],   // bottom
    [1, -1],  // bottom-left
    [0, -1],  // left
  ];

  for (let row = 0 ; row < grid.length ; row++) {
    for (let col = 0 ; col < grid.length ; col++) {
      let square = grid[row][col];

      if (square.mine) {
        continue;
      }
    }
  }
}

Let's declare a for...of loop that iterates on each element of the deltas array, and checks if the position of the square minus or plus the value of the current delta is out of the bounds of the grid.

function placeNumbers(grid) {
  const deltas = [[-1, -1], [-1, 0], [-1, 1], [0, 1], [1, 1], [1, 0], [1, -1], [0, -1]];

  for (let row = 0 ; row < grid.length ; row++) {
    for (let col = 0 ; col < grid.length ; col++) {
      let square = grid[row][col];

      if (square.mine) {
        continue;
      }

      for (const [deltaX, deltaY] of deltas) {
        if (row + deltaX < 0 || row + deltaX > grid.length - 1 || col + deltaY < 0 || col + deltaY > grid.length - 1) {
          continue;
        }
      }
    }
  }
}

Let's declare a variable named count that will keep track of the mines held in the adjacent squares present within the bounds of the grid, and update the adjacent property of the current square once the for...of loop has terminated.

function placeNumbers(grid) {
  const deltas = [[-1, -1], [-1, 0], [-1, 1], [0, 1], [1, 1], [1, 0], [1, -1], [0, -1]];

  for (let row = 0 ; row < grid.length ; row++) {
    for (let col = 0 ; col < grid.length ; col++) {
      let square = grid[row][col];

      if (square.mine) {
        continue;
      }

      let count = 0;

      for (const [deltaX, deltaY] of deltas) {
        if (row + deltaX < 0 || row + deltaX > grid.length - 1 || col + deltaY < 0 || col + deltaY > grid.length - 1) {
          continue;
        }
        if (grid[row + deltaX][col + deltaY].mine) {
          count++;
        }
      }

      square.adjacent = count;
    }
  }
}

Update the grid

Let's update the createGrid() function and invoke the placeNumbers() function within it to update the grid.

function createGrid(size) {
  const grid = Array.from(/* ... */);

  placeMines(grid);
  placeNumbers(grid);
  return grid;
}

Step 6: Set Up the Game Loop

Now that our grid generation process is complete, let's initialize our game by:

  1. Clearing the terminal window.
  2. Printing a "Welcome" message that tells the user how to play the game.
  3. Printing the formatted grid.
  4. Prompting the user to play by typing a command.

Clear the terminal window

Let's define a new function named clearScreen() that outputs the \x1Bc escape code to the standard output, which clears the console from any previous output.

function clearScreen() {
  process.stdout.write('\x1Bc');
}

Print a "Welcome" message

Let's define a new function named printWelcome() that greets the user and tells them how to play.

Whenever prompted the user can:

  • Type rROW,COL to reveal a square (e.g., r0,3).

  • Type fROW,COL to flag/unflag a square (e.g., f2,4).

function printWelcome() {
  process.stdout.write('Welcome to MinesweeperJS!\n\n');
  process.stdout.write('> Type "rROW,COL" to reveal a square (e.g., r0,3).\n');
  process.stdout.write('> Type "fROW,COL" to flag/unflag a square (e.g., f2,4).\n\n');
  process.stdout.write('Got it? Let\'s play!\n\n');
}

For example:

  • r0,0 reveals the square at row 0, column 0.

  • f1,1 flags the square at row 1, column 1.

0  1  2  3 
   ────────────
02  ■  ■  ■ │
1 │ ■  ⚑  ■  ■ │
2 │ ■  ■  ■  ■ │
3 │ ■  ■  ■  ■ │
   ────────────

Prompt the user to play

Let's define a new function named printPrompt() that outputs a > symbol, prompting the user to enter a command (e.g., r1,3 for revealing the square at row 1, column 3).

function printPrompt() {
  process.stdout.write('> ');
}

Print the formatted grid

Let's define a new function named printGrid() that outputs the updated grid in the following format:

0  1  2  3  4  5  6  7  8  9 
   ──────────────────────────────
00  1  1  ■  *  ■  ■  ■  ■  ■ │
10  2  ⚑  ■  ■  ■  ■  ■  ■  ■ │
20  0  ⚑  ■  ■  ■  ■  ■  ■  ■ │
3 │ ■  ■  ■  ■  ■  ■  ■  ■  ■  ■ │
4 │ ■  ■  ■  ■  ■  ■  ■  ■  ■  ■ │
5 │ ■  ■  ■  ■  ■  ■  ■  ■  ■  ■ │
6 │ ■  ■  ■  ■  ■  ■  ■  ■  ■  ■ │
7 │ ■  ■  ■  ■  ■  ■  ■  ■  ■  ■ │
8 │ ■  ■  ■  ■  ■  ■  ■  ■  ■  ■ │
9 │ ■  ■  ■  ■  ■  ■  ■  ■  ■  ■ │
   ──────────────────────────────

Where:

  • The numbers at the top of the grid represent the column numbers of the grid.
  • The numbers on the side of the grid represent the row numbers of the grid.
  • The ■ symbols inside the grid represent the squares that haven't yet been revealed.
  • The numbers inside the grid represent the squares that have already been revealed.
  • The ⚑ symbols inside the grid represent the squares that have been flagged.
  • The * symbols inside the grid represent a mine that exploded.

Within the function's body, let's first output the column numbers and the top grid separation line under each number.

function printGrid(grid) {
  process.stdout.write('   ');
  for (let i = 0 ; i < grid.length ; i++) {
    process.stdout.write(` ${i} `);
  }

  process.stdout.write('\n   ');
  for (let i = 0 ; i < grid.length ; i++) {
    process.stdout.write('───');
  }
  process.stdout.write('\n');
}

Let's then start a for loop that, for each row, outputs:

  1. The row number
  2. The state of the square (unrevealed, adjacent mines, mine, or flagged)
  3. The right grid separation line
function printGrid(grid) {
  process.stdout.write('   ');
  for (let i = 0 ; i < grid.length ; i++) {
    process.stdout.write(` ${i} `);
  }

  process.stdout.write('\n   ');
  for (let i = 0 ; i < grid.length ; i++) {
    process.stdout.write('───');
  }
  process.stdout.write('\n');
  
  for (let row = 0 ; row < grid.length ; row++) {
    process.stdout.write(`${row}`);

    for (let col = 0 ; col < grid.length ; col++) {
      let square = grid[row][col];
      let char = '■';
      
      if (square.revealed) {
        char = square.mine ? '*' : String(square.adjacent);
      }
      else if (square.flagged) {
        char = '⚑';
      }
      process.stdout.write(` ${char} `);
    }
    process.stdout.write('│\n');
  }
}

Finally, let's output the bottom grid separation line.

function printGrid(grid) {
  process.stdout.write('   ');
  for (let i = 0 ; i < grid.length ; i++) {
    process.stdout.write(` ${i} `);
  }

  process.stdout.write('\n   ');
  for (let i = 0 ; i < grid.length ; i++) {
    process.stdout.write('───');
  }
  process.stdout.write('\n');
  
  for (let row = 0 ; row < grid.length ; row++) {
    process.stdout.write(`${row}`);

    for (let col = 0 ; col < grid.length ; col++) {
      let square = grid[row][col];
      let char = '■';
      
      if (square.revealed) {
        char = square.mine ? '*' : String(square.adjacent);
      }
      else if (square.flagged) {
        char = '⚑';
      }
      process.stdout.write(` ${char} `);
    }
    process.stdout.write('│\n');
  }
  
  process.stdout.write('   ');
  for (let i = 0 ; i < grid.length ; i++) {
    process.stdout.write('───');
  }
  process.stdout.write('\n\n');
}

Put it together

Let's now invoke each of these functions within the IIFE in the following order.

(() => {
  const size = parseGridSize();
  const grid = createGrid(size);

  clearScreen();
  printWelcome();
  printGrid(grid);
  printPrompt();
})();

When launching the script, you should now see something similar in your terminal window.

$ node minesweeper.js 10
Welcome to MinesweeperJS!

> Type "rROW,COL" to reveal a square (e.g., r0,3).
> Type "fROW,COL" to flag/unflag a square (e.g., f2,4).

Got it? Let's play!

    0  1  2  3  4  5  6  7  8  9 
   ──────────────────────────────
0 │ ■  ■  ■  ■  ■  ■  ■  ■  ■  ■ │
1 │ ■  ■  ■  ■  ■  ■  ■  ■  ■  ■ │
2 │ ■  ■  ■  ■  ■  ■  ■  ■  ■  ■ │
3 │ ■  ■  ■  ■  ■  ■  ■  ■  ■  ■ │
4 │ ■  ■  ■  ■  ■  ■  ■  ■  ■  ■ │
5 │ ■  ■  ■  ■  ■  ■  ■  ■  ■  ■ │
6 │ ■  ■  ■  ■  ■  ■  ■  ■  ■  ■ │
7 │ ■  ■  ■  ■  ■  ■  ■  ■  ■  ■ │
8 │ ■  ■  ■  ■  ■  ■  ■  ■  ■  ■ │
9 │ ■  ■  ■  ■  ■  ■  ■  ■  ■  ■ │
   ──────────────────────────────
>

Step 7: Read and Parse User Input

Let's set up an event listener that will execute a callback function every time the standard input stream receives a 'data' event, which will be in turn triggered when the user presses the ENTER key.

(() => {
  // ...
  
  process.stdin.on('data', input => {
    //
  });
})();

Let's convert the input into a string using the toString() method and clean it by removing any whitespaces and newlines before and after using the trim() method.

(() => {
  // ...
  
  process.stdin.on('data', input => {
    const line = input.toString().trim();
  });
})();

Step 8: Check the Command Format

Let's use a regular expression that will match and extract the following pattern r|fROW,COL from the user input line, where:

  • rROW,COL is a command used to reveal a square at row ROW and column COL in the grid (e.g., r0,2).

  • fROW,COL is a command used to flag/unflag a square at row ROW and column COL in the grid (e.g., f3,9).

(() => {
  // ...
  
  process.stdin.on('data', input => {
    const line = input.toString().trim();
    const match = line.match(/^(r|f)(\d+),(\d+)$/i);
  });
})();

If the command is invalid because it doesn't match the regular expression format:

  1. Clear the terminal screen
  2. Output an error message
  3. Print the grid
  4. Print the prompt
(() => {
  // ...
  
  process.stdin.on('data', input => {
    const line = input.toString().trim();
    const match = line.match(/^(r|f)(\d+),(\d+)$/i);

    if (!match) {
      clearScreen();
      process.stdout.write('Error: Invalid command\n\n');
      printGrid(grid);
      printPrompt();
    }
  });
})();

Step 9: Check the Square Position

If the command format is valid, extract and convert the command, the row number, and the column number.

(() => {
  // ...
  
  process.stdin.on('data', input => {
    const line = input.toString().trim();
    const match = line.match(/^(r|f)(\d+),(\d+)$/i);

    if (!match) {
      clearScreen();
      process.stdout.write('Error: Invalid command\n\n');
      printGrid(grid);
      printPrompt();
    }
    else {
      const cmd = match[1];
      const row = parseInt(match[2]);
      const col = parseInt(match[3]);
    }
  });
})();

If the square position is out of bounds (negative or greater than the grid's last index):

  1. Clear the terminal screen
  2. Output an error message
  3. Print the grid
  4. Print the prompt
(() => {
  // ...
  
  process.stdin.on('data', input => {
    const line = input.toString().trim();
    const match = line.match(/^(r|f)(\d+),(\d+)$/i);

    if (!match) {
      clearScreen();
      process.stdout.write('Error: Invalid command\n\n');
      printGrid(grid);
      printPrompt();
    }
    else {
      const cmd = match[1];
      const row = parseInt(match[2]);
      const col = parseInt(match[3]);
      
      if (row < 0 || col < 0 || row > grid.length - 1 || col > grid.length - 1) {
        clearScreen();
        process.stdout.write('Error: Invalid square\n\n');
        printGrid(grid);
        printPrompt();
      }
    }
  });
})();

Step 10: Reveal a Square

If the square position is valid and the command is rROW,COL:

  1. Clear the terminal screen
  2. Mark the square as revealed
  3. Print the updated grid
  4. Check if the square was a mine, output a "Game over" message, and terminate the process
  5. Print the prompt
(() => {
  // ...
  
  process.stdin.on('data', input => {
    const line = input.toString().trim();
    const match = line.match(/^(r|f)(\d+),(\d+)$/i);

    if (!match) {
      clearScreen();
      process.stdout.write('Error: Invalid command\n\n');
      printGrid(grid);
      printPrompt();
    }
    else {
      const cmd = match[1];
      const row = parseInt(match[2]);
      const col = parseInt(match[3]);

      if (row < 0 || col < 0 || row > grid.length - 1 || col > grid.length - 1) {
        clearScreen();
        process.stdout.write('Error: Invalid square\n\n');
        printGrid(grid);
        printPrompt();
      }
      else {
        let square = grid[row][col];

        clearScreen();

        if (cmd === 'r') {
          square.revealed = true;
        }

        printGrid(grid);

        if (square.revealed && square.mine) {
          process.stdout.write('💥 Boooooom! Game over...\n\n');
          process.exit(0);
        }

        printPrompt();
      }
    }
  });
})();

Check if all squares are revealed

Let's define a new function named checkRevealedSquares() that returns true if all the safe squares (the ones without mines) have been revealed, and false otherwise.

function checkRevealedSquares(grid) {
  const maxRevealed = (grid.length * grid.length) - grid.length;
  let count = 0;
  
  for (let row = 0 ; row < grid.length ; row++) {
    for (let col = 0 ; col < grid.length ; col++) {
      let square = grid[row][col];

      if (!square.mine && square.revealed) {
        count++;
      }
    }
  }
  return count === maxRevealed;
}

Let's update the IIFE to invoke this function, output a "win" message if it returns true, and immediately terminate the process.

(() => {
  // ...
  let flags = size;
  
  process.stdin.on('data', input => {
    // ...

    if (!match) {
      // ...
    }
    else {
      const cmd = match[1];
      const row = parseInt(match[2]);
      const col = parseInt(match[3]);

      if (/* ... */) {
        // ...
      }
      else {
        // ...

        if (square.revealed && square.mine) {
          process.stdout.write('💥 Boooooom! Game over...\n\n');
          process.exit(0);
        }
        else if (checkRevealedSquares(grid)) {
          process.stdout.write('🏆 You win!\n\n');
          process.exit(0);
        }

        printPrompt();
      }
    }
  });
})();

Step 11: Flag a Square

At the top of the IIFE, let's declare a new variable named flags that will keep track of the number of flags available and initialize it to the size of the grid. Note that the number of flags available should equal the number of mines in the grid.

(() => {
  const size = parseGridSize();
  const grid = createGrid(size);
  let flags = size;
  
  // ...
})();

Within the if block that handles the "reveal" command, let's add a condition that checks if the revealed square was previously flagged, unflag it and increase the number of flags available.

(() => {
  // ...
  let flags = size;
  
  process.stdin.on('data', input => {
    // ...

    if (!match) {
      // ...
    }
    else {
      const cmd = match[1];
      const row = parseInt(match[2]);
      const col = parseInt(match[3]);

      if (/* ... */) {
        // ...
      }
      else {
        let square = grid[row][col];

        clearScreen();

        if (cmd === 'r') {
          square.revealed = true;

          if (square.flagged) {
            square.flagged = false;
            flags++;
          }
        }

        // ...
      }
    }
  });
})();

Let's create a new else...if block to handle the "flag" command, and within it let's add 3 more checks:

  1. If the square is unflagged and there are no more flags available, output an error message.
  2. If the square is already flagged, unflag it and increase the number of available flags.
  3. If the square is not yet revealed, flag it and decrease the number of available flags.
(() => {
  // ...
  let flags = size;
  
  process.stdin.on('data', input => {
    // ...

    if (!match) {
      // ...
    }
    else {
      const cmd = match[1];
      const row = parseInt(match[2]);
      const col = parseInt(match[3]);

      if (/* ... */) {
        // ...
      }
      else {
        let square = grid[row][col];

        clearScreen();

        if (cmd === 'r') {
          square.revealed = true;

          if (square.flagged) {
            square.flagged = false;
            flags++;
          }
        }
        else if (cmd === 'f') {
          if (!square.flagged && !flags) {
            process.stdout.write('Error: No more flags\n\n');
          }
          else if (square.flagged) {
            square.flagged = false;
            flags++;
          }
          else if (!square.revealed) {
            square.flagged = true;
            flags--;
          }
        }

        // ...
      }
    }
  });
})();

Check if all mines are flagged

Let's define a new function named checkFlaggedMines() that returns true if all the mines have been correctly flagged, and false otherwise.

function checkFlaggedMines(grid) {
  let count = 0;

  for (let row = 0 ; row < grid.length ; row++) {
    for (let col = 0 ; col < grid.length ; col++) {
      let square = grid[row][col];

      if (square.mine && square.flagged) {
        count++;
      }
    }
  }

  return count === grid.length;
}

Let's update the IIFE to invoke this function, output a "win" message if it returns true, and immediately terminate the process.

(() => {
  // ...
  let flags = size;
  
  process.stdin.on('data', input => {
    // ...

    if (!match) {
      // ...
    }
    else {
      const cmd = match[1];
      const row = parseInt(match[2]);
      const col = parseInt(match[3]);

      if (/* ... */) {
        // ...
      }
      else {
        // ...

        if (square.revealed && square.mine) {
          process.stdout.write('💥 Boooooom! Game over...\n\n');
          process.exit(0);
        }
        else if (checkFlaggedMines(grid) || checkRevealedSquares(grid)) {
          process.stdout.write('🏆 You win!\n\n');
          process.exit(0);
        }

        printPrompt();
      }
    }
  });
})();

Step 12: Ask for the Username

First, let's slightly modify the printWelcome() function so that instead of outputting a message with the game commands, it asks for the player's username.

function printWelcome() {
  process.stdout.write('Welcome to MinesweeperJS!\n\n');
  process.stdout.write('> Enter your username: ');
}

And let's create a new function named printCommands() that outputs the game commands.

function printCommands() {
  process.stdout.write('\nHow to play:\n\n');
  process.stdout.write('> Type "rROW,COL" to reveal a square (e.g., r0,3).\n');
  process.stdout.write('> Type "fROW,COL" to flag/unflag a square (e.g., f2,4).\n\n');
  process.stdout.write('Got it? Let\'s play!\n\n');
}

Within the IIFE block, let's declare a new undefined variable named username and let's remove the first calls to the printGrid() and printPrompt() functions for now.

(() => {
  const size = parseGridSize();
  const grid = createGrid(size);
  let flags = size;
  let username;

  clearScreen();
  printWelcome();

  process.stdin.on('data', input => {
    // ...
  });
})();

Then, within the body of the event listener's callback function, let's declare an if statement that will only be executed if the username variable is undefined, update the value of the username variable, and print the game commands, grid, and prompt.

(() => {
  const size = parseGridSize();
  const grid = createGrid(size);
  let flags = size;
  let username;

  clearScreen();
  printWelcome();

  process.stdin.on('data', input => {
    const line = input.toString().trim();
    
    if (!username) {
      username = line;
      printCommands();
      printGrid(grid);
      printPrompt();
    } else {
      const match = line.match(/^(r|f)(\d+),(\d+)$/i);

      // ...
    }
  });
})();

Which will ultimately produce the following output when running the script.

$ node minesweeper.js
Welcome to MinesweeperJS!

> Enter your username: razvan

How to play:

> Type "rROW,COL" to reveal a square (e.g., r0,3).
> Type "fROW,COL" to flag/unflag a square (e.g., f2,4).

Got it? Let's play!

    0  1  2  3  4  5 
   ──────────────────
0 │ ■  ■  ■  ■  ■  ■ │
1 │ ■  ■  ■  ■  ■  ■ │
2 │ ■  ■  ■  ■  ■  ■ │
3 │ ■  ■  ■  ■  ■  ■ │
4 │ ■  ■  ■  ■  ■  ■ │
5 │ ■  ■  ■  ■  ■  ■ │
   ──────────────────

>

Step 13: Keep Track of the Score

Within the IIFE, let's declare a new variable named score and initialize it with an object where:

  • time is used to store the number of milliseconds elapsed since the epoch at the beginning of the game.

  • revealed is used to count the number of safe squares revealed by the player.

  • flagged is used to count the number of mines correctly flagged by the player.

  • exploded is used to store whether the player revealed a mine or not.

(() => {
  const size = parseGridSize();
  const grid = createGrid(size);
  let flags = size;
  let username;
  let score = {
    time: 0,
    revealed: 0,
    flagged: 0,
    exploded: false
  };

  // ...
})();

Keep track of the game duration

To keep track of the game' start time, let's update the score.time property with the value returned by the Date.now() method, as soon as the player enters their first valid command.

Then within the wining/losing conditions, let's calculate the difference between the game start and now, and divide it by 1000 to express it in seconds.

(() => {
  // ...

  process.stdin.on('data', input => {
    const line = input.toString().trim();
    
    if (!username) {
      // ...
    } else {
      const match = line.match(/^(r|f)(\d+),(\d+)$/i);
  
      if (!match) {
        // ...
      }
      else {
        // ...
  
        if (row < 0 || col < 0 || row > grid.length - 1 || col > grid.length - 1) {
          // ...
        }
        else {
          let square = grid[row][col];

          if (!score.time) {
            score.time = Date.now();
          }

          // ...

          if (square.revealed && square.mine) {
            score.time = (Date.now() - score.time) / 1000;
            process.stdout.write('💥 Boooooom! Game over...\n\n');
            process.exit(0);
          }
          else if (checkRevealedSquares(grid) || checkFlaggedMines(grid)) {
            score.time = (Date.now() - score.time) / 1000;
            process.stdout.write('🏆 You win!\n\n');
            process.exit(0);
          }

          printPrompt();
        }
      }
    }
  });
})();

Keep track of revealed squares

To keep track of revealed squares, let's increment the score.revealed property every time the square doesn't hold a mine within the condition that checks if the played command is "reveal".

(() => {
  // ...

  process.stdin.on('data', input => {
    const line = input.toString().trim();
    
    if (!username) {
      // ...
    } else {
      const match = line.match(/^(r|f)(\d+),(\d+)$/i);
  
      if (!match) {
        // ...
      }
      else {
        // ...
  
        if (row < 0 || col < 0 || row > grid.length - 1 || col > grid.length - 1) {
          // ...
        }
        else {
          let square = grid[row][col];

          if (!score.time) {
            score.time = Date.now();
          }

          clearScreen();
          
          if (cmd === 'r') {
            square.revealed = true;
            
            if (!square.mine) {
              score.revealed++;
            }
  
            if (square.flagged) {
              square.flagged = false;
              flags++;
            }
          }

          // ...
        }
      }
    }
  });
})();

Keep track of flagged mines

To keep track of flagged mines:

  1. Let's decrement the score.flagged property every time the number of flags available is incremented — which implies that the flagged square was either revealed (and therefore didn't hold a mine) or just unflagged.

  2. Let's increment the score.flagged property every time an unrevealed square that holds a mine is correctly flagged.

(() => {
  // ...

  process.stdin.on('data', input => {
    const line = input.toString().trim();
    
    if (!username) {
      // ...
    } else {
      const match = line.match(/^(r|f)(\d+),(\d+)$/i);
  
      if (!match) {
        // ...
      }
      else {
        // ...
  
        if (row < 0 || col < 0 || row > grid.length - 1 || col > grid.length - 1) {
          // ...
        }
        else {
          let square = grid[row][col];

          if (!score.time) {
            score.time = Date.now();
          }

          clearScreen();
          
          if (cmd === 'r') {
            square.revealed = true;

            if (!square.mine) {
              score.revealed++;
            }

            if (square.flagged) {
              square.flagged = false;
              flags++;
              score.flagged--;
            }
          }
          else if (cmd === 'f') {
            if (!square.flagged && !flags) {
              process.stdout.write('Error: No more flags\n\n');
            }
            else if (square.flagged) {
              square.flagged = false;
              flags++;
              score.flagged--;
            }
            else if (!square.revealed) {
              square.flagged = true;
              flags--;

              if (square.mine) {
                score.flagged++;
              }
            }
          }

          // ...
        }
      }
    }
  });
})();

Keep track of an exploded mine

Finally, to keep track of whether the player exploded a mine while revealing a square, let's set the score.exploded property to true within the lose condition.

(() => {
  // ...

  process.stdin.on('data', input => {
    const line = input.toString().trim();
    
    if (!username) {
      // ...
    } else {
      const match = line.match(/^(r|f)(\d+),(\d+)$/i);
  
      if (!match) {
        // ...
      }
      else {
        // ...
  
        if (row < 0 || col < 0 || row > grid.length - 1 || col > grid.length - 1) {
          // ...
        }
        else {
          // ...
          if (square.revealed && square.mine) {
            score.exploded = true;
            score.time = (Date.now() - score.time) / 1000;
            process.stdout.write('💥 Boooooom! Game over...\n\n');
            process.exit(0);
          }
          else if (checkRevealedSquares(grid) || checkFlaggedMines(grid)) {
            // ...
          }

          // ...
        }
      }
    }
  });
})();

Step 14: Output the Score

Calculate the total amount of points

Let's define a new function named calculatePoints() that returns the total amount of points scored by the player using this formula: points = 10 x the number of safe squares revealed + 25 x the number of correctly flagged mines + 100 if the player won the game.

function calculatePoints(score) {
  return 10 * score.revealed + 25 * score.flagged + (score.exploded ? 0 : 100);
}

Save the score into a file

First, let's create a new file named scores.json within the same directory as the minesweeper.js script, and write an empty array into it.

$ echo '[]' > scores.json

At the top of the script, let's import the core Node.js fs module used to interact with the file system.

const fs = require('node:fs');

// ...

To save the player's score into a file, let's create a new function named updateScores() that:

  1. Reads and parses the contents of JSON file that contains all the scores of all the games.
  2. Adds the new score to the list.
  3. Writes this list back into the file.
  4. Returns the list.
function updateScores(username, score) {
  //
}

Within the function's body, let's:

  1. Declare a new variable named file and initialize it with the path to the file that contains the list of scores.
  2. Declare a new variable named scores and initialize it with null.
  3. Return the scores variable.
function updateScores(username, score) {
  const file = './scores.json';
  let scores = null;

  return scores;
}

Let's declare a try...catch block that will be used to gracefully handle and log any potential errors that may be thrown while attempting to read, parse, or write the file.

function updateScores(username, score) {
  const file = './scores.json';
  let scores = null;

  try {
    //
  }
  catch(error) {
    console.error(error.message);
  }

  return scores;
}

Let's read the contents of the file using the fs.readFileSync() method and store it into the scores variable.

function updateScores(username, score) {
  const file = './scores.json';
  let scores = null;

  try {
    scores = fs.readFileSync(file, { encoding: 'utf8' });
  }
  catch(error) {
    console.error(error.message);
  }

  return scores;
}

Let's convert the contents of the scores variable from a string to an array using the JSON.parse() method.

function updateScores(username, score) {
  const file = './scores.json';
  let scores = null;

  try {
    scores = fs.readFileSync(file, { encoding: 'utf8' });
    scores = JSON.parse(scores);
  }
  catch(error) {
    console.error(error.message);
  }

  return scores;
}

Let's format and add the player's username and new score to the array contained in the scores variable.

function updateScores(username, score) {
  const file = './scores.json';
  let scores = null;

  try {
    scores = fs.readFileSync(file, { encoding: 'utf8' });
    scores = JSON.parse(scores);
    scores.push({
      username,
      points: calculatePoints(score),
      time: score.time,
    });
  }
  catch(error) {
    console.error(error.message);
  }

  return scores;
}

Let's stringify and write the scores array back into the file using the JSON.stringify() and fs.writeFileSync() methods.

function updateScores(username, score) {
  const file = './scores.json';
  let scores = null;

  try {
    scores = fs.readFileSync(file, { encoding: 'utf8' });
    scores = JSON.parse(scores);
    scores.push({
      username,
      points: calculatePoints(score),
      time: score.time,
    });
    fs.writeFileSync(file, JSON.stringify(scores), { encoding: 'utf8' });
  }
  catch(error) {
    console.error(error.message);
  }

  return scores;
}

Finally, within the IIFE, let's declare a new undefined variable named scores and update its value by invoking the updateScores() function within both the winning and the losing conditions.

(() => {
  const size = parseGridSize();
  const grid = createGrid(size);
  let flags = size;
  let username;
  let score = {
    revealed: 0,
    flagged: 0,
    exploded: false,
    time: 0,
  };
  let scores;

  clearScreen();
  printWelcome();

  process.stdin.on('data', input => {
    // ...
    
    if (!username) {
      // ...
    } else {
      const match = line.match(/^(r|f)(\d+),(\d+)$/i);
  
      if (!match) {
        // ...
      }
      else {
        // ...
  
        if (row < 0 || col < 0 || row > grid.length - 1 || col > grid.length - 1) {
          // ...
        }
        else {
          // ...
  
          if (square.revealed && square.mine) {
            score.exploded = true;
            score.time = (Date.now() - score.time) / 1000;
            process.stdout.write('💥 Boooooom! Game over...\n\n');
            scores = updateScores(username, score);
            process.exit(0);
          }
          else if (checkRevealedSquares(grid) || checkFlaggedMines(grid)) {
            score.time = (Date.now() - score.time) / 1000;
            process.stdout.write('🏆 You win!\n\n');
            scores = updateScores(username, score);
            process.exit(0);
          }

          printPrompt();
        }
      }
    }
  });
})();

Output the score and the high scores

Let's declare a new function named printScore() that outputs the player's score, as well as the 10 highest scores saved in the scores.json file

function printScore(username, score, scores) {
  //
}

In the following format.

@razvan
Score: 20
Time: 5.397s
Revealed: 2
Flagged: 0

=== TOP 10 ===

1. V1kt0R       185    76.896s
2. john         30     71.991s
3. razvan       20     5.397s

Within the function's body, let's output the player's username, points, game duration, number of safe squares revealed, and number of correctly flagged mines.

function printScore(username, score, scores) {
  process.stdout.write(`@${username}\n`);
  process.stdout.write(`Score: ${calculatePoints(score)}\n`);
  process.stdout.write(`Time: ${score.time}s\n`);
  process.stdout.write(`Revealed: ${score.revealed}\n`);
  process.stdout.write(`Flagged: ${score.flagged}\n`);
}

Let's then:

  1. Sort the scores in descending order based on their amount of points using the sort() method of the scores array

  2. Only keep the first 10 elements of the array using the slice() method

  3. Output each element using a for loop

function printScore(username, score, scores) {
  process.stdout.write(`@${username}\n`);
  process.stdout.write(`Score: ${calculatePoints(score)}\n`);
  process.stdout.write(`Time: ${score.time}s\n`);
  process.stdout.write(`Revealed: ${score.revealed}\n`);
  process.stdout.write(`Flagged: ${score.flagged}\n`);

  if (scores) {
    process.stdout.write('\n=== TOP 10 ===\n\n');

    let highScores = scores.sort((a, b) => {
      if (a.points > b.points) {
        return -1
      } else if (a.points < b.points) {
        return 1;
      } else {
        if (a.time < b.time) {
          return -1
        } else if (a.time > b.time) {
          return 1;
        }
        return 0;
      }
    }).slice(0, 10);

    for (let i = 0 ; i < highScores.length ; i++) {
      process.stdout.write(`${i + 1}. ${highScores[i].username.padEnd(12, ' ')} ${highScores[i].points.toString().padEnd(6, ' ')} ${highScores[i].time}s\n`);
    }
  }
}

Finally, let's invoke the printScore() function within both the winning and the losing conditions using the value returned by the updateScores() function.

(() => {
  const size = parseGridSize();
  const grid = createGrid(size);
  let flags = size;
  let username;
  let score = {
    revealed: 0,
    flagged: 0,
    exploded: false,
    time: 0,
  };
  let scores;

  clearScreen();
  printWelcome();

  process.stdin.on('data', input => {
    // ...
    
    if (!username) {
      // ...
    } else {
      const match = line.match(/^(r|f)(\d+),(\d+)$/i);
  
      if (!match) {
        // ...
      }
      else {
        // ...
  
        if (row < 0 || col < 0 || row > grid.length - 1 || col > grid.length - 1) {
          // ...
        }
        else {
          // ...
  
          if (square.revealed && square.mine) {
            score.exploded = true;
            score.time = (Date.now() - score.time) / 1000;
            process.stdout.write('💥 Boooooom! Game over...\n\n');
            scores = updateScores(username, score);
            printScore(username, score, scores);
            process.exit(0);
          }
          else if (checkRevealedSquares(grid) || checkFlaggedMines(grid)) {
            score.time = (Date.now() - score.time) / 1000;
            process.stdout.write('🏆 You win!\n\n');
            scores = updateScores(username, score);
            printScore(username, score, scores);
            process.exit(0);
          }

          printPrompt();
        }
      }
    }
  });
})();

Congratulations! 🎉

You've built a fully playable Minesweeper game in Node.js

Download the source code

Receive a private download link via email to the ZIP file including a Readme, the source code, and the configuration files of this project.

First name

Email address

By submitting this form, you agree to Learn Backend's Terms & Conditions and Privacy Policy.