Many of us know the board game peg solitaire and might even have one of its many variants at home. Peg solitaire is a one-player game played on a board with \(n\) holes and \(n-1\) pegs. The number of holes depends on the board variant. For example, the English variant consists of 33 holes, while the typical diamond variant consists of 41. The rules of the game are relatively straightforward. In each move, the player selects one peg and jumps – vertically or horizontally, not diagonally – with this peg over a directly neighboring one into an empty hole. The neighboring peg is then removed, leaving an empty hole. So, in each move, one peg jumps two holes further, and the peg in between is removed. Once no move is possible any longer, the game is over. This is the case when no pair of orthogonally adjacent pegs or if only one peg is left. In the latter case, the game is won. As shown below, the English variant has one additional rule: To win, more is needed than only one peg being left in the end; this peg must also be located in the center of the board. The English variant is shown in the figure below.
Even though the game’s rules are pretty simple, finding a solution is not trivial. Many players need several attempts to find the solution for the English peg solitaire. The solution for the diamond-shaped board is even more tricky.
Solution for the English Peg Solitaire
When I embarked on my programming journey a few years back, English peg solitaire was among my first projects. Frustrated by my inability to solve the game, I decided to take matters into my own hands and write a solver. The code, which you can find at the end of this post, may not be the most elegant or efficient, but it manages to find a solution in under a second. I attribute this success to a stroke of luck with the standard move ordering I used. Interestingly, a slight change in the move ordering significantly increases the solver’s runtime to several hours.
The solver gave me the following solution:
Recently, I again stumbled across peg solitaire when I saw a different board with the shape of a diamond. This stirred my interest in the game again, and I spent several hours writing a solver using bit boards and some other enhancements for this board, as described in the following section.
Efficiently Solving the Diamond-41 Board
As the name suggests, the Diamond-41 board consists of 41 holes. As I was told – in contrast to the English variant – the only initial empty hole is not located in the center of the board but has to be placed at a slightly different position to be able to solve the game (as I found later, the solver was not able to solve the game for an initially empty hole in the center of the board). The initial position is as follows:
In the beginning, only two moves are possible. Since there are many corners on this board, it isn’t easy to find a solution where only one peg is left over at the end. Also, for backtracking algorithms, the computational effort is enormous to solve this problem. Without advanced approaches, an algorithm could run for many days until a solution is found.
Bit-Boards
One significant improvement of a classical algorithm can be achieved when, instead of arrays, so-called bit-boards are used to represent a position. Using bit-boards is especially easy for peg solitaire since each hole can either be empty (binary 0) or filled with a peg (binary 1). In this way, only 41 bits are required to encode any board position. Hence, a 64-bit variable is sufficient. There are also enough bits left over which can be used to encode the board’s boundary. As we will see later, this is important for checking if a move is within the allowed limits. Bit boards have the advantage that the whole board can be processed at once with bitwise operations. Suppose the encoding of each hole is done suitably. In that case, many operations (such as generating valid moves for each position, mirroring the board along a particular axis, counting pegs, etc.) can be executed with minimum CPU cycles. Additionally, the representation is memory efficient, so positions can be easily stored in a hash table or other data structures.
I designed the following layout for the bit-board representation of the Diamond-41 variant of peg solitaire.
Each number represents the corresponding bit in the bit-board. The center hole is placed at bit 0. The squares in the above diagram show the boundary of the board. Moves are not allowed to end up in any of these bits. Note that many boundary bit-numbers appear twice in the diagram. This is due to the unique layout, which I will explain in the following. However, having a separate bit for every point on the boundary is unnecessary since it is only used to check if a move leaves the board.
The advantage of this arrangement is that all pegs can be easily moved vertically or horizontally. For example, a bitwise left rotation of the board would move all pegs vertically by one. Accordingly, pegs are moved down by a rotation of one to the right (or left rotation of -1) and moved horizontally by bitwise rotations of the bit-board by 10 and -10. As we will see, this is especially helpful when we want to find all possible moves. This move generation can be done in very few CPU cycles, even for complex board situations. Furthermore, mirroring the board along the vertical and horizontal axes can also be done comparably fast. The only tradeoff is that rotations of a position can only be computed with some extra effort. One solution for this problem could be consistently maintaining two bitboards: one for the original position and one for a rotated position (by 90 degrees). All other symmetric positions (8 overall) could be constructed with vertical/horizontal flips.
For example, the remaining number of pegs can be computed with an efficient function that counts the number of set bits in a bit field. Since we are usually interested in counting the remaining pegs after no move is possible anymore, there are typically not too many pegs left, and the following function computes the number very fast:
If only three bits are set in a bit-field, above function would only require 3 iterations in order to count the set bits.
Move Generation
The method to compute all the possible moves for a given board position b is even more critical. All possible moves in one direction (in total four directions) can simply be computed with:
where b is an arbitrary board position, rol is a bitwise rotation left (implemented with some inline assembler), dir is the specified direction (+1 for up, -1 for down, +10 for right, -10 for left) and BOARD is a bit-mask which masks all 41 holes of the board. The variable mv contains all the target holes for the allowed moves in the specified direction. After all the possible moves (in mv) for a direction dir are known, each move can be performed with a few bitwise operations. We can extract the next move, which should be performed with:
To perform the move, we have to set a peg to the target position, we have to remove the “jumped-over” peg, and then remove the peg from the old position:
Undoing a move can be done accordingly. All of these steps require only a few bitwise operations. A solver with an array-based data structure for the board would likely perform the moves slightly faster, but the move generation itself (which is commonly an expensive task) is done much quicker with this bit-board design.
Move Ordering
Finding all possible moves in one direction is as simple as that. However, I added a few lines of code that allow for better move ordering, which is essential for finding a solution faster. With a standard move ordering, the search might take many days. Hence, it might be reasonable to spend CPU time sorting the moves to try more promising ones first. For example, the move ordering defers moves that end up in the corners of the board since it is typically not easy to get out of the corner again. Also, other types of moves are ordered to the back of the list if they do not appear promising.
Symmetries & Transposition Tables
When traversing the search tree, many positions repeat since permutations of a move sequence can lead to identical positions. Furthermore, the Diamond-41 board is symmetric, having mirror and rotation symmetries. Hence, we can save computation time if we know the values of repeating positions and their symmetric equivalents. The value of a position is the minimum number of remaining stones of the final state when an optimal move sequence is performed – starting from this particular position. For example, if we know that position \(b_1\) has a corresponding value of \(3\), then a position \(b_2\), which is identical to \(b_1\) after rotation, will also have a value of \(3\). If we store the already known value of positions, we could save some computation time when we observe a (symmetrically) equivalent position again by retrieving and returning the stored value. With such an approach, we could prune the search tree significantly and avoid redundant calculations without losing any information. A common technique used for board games, such as chess, checkers, etc., is the utilization of transposition tables. The idea is to define a suitable hash function that takes the board position (and no historical information of the move sequence that led to this position) and returns a hash value that can be mapped to an index to store the game-theoretic value of a position in a hash table. When we observe a new position, we can calculate the corresponding hash and check if there is an entry in the hash table. If we find an entry, we are lucky and can stop the search for the current position and cut off the connected sub-tree. If not, we have to traverse the connected sub-tree and then store the retrieved value afterward in the table. For our problem, an entry in the table could look like this:
Since we are not performing an Alpha-Beta search or similar, there is no need to store further information besides a key and a value. Note that we also have to store a key, which is simply the position itself in this case, since many board positions will map to the same hash table index. This is because the hash function is typically not injective, and the modulo operation is required to break down the hash value to an index. Since it is impossible to prevent collisions, it is always necessary to compare the key of the stored element with the current board position. From this problem, another question arises: What do we do if we want to store a key-value pair in the transposition table, but the corresponding slot is already occupied by another position? The answer for our case is simple: We overwrite the older entry in the table. This approach is often sub-optimal since we might destroy valuable information (especially the values of positions located close to the root node, which was expensive to compute and could be replaced by those of positions that are very close to a leaf node of the tree). However, in our case, it still worked decently. Commonly, the size of the hash table is chosen to be a power of two since the modulo operation to map a hash value to a table index turns out to be simply a bitwise AND:
where HASHMASK is the size of the table minus one (\(2^n - 1\)). One last detail has to be mentioned: How do we define the hash function? Typically, so-called Zobrist keys are used for many board games. These are a clever way to encode a position by XOR-ing (exclusive or) random integers. One random number is generated initially and then used throughout the search for each possible move. Whenever a move is performed, the current Zobrist key \(Z\) will be linked by an XOR and the corresponding random number of the move. This approach utilizes the associative and commutative property of the exclusive or as well as the involutory property (\(x \oplus x = 0\) ). In my program, I did not use Zobrist keys for simplicity reasons (but they can also be implemented with relatively little effort). Instead, I use a simple hash function of the form
Finally, the Solution of the Diamond-41 Peg Solitaire Board…
After we put all our components for the solver together, we can finally start running it (the whole source code is listed below). Initially, I ran the solver with a modified termination condition: solutions with \(n\) (e.g., \(n=5\)) left-over pegs in the final state were also accepted. A solver can find such a solution faster and be tested this way. After everything worked as intended, the solver was started for \(n=1\) (a solution is searched for where only one peg remains). Surprisingly, the solver found the solution much faster than expected: After about 6 minutes of computation, an optimal move sequence was found. This move sequence is shown in the slideshow below.
Source Code for Solving the Diamond-41 Peg Solitaire
The following code should be compile-able with most C compilers (gcc, etc.). When running the binary file, you will need some patience. The solver will require several minutes, up to one hour (depending on your machine and the size of the transposition table). Once the result is found, the program will list all moves and print the positions in reverse order.
Source Code for Solving the English Peg Solitaire
Save the following code in a file solitaire-en.cpp and compile with g++ solitaire-en.cpp and then run the binary file. I wrote this code a long time ago, when I started learning programming. So, you will find many redundant code segments and strange programming styles. Nevertheless, the solver works and delivers exactly the solution which is shown in the above slideshow.
Interestingly, this naive implementation of the problem actually finds the solution very fast (on my computer in less than a second). Most likely, the solver has a good move order (just by chance) which leads to this fast solution. When changing the order of the moves, the solver can require several hours in order to find a correct solution.
solitaire-en.cpp
Enjoy Reading This Article?
Here are some more articles you might like to read next: