Building Minesweeper 2
Making up the rules as we go along
I've done enough UI development to be able to test out the rules, but I'm going to just ignore that for now. Since I've built Minesweeper a handful of times now, I had a decent place to start from. This requires:
- A
GridPoint
struct to allow for addressing points on the grid - A
Game
struct that tracks all of the game state and allows the gameplay actions - Enums that report the state of the game (active, win, lose), and the possible states that each grid point can occupy.
The interfaces for all of that are:
struct GridPoint: Hashable, Identifiable {
var id: GridPoint { self }
let x: Int
let y: Int
}
struct Game {
var state: GameState { get }
var elapsedTime: TimeInterval { get }
var remainingMines: Int { get }
init(width: Int, height: Int, mineCount: Int)
subscript(_ gridPoint: GridPoint) -> Game.SpaceState { get }
mutating func reveal(_ gridPoint: GridPoint)
mutating func revealSurroundingIfSafe(_ gridPoint: GridPoint)
mutating func toggleFlag(_ gridPoint: GridPoint)
}
extension Game {
enum State {
case active
case win
case lose
}
}
extension Game {
enum SpaceState {
case unrevealed
case revealed(Int)
case flagged
case revealedMine
case flaggedMine
case incorrectFlag
case unrevealedMine
}
}
One interesting detail to note is that the SpaceState
includes both the states used for when the game is active, and when the game has been won or lost.
The Game
struct is also used to only allow valid moves. Hence why subscript
is read only, and then there are flag
and reveal
functions instead of allowing the game board direct write access to the game data. The data included in the Game
struct is:
struct Game {}
private let width: Int
private let height: Int
private let mineCount: Int
private let startDate = Date()
private var endDate: Date?
private (set) var state: State = .active
private var board: [GridPoint: Space]?
private var flagged = Set<GridPoint>()
private var mines = Set<GridPoint>()
private var revealed = Set<GridPoint>()
}
private extension Game {
enum Space {
case mine
case empty(Int)
}
}
The board
is an optional var, because in Minesweeper the first move should never be a mine. That can be accomplished in different ways. I went with the relatively simple option of removing the first revealed tile from the list of tiles that are allowed to hold mines. It also means that the board isn't created until the user reveals a square, event if they flag squares first.
I've decided on using a Dictionary
to store the grid points because in previous iterations I've used nested arrays, and have managed to write bugs related to grid / column lookup. To isolate out all of the code related to creating the board, it's implemented as extensions on Dictionary, including the initializer that takes all of the values and uses that to populate the grid. One thing that made me really happy in the implementation was that I could use some Collection<GridPoint>
for the helper functions and not need to convert from ArraySlice
to Array
in the init.
private extension Dictionary where Key == GridPoint, Value == Game.Space {
init(width: Int, height: Int, mineCount: Int, initialMove: GridPoint) {
let allGridPoints = Set<GridPoint>(width: 0..<width, height: 0..<height)
var board = [GridPoint: Game.Space](minimumCapacity: width * height)
board.setMines(allGridPoints.subtracting([initialMove]).shuffled().prefix(mineCount))
board.calculateCounts(allGridPoints)
self = board
}
mutating private func setMines(_ mineGridPoints: some Collection<GridPoint>) {
for index in mineGridPoints {
self[index] = .mine
}
}
mutating private func calculateCounts(_ gridPoints: some Collection<GridPoint>) {
for index in gridPoints {
switch self[index] {
case .mine: break
case .empty, .none:
self[index] = .empty(emptyCount(index))
}
}
}
private func emptyCount(_ gridPoint: GridPoint) -> Int {
gridPoint.surroundingGridPoints.filter { index in
switch self[index] {
case .empty, .none: return false
case .mine: return true
}
}.count
}
}
The last function of interest is the reveal function on the Game
, since it also handles calculating the win / loss state.
It enforces that:
- Only unrevealed, unflagged points can be revealed
- If the board doesn't exist, create it
- Only reveal points that already exist on the board, every valid tile is populated when the board is created. This lets us not worry about overflowing the board when revealing surrounding tiles when a point with zero adjacent mines is revealed.
- Reveal the point
- If the revealed tile has zero adjacent mines, reveal all tiles surrounding this tile
- If a tile containing a mine is revealed, end the game in failure
- Otherwise, end the game is the number of revealed tiles equals the number of tiles on the board minus the mine count
extension Game {
mutating func reveal(_ gridPoint: GridPoint) {
guard !flagged.contains(gridPoint), !revealed.contains(gridPoint) else { return }
createBoardIfNeeded(initialMove: gridPoint)
guard board?[gridPoint] != nil else { return }
revealed.insert(gridPoint)
if case .empty(let count) = board?[gridPoint], count == 0 {
for surroundingIndex in gridPoint.surroundingGridPoints {
reveal(surroundingIndex)
}
}
if mines.isDisjoint(with: revealed) {
if revealed.count == (width * height) - mineCount {
endDate = Date()
state = .win
}
} else {
endDate = Date()
state = .lose
}
}
}
There's a lot more going on in this struct to drive the rules of the game, so for anyone interested in taking a look at all of the code:
import Foundation
struct Game {
private let width: Int
private let height: Int
private let mineCount: Int
private let startDate = Date()
private var endDate: Date?
private (set) var state: State = .active
private var board: [GridPoint: Space]?
private var flagged = Set<GridPoint>()
private var mines = Set<GridPoint>()
private var revealed = Set<GridPoint>()
var elapsedTime: TimeInterval {
(endDate ?? Date()).timeIntervalSince(startDate)
}
var remainingMines: Int {
mineCount - flagged.count
}
init(width: Int, height: Int, mineCount: Int) {
self.width = width
self.height = height
self.mineCount = mineCount
}
subscript(_ gridPoint: GridPoint) -> SpaceState {
switch board?[gridPoint] {
case .none:
return .unrevealed
case .mine:
return mineState(gridPoint)
case .empty(let count):
return emptyState(gridPoint, count: count)
}
}
mutating func reveal(_ gridPoint: GridPoint) {
guard !flagged.contains(gridPoint), !revealed.contains(gridPoint) else { return }
createBoardIfNeeded(initialMove: gridPoint)
guard board?.keys.contains(gridPoint) == true else { return }
revealed.insert(gridPoint)
if case .empty(let count) = board?[gridPoint], count == 0 {
for surroundingIndex in gridPoint.surroundingGridPoints {
reveal(surroundingIndex)
}
}
if mines.isDisjoint(with: revealed) {
if revealed.count == (width * height) - mineCount {
endDate = Date()
state = .win
}
} else {
endDate = Date()
state = .lose
}
}
mutating func revealSurroundingIfSafe(_ gridPoint: GridPoint) {
guard revealed.contains(gridPoint),
case .empty(let mines) = board?[gridPoint]
else { return }
let surroundingGridPoints = gridPoint.surroundingGridPoints
guard surroundingGridPoints.intersection(flagged).count == mines else { return }
for gridPoint in surroundingGridPoints.subtracting(flagged) {
reveal(gridPoint)
}
}
mutating func toggleFlag(_ gridPoint: GridPoint) {
guard !revealed.contains(gridPoint) else { return }
flagged.formSymmetricDifference([gridPoint])
}
}
// MARK: - Private functions
private extension Game {
func mineState(_ gridPoint: GridPoint) -> SpaceState {
if revealed.contains(gridPoint) {
return .revealedMine
} else {
switch state {
case .lose, .win:
if flagged.contains(gridPoint) {
return .flaggedMine
} else {
return .unrevealedMine
}
case .active:
if flagged.contains(gridPoint) {
return .flagged
} else {
return .unrevealed
}
}
}
}
func emptyState(_ gridPoint: GridPoint, count: Int) -> SpaceState {
if revealed.contains(gridPoint) {
return .revealed(count)
} else if flagged.contains(gridPoint) {
switch state {
case .win, .lose:
return .incorrectFlag
case .active:
return .flagged
}
} else {
return .unrevealed
}
}
private mutating func createBoardIfNeeded(initialMove: GridPoint) {
guard board == nil else { return }
self.board = [GridPoint: Game.Space](width: width,
height: height,
mineCount: mineCount,
initialMove: initialMove)
mines = board?.mineGridPoints ?? []
}
}
// MARK: - Space Values
private extension Game {
enum Space {
case mine
case empty(Int)
}
}
// MARK: - Convenience inits for sets of GridPoints
private extension Set where Element == GridPoint {
init(width: Range<Int>, height: Range<Int>) {
self = width.reduce(into: Set<GridPoint>()) { grid, col in
let column = height.reduce(into: Set<GridPoint>()) { column, row in
column.insert(GridPoint(x: col, y: row))
}
grid.formUnion(column)
}
}
init(width: ClosedRange<Int>, height: ClosedRange<Int>) {
self = width.reduce(into: Set<GridPoint>()) { grid, col in
let column = height.reduce(into: Set<GridPoint>()) { column, row in
column.insert(GridPoint(x: col, y: row))
}
grid.formUnion(column)
}
}
}
// MARK: - Dictionary helpers
private extension Dictionary where Key == GridPoint, Value == Game.Space {
init(width: Int, height: Int, mineCount: Int, initialMove: GridPoint) {
let allGridPoints = Set<GridPoint>(width: 0..<width, height: 0..<height)
var board = [GridPoint: Game.Space](minimumCapacity: width * height)
board.setMines(allGridPoints.subtracting([initialMove]).shuffled().prefix(mineCount))
board.calculateCounts(allGridPoints)
self = board
}
mutating private func setMines(_ mineGridPoints: some Collection<GridPoint>) {
for index in mineGridPoints {
self[index] = .mine
}
}
mutating private func calculateCounts(_ gridPoints: some Collection<GridPoint>) {
for index in gridPoints {
switch self[index] {
case .mine: break
case .empty, .none:
self[index] = .empty(emptyCount(index))
}
}
}
private func emptyCount(_ gridPoint: GridPoint) -> Int {
gridPoint.surroundingGridPoints.filter { index in
switch self[index] {
case .empty, .none: return false
case .mine: return true
}
}.count
}
var mineGridPoints: Set<GridPoint> {
let gridPoints = self.filter { _, value in
switch value {
case .empty: return false
case .mine: return true
}
}.keys
return Set(gridPoints)
}
}
private extension GridPoint {
var surroundingGridPoints: Set<GridPoint> {
let minColumn = x - 1
let maxColumn = x + 1
let minRow = y - 1
let maxRow = y + 1
var surroundingGridPoints = Set<GridPoint>(
width: minColumn...maxColumn,
height: minRow...maxRow)
surroundingGridPoints.remove(self)
return surroundingGridPoints
}
}