AtelierClockwork

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
    }
}