DEV Community

Cover image for How to Build a Minesweeper CLI Game in Node.js (Part 3/3)
Razvan
Razvan

Posted on • Edited on

How to Build a Minesweeper CLI Game in Node.js (Part 3/3)

Welcome back to Minesweeper Part 3.

Last time you built the display: terminal UI, game loop, command parsing and validation, win/lose checks.

In this final part, you’ll ask the player for their username, keep track of their score, and display the list of high score at the end of each game.

Let’s build!

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

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

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

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);

      // ...
    }
  });
})();
Enter fullscreen mode Exit fullscreen mode

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 │ ■  ■  ■  ■  ■  ■ │
   ──────────────────

>
Enter fullscreen mode Exit fullscreen mode

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
  };

  // ...
})();
Enter fullscreen mode Exit fullscreen mode

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

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++;
            }
          }

          // ...
        }
      }
    }
  });
})();
Enter fullscreen mode Exit fullscreen mode

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++;
              }
            }
          }

          // ...
        }
      }
    }
  });
})();
Enter fullscreen mode Exit fullscreen mode

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)) {
            // ...
          }

          // ...
        }
      }
    }
  });
})();
Enter fullscreen mode Exit fullscreen mode

💡 New to backend programming in Node.js?

Check out the Learn Backend Mastery Program — a complete zero-to-hero roadmap that takes you from beginner to job-ready junior Node.js backend developer in 12 months.

👉 learnbackend.dev


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

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

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');

// ...
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

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

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

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

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

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

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

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

Final thoughts

Congratulations — you’ve built a fully playable Minesweeper CLI in Node.js with high score capabilities!

Thank you for reading and don’t hesitate to like, comment, and share if you enjoy my work

What’s next?

💾 Want to run this project on your machine? Download the source code for free here.

🚀 Go further: If you’re serious about backend development, check out the Learn Backend Mastery Program — a complete zero-to-hero roadmap to become a professional Node.js backend developer and land your first job in 12 months.

Top comments (0)