I am LOVING all things Blazor! You can probably tell, from my previous posts about it. The ability to write C# and have it run in the browser is amazing, particularly for those of us who don't like JavaScript very much.
With the recent release of Blazor WebAssembly to general availability in .NET Core 3.2, I've been spending all my free time on building some new models with it. I want to share what I've learned so far, and to do that in a fun way. So, let's build a game.
Specifically, let's build the venerable computer game Minesweeper using Blazor WebAssembly.
In this post and the next, we will build a working game of Minesweeper and implement it using Blazor WebAssembly, ASP.NET Core, and C#. Let's get going!
The Sample Code
As always, the sample code for this series can be found over on GitHub.
Also check out the other posts in my Modeling Practice series:
Modeling the Game
NOTE: I previously modeled a Minesweeper solver as a C# project, and much of the C# code in these posts is modified from that one.
Let's take a look at a sample board of Minesweeper.
There are a couple of components we need to consider when modeling this game, and it all starts with the small pieces in the board that the user clicks on. We're going to call those pieces "Panels", because that's what they look like.
Each panel can be either unrevealed (the starting state) or revealed, and once revealed, each panel has either a mine, a number that represents the count of adjacent mines, or a blank space. Further, an unrevealed panel can be "flagged" so that the user cannot accidentally click on it; this is done to mark where the mines likely are.
Let's create a C# class that has all of these attributes:
Each panel has two possible actions that can be performed against it: they can be revealed (when clicked), or flagged (when right-clicked).
public class Panel
{
//... Properties
public void Flag()
{
if(!IsRevealed)
{
IsFlagged = !IsFlagged;
}
}
public void Reveal()
{
IsRevealed = true;
IsFlagged = false; //Revealed panels cannot be flagged
}
}
As far as the panels are concerned, this is the extent of their functionality. The real work comes next, as we begin to design the game board itself.
The Board
A game of minesweeper takes place on a board of X width and Y height, where those numbers are defined by the user. Each board has Z mines hidden in its panels. For every panel that is not a mine, a number is placed showing the count of mines adjacent to that panel. We therefore need our GameBoard class to keep track of its own dimensions, mine count, and collection of panels.
We will be greatly expanding this class as we develop our game.
Game Status
Because the appearance of the Minesweeper board is determined by the state of the game, we can use an enumeration to track said state.
public enum GameStatus
{
AwaitingFirstMove,
InProgress,
Failed,
Completed
}
We must also allow the board to track the game state:
public class GameBoard
{
//...Other properties
public GameStatus Status { get; set; }
}
Now let's build out the functionality of this board.
Initializing the Game
Every game of Minesweeper begins in the same state, with all panels hidden. Let's create a method to initialize a game, including creating the collection of panels. Given the height, width, and mine count as parameters, here's what our initialization method would look like:
public class GameBoard
{
//...Properties
public void Initialize(int width, int height, int mines)
{
Width = width;
Height = height;
MineCount = mines;
Panels = new List<Panel>();
int id = 1;
for (int i = 1; i <= height; i++)
{
for (int j = 1; j <= width; j++)
{
Panels.Add(new Panel(id, j, i));
id++;
}
}
Status = GameStatus.AwaitingFirstMove;
}
}
You might be wondering: why didn't we place the mines and calculate the adjacent numbers? That's because of a trick in most implementations of Minesweeper: they don't calculate where the mines are placed until after the user clicks on their first panel. This is so that the user doesn't click on a mine on the first move, because that's no fun.
For later usage, let's also implement a Reset method, which will reset the board to a new game using the same width, height, and mine count:
public class GameBoard
{
//...Other Properties and Methods
public void Reset()
{
Initialize(Width, Height, MineCount);
}
}
Now we can work on the next major step of this implementation: the first move.
The First Move and Getting Neighbors
We now need a method which represents the user's first move, and therefore determines where the mines go and calculates the adjacent mines numbers. That algorithm looks something like this:
- When the user makes the first move, take that panel and a certain number of neighbor panels, and mark them as unavailable for mines.
- For every other panel, place the mines randomly.
- For every panel which is not a mine (including the panels from Step 1), calculate the adjacent mines.
- Mark the game as started
The trickiest part of this algorithm is calculating the neighbors of a given panel. Remember that the neighbors of any given panel are the first panel above, below, to the left, to the right, and on each diagonal.
For our implementation, we have an entire method to do this:
public class GameBoard
{
//...Other Properties and Methods
public List<Panel> GetNeighbors(int x, int y)
{
var nearbyPanels = Panels.Where(panel => panel.X >= (x - 1)
&& panel.X <= (x + 1)
&& panel.Y >= (y - 1)
&& panel.Y <= (y + 1));
var currentPanel = Panels.Where(panel => panel.X == x
&& panel.Y == y);
return nearbyPanels.Except(currentPanel).ToList();
}
}
Using our "first move" algorithm, the new GetNeighbors()
method, and some fancy LINQ, we end up with this method to implement the first move:
public class GameBoard
{
//...Other Properties and Methods
public void FirstMove(int x, int y)
{
Random rand = new Random();
//For any board, take the user's first revealed panel
// and any neighbors of that panel, and mark them
// as unavailable for mine placement.
var neighbors = GetNeighbors(x, y); //Get all neighbors
//Add the clicked panel to the "unavailable for mines" group.
neighbors.Add(Panels.First(z => z.X == x && z.Y == y));
//Select all panels from set which are available for mine placement.
//Order them randomly.
var mineList = Panels.Except(neighbors)
.OrderBy(user => rand.Next());
//Select the first Z random panels.
var mineSlots = mineList.Take(MineCount)
.ToList()
.Select(z => new { z.X, z.Y });
//Place the mines in the randomly selected panels.
foreach (var mineCoord in mineSlots)
{
Panels.Single(panel => panel.X == mineCoord.X
&& panel.Y == mineCoord.Y)
.IsMine = true;
}
//For every panel which is not a mine,
// including the unavailable ones from earlier,
// determine and save the adjacent mines.
foreach (var openPanel in Panels.Where(panel => !panel.IsMine))
{
var nearbyPanels = GetNeighbors(openPanel.X, openPanel.Y);
openPanel.AdjacentMines = nearbyPanels.Count(z => z.IsMine);
}
//Mark the game as started.
Status = GameStatus.InProgress;
}
}
There's one last thing we need to finish the first move implementation: a method which activates when a user makes a move, and if it is the first move, calls our FirstMove()
method. Here's that method:
public class GameBoard
{
//...Other Properties and Methods
public void MakeMove(int x, int y)
{
if (Status == GameStatus.AwaitingFirstMove)
{
FirstMove(x, y);
}
RevealPanel(x, y);
}
}
All right! First move implementation is complete, and we can now move on to what happens on every other panel.
Revealing Panels
For every move except the first one, clicking on a panel reveals that panel. A more specific algorithm for moves after the first one might be this:
- When the user left-clicks on a panel, reveal that panel.
- If that panel is a mine, show all mines and end the game.
- If that panel is a zero, show that panel and all neighbors. If any neighbors are also zero, show all their neighbors, and continue until all adjacent zeroes and their neighbors are revealed. (I termed this a "cascade reveal").
- If that panel is NOT a mine, check to see if any remaining unrevealed panels are not mines. If there are any, the game continues.
- If all remaining unrevealed panels are mines, the game is complete. This is the "win" condition.
Using that algorithm, we end up with this method:
public class GameBoard
{
//...Other Properties and Methods
public void RevealPanel(int x, int y)
{
//Step 1: Find and reveal the clicked panel
var selectedPanel = Panels.First(panel => panel.X == x
&& panel.Y == y);
selectedPanel.Reveal();
//Step 2: If the panel is a mine, show all mines. Game over!
if (selectedPanel.IsMine)
{
Status = GameStatus.Failed;
RevealAllMines();
return;
}
//Step 3: If the panel is a zero, cascade reveal neighbors.
if (selectedPanel.AdjacentMines == 0)
{
RevealZeros(x, y);
}
//Step 4: If this move caused the game to be complete, mark it as such
CompletionCheck();
}
}
We now have several methods to create to finish this part of our implementation.
Revealing All Mines
The simplest of the methods we need is this one: showing all mines on the board.
public class GameBoard
{
//...Other Methods and Properties
private void RevealAllMines()
{
Panels.Where(x => x.IsMine)
.ToList()
.ForEach(x => x.IsRevealed = true);
}
}
Cascade Reveal Neighbors
When a zero is clicked, the game needs to reveal all of that panel's neighbors, and if any of the neighbors are zero, reveal the neighbors of that panel as well. This calls for a recursive method:
Is The Game Complete?
A game of Minesweeper is complete when all remaining unrevealed panels are mines. We can use this method to check for that:
public class GameBoard
{
//...Other Properties and Methods
private void CompletionCheck()
{
var hiddenPanels = Panels.Where(x => !x.IsRevealed)
.Select(x => x.ID);
var minePanels = Panels.Where(x => x.IsMine)
.Select(x => x.ID);
if (!hiddenPanels.Except(minePanels).Any())
{
Status = GameStatus.Completed;
Stopwatch.Stop();
}
}
}
With this functionality in place, there's only one thing left to do: flagging.
Flagging a Panel
"Flagging" a panel means marking it as the location of a mine. Doing this means that left-clicking that panel will do nothing.
In our game, we will use right-click to flag a panel.
Recall that our Panel
class already has a Flag()
method. Now we just need to call that method from the GameBoard
:
public class GameBoard
{
//...Other Properties and Methods
public void FlagPanel(int x, int y)
{
var panel = Panels.Where(z => z.X == x && z.Y == y).First();
panel.Flag();
}
}
Just a little bit left to do.
Implementing a Timer
Take a look at the top part of the Minesweeper game we saw earlier:
Other than the smiley face's piercing stare, what do you notice? The counters!
The left counter is for mines; it goes down as the number of flagged panels increases. The right counter is for the time; it ticks up every second. We need to implement a timer for our Minesweeper game.
For this, we will use the C# Stopwatch class. We will use the following algorithm:
- Whenever the game is created or reset, we reset the timer,
- When the player makes their first move, start the timer.
- When the game is either complete or failed, stop the timer.
The changes we need to make to our GameBoard class look like this:
public class GameBoard
{
//...Other Properties
public Stopwatch Stopwatch { get; set; }
//...Other Methods
public void Reset()
{
Initialize(Width, Height, MineCount);
Stopwatch = new Stopwatch();
}
public void FirstMove()
{
//...Implementation
Stopwatch.Start();
}
private void CompletionCheck()
{
var hiddenPanels = Panels.Where(x => !x.IsRevealed)
.Select(x => x.ID);
var minePanels = Panels.Where(x => x.IsMine)
.Select(x => x.ID);
if (!hiddenPanels.Except(minePanels).Any())
{
Status = GameStatus.Completed;
Stopwatch.Stop(); //New line
}
}
}
Ta-da! With the timer implemented, all of our implementation for the Minesweeper game is complete!
Summary
To model a game of Minesweeper, we needed a Panel
class and a GameBoard
class. The panels can be revealed or flagged, and the board tracks everything else, including the dimensions, mine count, and current game status.
Mines are not placed on the board until after the user clicks their first panel, at which point they are spread randomly around.
When a panel is clicked, if it's a mine, it's game over. If it's a zero, we reveal all neighbors, and if any neighbors are also zero, we reveal their neighbors. If it's a number, the game continues.
If, after revealing a panel, all remaining unrevealed panels are mines, the player has won the game!
Thanks for Reading!
Thank you, dear readers, for reading this post. Watch this space on Thursday for Part 2 of this series, where we finish our implementation by creating a Blazor WebAssembly component using this code, as well as a special announcement.
Don't forget to check out the sample project over on GitHub! And if you see a way to improve this implementation, don't hesitate to submit a pull request.
Happy Coding!