Create Minesweeper UI
Following up on my last article on building the minesweeper game we will now create the UI for the game. First, we will do some cleaning to make the code more readable in the future.
We will move the MineSweeper struct and implementation into its own module and then import it into lib.rs. You can see the commit here.
Now, let's start constructing the UI. We start with a basic index.html file which will run the game.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
</body>
</html>
Similar to my article on creating a WebUI for wordle we will be using WASM for the UI here.
Lets add wasm-bindgen to the list of dependencies in cargo.toml.
[dependencies]
rand = "0.8.5"
wasm-bindgen = "0.2.83"
And add the lib crate-type declaration.
[lib]
crate-type = ["cdylib"]
You can see the changes to Cargo.toml here.
Let's update lib.rs. First, we will add the prelude module from wasm_bindgen.
use wasm_bindgen::prelude::*;
And in order for our random generation to work, we again have to include getrandom with the js feature enabled, as we did in the wordle project. So let's update the dependencies.
[dependencies]
rand = "0.8.5"
wasm-bindgen = "0.2.83"
getrandom = { version = "0.2", features = ["js"] }
To test the implementation run the wasm-pack build --target web
command to verify that it compiles, and creates the pkg folder with the ES Module code.
Now let's update our index.html to load the compiled module.
<body>
<div id="game"></div>
<script type="module">
import init, { greet } from "./pkg/MineSweeper.js";
</script>
</body>
We also need a server, for this, I will also reuse the solution from the wordle game, by installing http-server via npm.
npm install -g http-server
http-server
Now we are ready to create the UI, but first, we will do a small house cleaning item. We have to rename the MineSweeper.rs file to minesweeper.rs otherwise we will have a naming clash which prevents us from running the new function on the MineSweeper struct.
First let's create a function we can call to start the game. To begin with, it will return a string representation of our game, which we can do because we implemented the Display trait.
Update lib.rs with the following was_bindgen function. js_name will tell wasm that we can call this function using the specified name instead of the function name in Rust. Since naming conventions are different it's nice to make sure they are consistent with the language we are using them in.
#[wasm_bindgen(js_name = getGame)]
pub fn get_game() -> String {
let ms = MineSweeper::new(10, 10, 10);
ms.to_string()
}
And then update index.html to run this function.
<script type="module">
import init, { getGame } from "./pkg/MineSweeper.js";
init().then(() => {
console.log(getGame());
});
</script>
Now if we run the http-server we should see the minesweeper representation in the console.
Next, let's display the game on the page. For this, we will create a new render function in Javascript. In this function, we will get the game root node in the HTML and add the game state to this node.
init().then(() => {
render(getGame());
});
function render(state) {
let root = document.getElementById("game");
root.innerText = state;
}
Let's change this so we render HTML elements for the game and add a bit of styling to display the gameboard correctly. First, we need to split the items that are present in the string version of the game, we will add each element as a tag so they are clickable. We will add it all to a render function which we can run after our Init is done.
<script type="module">
import init, { getGame } from "./pkg/MineSweeper.js";
init().then(() => {
render(getGame());
});
function render(state) {
let root = document.getElementById("game");
root.innerHtml = "";
let gameElement = document.createElement("div");
gameElement.classList.add("game-board");
root.appendChild(gameElement);
let data = state.split("\n").map((row) => row.trim().split(/\s+/));
gameElement.style.gridTemplate = `repeat(${data.length}, 1fr) / repeat(${data[0].length}, 1fr)`;
for (let y = 0; y < data.length; y++) {
for (let x = 0; x < data[y].length; x++) {
let element = document.createElement("a");
element.classList.add("cell");
element.href = "#";
element.innerText = data[y][x];
gameElement.appendChild(element);
}
}
}
</script>
The arrangement will be done with CSS Grid, and in order to set up the gameboard, we will get the length of the outer array and the length of the first child array, which we will use to set the grid template style on the game-board element.
We also need a bit of styling in order to make this complete.
#game {
display: grid;
align-items: center;
justify-items: center;
}
.game-board {
display: grid;
grid-gap: 0.5rem;
}
.field {
text-decoration: none;
text-align: center;
}
Now we see a nicely laid out board of purple tiles. Let's add interaction to the tiles.
We will add event listeners in javascript to listen for clicks on the cells and then trigger the same action on the minesweeper game. We need to create a new openCell function in our lib.rs which we can import and call from javascript.
First let's update the Javascript.
import init, { getGame, openCell } from "./pkg/MineSweeper.js";
...
element.addEventListener('click', (e) => {
e.preventDefault();
openCell(x, y);
});
And now let's update rust. One small change is to the minesweeper.rs where we have to make OpenResults public.
pub enum OpenResult {
Mine,
NoMine(u8),
Flagged,
}
And then we need to create the openCell function. This will require several changes to our implementation.
use std::cell::RefCell;
use minesweeper::*;
use wasm_bindgen::prelude::*;
thread_local! {
static GAME: RefCell<MineSweeper> = RefCell::new(MineSweeper::new(10, 10, 10));
}
#[wasm_bindgen(js_name = getGame)]
pub fn get_game() -> String {
return GAME.with(|game| game.borrow().to_string());
}
#[wasm_bindgen(js_name = openField)]
pub fn open_field(x: usize, y:usize) -> String {
GAME.with(|game| {
game.borrow_mut().open_cell((x, y));
return game.borrow().to_string();
})
}
We have to store a reference to the game. This will be done in a static variable that's wrapped in a threat_local declaration which will make the static object to be available only in a single thread instead of being multithreaded, and since wasm is single threaded this will not impact us. Using RefCell we are decoupling the game variable from the borrow checker at compile time and it will instead be enforced at runtime. This means the responsibility to ensure borrow checking is on our shoulders. Using WASM the compiler will have difficulties ensuring the borrow checking at compile time due to the external exposure, and we then add the open_field function which calls the game open_cell internal function.
The open_field function will return a new string representation of the game in its current state. You can see this commit here.
Now let's add the right click to toggle a flag. For this, we need to add the toggle_flag function in lib.rs and update our javascript to handle the right click and call the function.
Lib.rs
#[wasm_bindgen(js_name = toggleFlag)]
pub fn toggle_flag(x: usize, y:usize) -> String {
GAME.with(|game| {
game.borrow_mut().toggle_flag((x, y));
return game.borrow().to_string();
})
}
Javascript
import init, { getGame, openCell, toggleFlag } from "./pkg/MineSweeper.js";
...
element.addEventListener('contextmenu', function(ev) {
ev.preventDefault();
render(toggleFlag(x, y));
return false;
}, false);
When testing this, I also found a bug that caused incorrect calculations of the neighbour mines which I have corrected in the commit.
Let's implement our lost condition. We need to add a new attribute to the minesweeper struct, we will call it lost. If we click on a mine we set lost to true, and then we have to prevent actions in open_cell and toggle_flag if lost is true.
pub struct MineSweeper {
width: usize,
height: usize,
open_cells: Vec<Position>,
mines: Vec<Position>,
flagged_cells: Vec<Position>,
lost: bool,
}
...
pub fn open_cell(&mut self, position: Position) -> OpenResult{
if self.lost || self.flagged_cells.contains(&position) {
return OpenResult::Flagged;
}
self.open_cells.push(position);
let is_mine = self.mines.contains(&position);
if is_mine {
self.lost = true;
OpenResult::Mine
} else {
OpenResult::NoMine(8)
}
}
...
pub fn toggle_flag(&mut self, pos: Position) {
if self.lost || 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);
}
}
You can see the commit here.
We also want to make it so if a cell has no mines, then its neighbors are automatically opened until all empty cells are open.
For this, we will add an iteration to the open_cell function which will go through all neighbours of an empty cell and open them. I also did a bit of refactoring because I found a bug in the open_cell code.
pub fn open_cell(&mut self, position: Position) -> Option<OpenResult> {
if self.lost || self.flagged_cells.contains(&position) {
return None;
}
self.open_cells.push(position);
let is_mine = self.mines.contains(&position);
if is_mine {
self.lost = true;
Some(OpenResult::Mine)
} else {
let mine_count = self.neighbor_mines(position);
if mine_count == 0 {
for neightbor in self.neighbors(position) {
if !self.open_cells.contains(&neightbor) {
self.open_cell(neightbor);
}
}
}
Some(OpenResult::NoMine(mine_count))
}
}
I wrapped the return in an Option in order to return an empty result when the cell has been flagged or the game is lost.
When the game is lost we want to display all the existing mine. To do this we will update the Display implementation like so:
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.lost && self.mines.contains(&pos) {
f.write_str("๐ฃ ")?;
} else 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(())
}
We make it so that when we check if the open cells do not contain position then in case we have already lost the game we will draw a bomb if the cell contains a mine.
The next thing to do is to make so the game opens all neighbour cells that already have the correct number of flags displayed.
pub fn open_cell(&mut self, position: Position) -> Option<OpenResult> {
if self.open_cells.contains(&position) {
let mine_count = self.neighbor_mines(position);
let flag_count = self.neighbors(position).filter(|neighbor| self.flagged_cells.contains(neighbor)).count() as u8;
if (mine_count - flag_count) == 0 {
for neighbor in self.neighbors(position) {
if !self.flagged_cells.contains(&neighbor) && !self.open_cells.contains(&neighbor) {
self.open_cell(neighbor);
}
};
}
return None;
}
if self.lost || self.flagged_cells.contains(&position) {
return None;
}
When we open a cell, if the cell is already open we will get its neighbouring mine count and the neighbouring flag count and compare the two. If they are equal then we will open all cells that are closed and haven't been flagged next to the cell.
And that's it for the game. It was really fun to follow the video and to play around with improving the code a bit.
I hope you enjoyed it, on to the next project.
Subscribe to my newsletter
Read articles from Jesper Bisgaard directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Jesper Bisgaard
Jesper Bisgaard
Web developer with a passion for architecture and speed.