I used to play Minesweeper whenever I wanted to pass time, always using the same online site to play.
After getting into game development I thought it would be fun to revisit the game and learn how it was made and make the it myself.
I also decided to publish it to Steam to create a dedicated place to play it from.
As there are quite a few existing Minesweeper games already on Steam and on online sites,
I decided to add some unique game modes I had not previously seen before: Classic, Hexagonal, 3D and Endless Mode.
⇅
public class Grid
{
private CellData[,] _grid;
public int Width => _grid.GetLength(0);
public int Height => _grid.GetLength(1);
public CellData this[int x, int y] => _grid[x, y];
public Grid(int width, int height)
{
_grid = new CellData[width, height];
for (int x = 0; x < _grid.GetLength(0); x++)
{
for (int y = 0; y < _grid.GetLength(1); y++)
{
CellData cellData = ScriptableObject.CreateInstance<CellData>();
_grid[x, y] = cellData;
cellData.Position = new Vector3Int(x, y, 0);
cellData.Type = CellData.CellType.Empty;
}
}
}
}
Grid Class
The Grid class initializes a 2D array of Celldata objects, each representing an individual cell.
Every cell is assigned a position within the grid and a default type of Empty.
By using Celldata as a Scriptable Object makes it easier to manage each cell's properties and state changes.
This allows cells to be modified and tracked simply by accessing their celldata.
Mine Placement
Mines are generated when the player first clicks on a cell.
A list of valid cells is created, which includes every cell in the grid, excluding the initial clicked cell and it's adjacent neighbors.
The list is then shuffled into a random order.
Mines are placed by looping through the list until the set number of mines have been placed.
⇅
public void CreateCellMines(CellData clickedCell, int amount)
{
int width = Width;
int height = Height;
int placedAmount = 0;
List<Vector2Int> validCells = new List<Vector2Int>();
for (int x = 0; x < width; x++)
{
for (int y = 0; y < height; y++)
{
CellData cellData = _grid[x, y];
// Exclude the clicked cell and its neighbors
if (!IsAdjacent(clickedCell, cellData) &&
!(x == clickedCell.Position.x && y == clickedCell.Position.y))
{
validCells.Add(new Vector2Int(x, y));
}
}
}
// Shuffle the list of possible positions to randomize mine placement
validCells = validCells.OrderBy(a => Random.value).ToList();
// Place the mines
for (int i = 0; i < amount && validCells.Count > 0; i++)
{
Vector2Int pos = validCells[0];
validCells.RemoveAt(0); // Remove the position to avoid placing another mine here
_grid[pos.x, pos.y].Type = CellData.CellType.Mine;
placedAmount++;
}
}
⇅
public void CreateCellNumbers()
{
int width = Width;
int height = Height;
for (int x = 0; x < width; x++)
{
for (int y = 0; y < height; y++)
{
CellData cellData = _grid[x, y];
if (cellData.Type == CellData.CellType.Mine) continue;
cellData.Number = GetAdjacentMines(cellData);
cellData.Type = cellData.Number > 0 ? CellData.CellType.Number : CellData.CellType.Empty;
}
}
}
Number Placement
Numbers are generated based on the number of adjacent mines there are to each individual cell.
For each cell in the grid, the number of adjacent mines is calculated and stored in it's celldata.
Mine cells are skipped, and cell with no adjacent mines are set to empty.
Reveal Cells
When a hidden cell is clicked there are three outcomes:
- If the cell is a mine, the game ends.
- If the cell is empty, an area of cells are revealed.
- If the cell is a number, the number is revealed.
An area of cells is revealed by recursively checking the adjacent neighbors of empty cells.
If a neighbor is either empty or a number, it is revealed.
⇅
private void RevealCellData(CellData cellData)
{
if (cellData.IsRevealed || cellData.IsFlagged) return;
switch (cellData.Type)
{
case CellData.CellType.Mine:
ExplodeCells(cellData);
_audioManager.PlayExplosion();
break;
case CellData.CellType.Empty:
RevealArea(cellData);
_audioManager.PlayRevealCell();
CheckWinCondition();
break;
default:
cellData.IsRevealed = true;
_audioManager.PlayRevealCell();
CheckWinCondition();
break;
}
RefreshGame();
}
private void RevealArea(CellData cellData)
{
if (_gameIsOver || cellData.IsRevealed || cellData.Type == CellData.CellType.Mine) return;
Queue<CellData> cellsToReveal = new Queue<CellData>();
HashSet<CellData> processedCells = new HashSet<CellData>();
cellsToReveal.Enqueue(cellData);
while (cellsToReveal.Count > 0)
{
CellData currentCell = cellsToReveal.Dequeue();
if (currentCell.IsRevealed || processedCells.Contains(currentCell)) continue;
currentCell.IsRevealed = true;
processedCells.Add(currentCell);
// If the cell is empty, enqueue all 8 surrounding cells
if (currentCell.Type == CellData.CellType.Empty)
{
IEnumerable<Vector2Int> adjacentOffsets = GameModeManager.Instance.CurrentGameMode != GameModeManager.GameMode.Hex
? _grid.GetAllAdjacent()
: _grid.GetAdjacentHex(currentCell);
foreach (Vector2Int offset in adjacentOffsets)
{
int neighbourX = currentCell.Position.x + offset.x;
int neighbourY = currentCell.Position.y + offset.y;
if (_grid.TryGetCellData(neighbourX, neighbourY, out CellData adjacentCell))
{
if (!adjacentCell.IsRevealed && adjacentCell.Type != CellData.CellType.Mine)
cellsToReveal.Enqueue(adjacentCell);
}
}
}
}
}
⇅
private void Unchord(CellData chord)
{
chord.IsChorded = false;
IEnumerable<Vector2Int> adjacentOffsets = GameModeManager.Instance.CurrentGameMode != GameModeManager.GameMode.Hex
? _grid.GetAllAdjacent()
: _grid.GetAdjacentHex(chord);
foreach (Vector2Int offset in adjacentOffsets)
{
int x = chord.Position.x + offset.x;
int y = chord.Position.y + offset.y;
if (_grid.TryGetCellData(x, y, out CellData cellData))
{
if (cellData.IsRevealed && cellData.Type == CellData.CellType.Number)
{
if (_grid.GetAdjacentFlags(cellData) >= cellData.Number)
{
RevealCellData(chord);
return;
}
}
}
}
}
Chord Cells
Chording is a method used to automatically reveal nearby cells when certain conditions are met.
If a revealed number cell has the same number of adjacent flags as it's number, it can be chorded.
This reveals all the cell's adjacent neghbors, except for it's flagged neighbors.
If any revealed neighbor is a mine, the game ends.
Steam Achievements Manager
The Achievements Manager uses Steam's Steamworks API to set and update achievements.
It follows the singleton pattern to ensure only one instance exists.
- SetAchievement sets a specific achievement and saves it.
- SetStats updates a stat and saves it.
For example, an achievement requiring 10 wins would update after each win.
⇅
public class AchievementsManager : MonoBehaviour
{
private static AchievementsManager _instance;
public static AchievementsManager Instance
{
get { return _instance; }
}
private void Awake()
{
if (_instance == null)
{
_instance = this;
DontDestroyOnLoad(gameObject);
}
else
{
Destroy(gameObject);
}
}
public void SetAchievement(string ach)
{
SteamUserStats.SetAchievement(ach);
SteamUserStats.StoreStats();
}
public void SetStats(string stat, int data)
{
SteamUserStats.SetStat(stat, data);
SteamUserStats.StoreStats();
}
public void ClearAllAchievements()
{
SteamUserStats.ResetAllStats(true);
}
public void ClearAchievement(string ach)
{
SteamUserStats.ClearAchievement(ach);
}
public void HasAchievement(string ach)
{
SteamUserStats.GetAchievement(ach, out bool achieved);
Debug.Log($"Achievement: {ach}, Status: {achieved}");
}
}