Create MineSweeper in Rust

Photo by Matt Artz on Unsplash

Create MineSweeper in Rust

Learning rust by creating minesweeper

ยท

8 min read

Featured on Hashnode

For the next exercise, I want to create the classic game MineSweeper in Rust. Microsoft MineSweeper was included in Windows 3.11 and was a copy of another game Mined-Out. You can check the wiki page here. Credit due, much of this is based on this video by Yishn

The rules of MineSweeper are:

  • We have a board of clickable tiles
  • The player can left-click on the tiles which will reveal what's underneath
  • If the tile covers a bomb the player has lost the game
  • If the tile is not next to a bomb it is empty and will reveal all its siblings
  • If the tile is next to one or more bombs it will reveal how many bombs are next to it
  • The player can right-click the tile it will be flagged and cannot be left clicked

We will, like for the last posts, implement these in pieces until we have a full game. First, we need to set up a new project.

Let's run the cargo new to generate the project and for this, we will add --lib because we will implement the UI in WASM.

cargo new MineSweeper --lib
cd MineSweeper

Open the project in your favourite editor and let's get started. To begin with, we will remove the default test from the lib.rs file so we have a clean file. We will then implement the game model, let's call it MineSweeper.

type Position = (usize, usize);

struct MineSweeper {
    width: usize,
    height: usize,
    open_tiles: Vec<Position>,
    mines: Vec<Position>,
    flagged_tiles: Vec<Position>,
}

We are using a struct to define the model and a type alias to define the positions that we will use for the game. The positions are basically an x and y coordinate. A struct allows us to specify properties that we want to be part of our model, so we have a clear representation.

We will add width to define the width of our game board, and height to set the height of our game board. We will also need three properties to keep track of our tiles and mines. The tiles that have been opened, the positions of the mines and the positions of our flags.

We will also need to implement some methods on the game model, and for this, we will use the impl keyword. Let's start by adding the new function.

impl MineSweeper {
    pub fn new(width: usize, height: usize) -> MineSweeper {
        MineSweeper {
            width,
            height,
            open_cells: Vec::new(),
            mines: Vec::new(),
            flagged_cells: Vec::new(),
        }
    }
}

Now we are ready to proceed with implementing the game. You can find the first commit here

Now let's add the initial mines on the game board. For this, we will need to generate a random number, which we will use the rand package for. The function to generate a random number is quite simple, it requires the code below:

use rand::{thread_rng, Rng};

pub fn random_number(length: usize) -> usize {
  let mut rng = thread_rng();
  rng.gen_range(0..length)
}

Here we will generate a random number between 0 and the length value. We could also do this:

use rand::{thread_rng, Rng};

pub fn random_number(min: usize, max: usize) -> usize {
  let mut rng = thread_rng();
  rng.gen_range(min..max)
}

This will generate a random number between the two provided values, but I will keep it simple for our case and just have 0 as the lower value by default. Notice how I have not added a return or a semi-colon to the end of the function, this allows Rust to return the value of the last line in the function, see the documentation here.

We can now import the random_number function and use it to generate the mines.

mod random;

use random::random_number;

...

impl MineSweeper {
    pub fn new(width: usize, height: usize, mine_count: usize) -> MineSweeper {
        MineSweeper {
            width,
            height,
            open_cells: Vec::new(),
            mines: {
                let mut mines:Vec<Position> = Vec::new();

                while mines.len() < mine_count {
                    mines.push((random_number(width), random_number(height)))
                }

                mines
            },
            flagged_cells: Vec::new(),
        }
    }
}

You can find the commit here

We need to make one final change to the generation of the mines, which is to prevent duplicate mines.

For this, we will modify our while statement. First, we save the new Position in a variable, we then check if the vector contains that set of coordinates, if not we add it to the vector.

while mines.len() < mine_count {
    let new_mine = (random_number(width), random_number(height));
    if !mines.contains(&new_mine) {
        mines.push((random_number(width), random_number(height)))
    }
}

Here is the commit.

Now we can move on to the topic of user interactions.

First, we will create a function to handle the click on a cell. Let's call it open_cell. In this function, we have to add the cell that was opened to the vector of opened cells, and we need to check if the cell was a mine.

The first implementation will look like this:

pub fn open_cell(&mut self, position: Position) -> OpenResult {
    self.open_cells.push(position);

    let is_mine = self.mines.contains(&position);
    if is_mine {
        OpenResult::Mine
    } else {
        OpenResult::NoMine(8)
    }
}

Next, we need to provide the neighbours to the cell that was opened if it was not a mine. The function result will look like this:

pub fn neighbors(&self, (x, y): Position) -> impl Iterator<Item = Position> {
        let width = self.width;
        let height = self.height;

        (x.min(1) - 1..=(x + 1).max(width - 1)).flat_map(move |i| (
                y.min(1) - 1..=(y + 1).max(height - 1)).map(move |j| (i, j)
        )).filter(move |&pos| pos != (x, y))
    }

first, we save the width and height to local variables so we can use them in our calculation. Then we start iterating from x - 1, but with a minimum value of 1 in order to avoid going outside the board, up to a maximum of the width -1 again to stay within the game board. We then take the value and move it into the flat_map function. The vertical lines define a closure if you are unsure of its meaning go to the rust documentation and take some time to read about it. Inside the flat_map we do the same, we iterate over y from 1 to the height - 1, and as the final piece, we exclude the position that was clicked from this list of positions. Now we have an iterator that can run through all 8 adjacent cells.

Then we have to check if any of those cells are mine.

For this, we will write a neighbor_mines function which will use the iterator we just reacted and check if any of them exist in the list of mines. We will then return the mine count as a u8, this will save some memory.

pub fn neighbor_mines(&self, pos: Position) -> u8 {
    self
        .neighbors(pos)
        .filter(|pos| self.mines.contains(pos))
        .count() as u8
}

It is now time to implement the flags, which the player can set to keep track of where they believe a mine might be.

First we will add a toggle_flag method.

pub fn toggle_flag(&mut self, pos: Position) {
    if self.open_cells.contains(&pos) {
        return;
    }
    if self.flagged_cells.contains(&pos) {
        let index = self.flagged_cells.iter().position(|&item| item == pos).unwrap();
        self.flagged_cells.remove(index);
    } else {
        self.flagged_cells.push(pos);
    }
}

In this method we will first check if the cell is already opened, and if so, we will just return. If its not opened we will check if its already flagged, if it is we find its index in the vector and remove the item at that index. If its not already flagged we add it to the flagged cells vector.

We also have to update our open_cell function to prevent the opening of a flagged cell. We will do this by adding a similar condition to the beginning of the function. But in order to do so we also have to add a new return value to our OpenResult.

enum OpenResult {
    Mine,
    NoMine(u8),
    Flagged,
}

...

pub fn open_cell(&mut self, position: Position) -> OpenResult{
    if self.flagged_cells.contains(&position) {
        return OpenResult::Flagged;
    }
    self.open_cells.push(position);

    let is_mine = self.mines.contains(&position);
    if is_mine {
        OpenResult::Mine
    } else {
        OpenResult::NoMine(8)
    }
}

You can see the latest additions on this commit.

Now lets add a display function to test it.

Lets add a display and modify our test.

use std::fmt::{Display, Write};

...

impl Display for MineSweeper {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        for y in 0..self.height {
            for x in 0..self.width {
                let pos = (x, y);

                if !self.open_cells.contains(&pos) {
                    if self.flagged_cells.contains(&pos) {
                        f.write_str("๐Ÿšฉ ")?;
                    } else {
                        f.write_str("๐ŸŸช ")?;
                    }
                } else if self.mines.contains(&pos) {
                f.write_str("๐Ÿ’ฃ ")?;
                } else {
                let mine_count = self.neighbor_mines(pos);

                if mine_count > 0 {
                    write!(f, " {} ", mine_count)?;
                } else {
                    f.write_str("โฌœ ")?;
                }
                }
            }

            f.write_char('\n')?;
        }

        Ok(())
    }
}

...

fn test_new_game() {
    let mut ms = MineSweeper::new(10, 10, 5);
    ms.open_cell((5, 5));
    ms.toggle_flag((6, 6));
    ms.open_cell((6, 6));

    println!("{}", ms);
}

This display implementation will iterate though all cells and draw either a purple square if its open, a flag if the cell is flagged, a bomb if the cell is opened and its a bomb, the number of neighbour bombs if there are any, or a white square if it is an empty square and has no neighbour bombs.

Our test creates the 10x10 game with 5 bombs and then opens 1 cell, flags another and for the sake of testing tries to open the flagged cell.

Next article will be about building the UI for playing the game.

ย