This project uses bevy, a Rust game engine, to recreate the classic puzzle game Minesweeper. The main purpose was to create an algorithm for playing Minesweeper, described below. To see the algorithm in action check out the github-pages deployment of this repo. I've slowed it down so you can see it in action, but on average it finishes games in ~50ms and wins just over 50% of the time, which is about as good as it gets for Minseweeper.
Game 500 finished in 0.04s
Record: 257-243-0 on Hard (51.40% win rate, 88.09% bombs cleared)
50ms per game, 25.03s in total, longest game took 0.61s
If you're unfamiliar with Minesweeper, it's a game of boolean logic in which you are given a grid of tiles, with some labelled with integers between 1 and 8, and some covered up. The integers tell you how many of the surrounding tiles contain bombs. Clicking on a bomb-tile detonates it, while right-clicking a bomb-tile flags it as unsafe. Clicking a non-bomb tile reveals another integer, showing how many of its neighbours are bombs, allowing you to deduce more safe or unsafe tiles. The game ends when you detonate a bomb, losing, or you flag all bombs, winning.
The game playing agent comes in two parts:
- A deterministic process to check if any tiles are guaranteed to be safe or unsafe using deductions from known subsets/supersets of tiles
- A probabilistic process to identify the tile that is most likely to be safe, which is then uncovered
This process involves storing sets of tiles along with bounds on the number of bombs they may contain.
The goal is to find a nonempty set of
- Size constraints: every set of
$n$ tiles contains between$0$ and$n$ bombs inclusive. - Subsets: if a set of tiles contains at most
$k$ bombs, every subset of these tiles contains at most$k$ bombs. - Recursive #1: For every set of tiles
$t$ that contains at MOST$k$ bombs and for every subset$s$ of$t$ that contains at LEAST$m$ bombs, the remaining tiles$t - s$ contain at MOST$k - m$ bombs. - Recursive #2: For every set of tiles
$t$ that contains at LEAST$k$ bombs and for every subset$s$ of$t$ that contains at MOST$m$ bombs, the remaining tiles$t - s$ contain at LEAST$k - m$ bombs.
Rule 4 is equivalent to rule 3, and is just a relabelling of bomb as 'not bomb' and safe as 'not safe'. To apply these rules to minesweeper we do the following:
- For every uncovered tile displaying the number
$k$ , the set of adjacent tiles contains exactly$k$ bombs. - Recursively apply the above rules to all sets we have information about.
- If a set of
$n$ tiles contains at least$n$ bombs, flag them all. - If a set of tiles contains at most
$0$ bombs, uncover them all.
This is roughly how a human plays Minesweeper.
Unfortunately this is not enough, and nothing ever will be. Not all games of Minesweeper can be won with the information available. We can do better on average, however, by generating all possible assignments of bombs that satisfy the given constraints, then finding the tile that is least likely to be a bomb based on these scenarios.
This algorithm begins by considering only the covered tiles that we have information about, i.e. the covered boundary of tiles adjacent to numbered unconvered tiles. The game gives us a set of constraints, tellings us for some subsets of the boundary how many bombs are present. At a high level, we break the boundary into small sections, then in each section, generate all possible assigments and filter them down to those that satisfy overlapping constraints. Once we have these, we merge neighbouring sections together, mergesort style, and check any constraints that overlap both sections. We do this until we have merged all sections together, at which point we have the set of assignments that satisfy all constraints. This approach lets us eliminate candidates early in each section, avoiding combinatorial explosion. Once we have all these scenarios, we count how many times each bit is set to work out the safest tile, weighting for the probability of each scenario. This is all done using bitwise operations and integer comparison, making it very quick.
We encode each constraint as an integer and a bit array.
The integer is how many bombs are present, while the bit array tells us which tiles the constraint applies to.
This implementation uses a u128 integer for the bit array in Rust, which is enough for realistic scenarios on a standard Minesweeper board.
For example,
Once we've encoded all constraints, we partition the boundary into sections of some chosen size
We then test each of these against all our constraints.
Lets say we have some candidate assignment
- If
$c$ assigns every tile in$t$ , then$c$ has$n$ bits set in t. - If
$c$ leaves$k$ tiles in$t$ unassigned, then$c$ has between$n - k$ and$n$ (inclusive) bits set in$t$ .
These can be written as bitwise operations. If
$(c\land t)=t\implies|c\land t|=n$ $n-|\lnot m\land t|\leq |c\land t|\leq n$
I won't describe the remaining bitwise operations in detail. Once we have independently generated candidates for each section of the boundary, we merge adjacent sections together in pairs, using bitwise 'or'. For each pair, we then recheck constraints that overlap both of the original sections. All other constraints either fully overlap an original section, and so have already been verified, or do not overlap either original section. This means that minimal work is required on each merge and we get rid of candidates as soon as possible.
There is one more complexity after generating all possible boundary scenarios, which is that not all boundary scenarios are equally likely. This would be true if there were no
non-boundary tiles, however in general we need some combinatorics to tells us how many ways we can assign non-boundary bombs
to non-boundary tiles, since it's the bombs that are uniformly distributed not the boundary scenarios.
To be more specific if we have a valid scenario that leaves
Now we have
Finally, it can be even safer to pick a random non-boundary tile. Even though we have no specific information about each of these tiles, we do know how many non-boundary tiles there are and how many bombs there are remaining. If this proportion of non-boundary bomb squares is better than our best shot on the boundary, we can instead just pick one of them.
Ideally you want tiles that have common constraints to be nearby each other (in terms of boundary indexes) so that these constraints are considered as early as possible, and so there are fewer constraints considered when merging. The approach implemented is to pick the two tiles that are the furthest apart, then partition all tiles based on which of the two is closer. The same method is then recursively applied to each half, then they are concatenated. There is potentially a better approach based on the constraints themselves.