loic-ehrhardt / connectpy

Python module implementing the logic and an efficient solver for the Connect Four game, using optimized C++ code exposed with pybind11.

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

CC BY-NC-SA 4.0

connectpy

board_137730539776

connectpy is a Python module implementing the logic of the Connect Four game.

It exposes compiled classes which allow to interact with a Connect Four board (basically allowing to play a game) and gives access to an optimized solver that can compute a score indicating if the current player of a given board position can force a win and in how many steps (a "strong" solver). An interactive game can also be played with live computation of the scores for each possible move.

The code is relatively small and can also be used as an example of how to use pybind11 to expose C++ functions to Python.

πŸ› οΈ Installation

Clone or copy the project.

git clone https://github.com/loic-ehrhardt/connectpy

Download the pybind11 dependency.

git submodule update --init

Compile the project. Either call make or open connectlib.sln with Visual Studio and compile from there. The version using make finds out the Python version automatically, but the Visual Studio alternative is configured to compile for Python version 3.9. This can be changed on the project properties (change additional include and library directories) or by replacing the "C:\Program Files\Python39\" paths in connectlib.vcxproj to whatever version you use.

Decompress the Opening Book data.

cd connectpy/
gunzip -k opening_book_8.bin.gz

If necessary, download the benchmark files.

cd benchmarks/
./download_benchmark_files.sh

🧐 Usage

>>> import connectpy

Play a game with Board

>>> b = connectpy.Board()
>>> b
πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³
πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³
πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³
πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³
πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³   0 moves
πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³   πŸ”΄β€²s turn

>>> b.play(2)
>>> b.play(3)
>>> b
πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³
πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³
πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³
πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³
πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³   2 moves
πŸ”³πŸ”΄πŸŸ‘πŸ”³πŸ”³πŸ”³πŸ”³   πŸ”΄β€²s turn

>>> b.status
<GameStatus.InProgress: 0>

A Board can also be instantiated from a sequence of moves (string input) or a unique key integer representing it.

>>> b = connectpy.Board("4455673")
>>> b
πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³
πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³
πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³
πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³
πŸ”³πŸ”³πŸ”³πŸŸ‘πŸŸ‘πŸ”³πŸ”³   7 moves
πŸ”³πŸ”³πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸŸ‘   winner: πŸ”΄

>>> b.status
<GameStatus.Player1Wins: 2>

>>> b.key()
8831805440000

>>> connectpy.Board(8831805440000)
πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³
πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³
πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³
πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³πŸ”³
πŸ”³πŸ”³πŸ”³πŸŸ‘πŸŸ‘πŸ”³πŸ”³   7 moves
πŸ”³πŸ”³πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸŸ‘   winner: πŸ”΄

It also has some convenience methods:

>>> b = connectpy.Board(138934665985)
>>> b
πŸ”³πŸ”³πŸ”³πŸ”΄πŸ”³πŸ”³πŸ”³
πŸ”³πŸ”³πŸ”³πŸ”΄πŸ”³πŸ”³πŸ”³
πŸ”³πŸ”³πŸŸ‘πŸŸ‘πŸ”³πŸ”³πŸ”³
πŸ”³πŸ”³πŸŸ‘πŸ”΄πŸ”³πŸ”³πŸ”³
πŸ”³πŸŸ‘πŸ”΄πŸŸ‘πŸŸ‘πŸ”΄πŸ”³   17 moves
πŸ”΄πŸŸ‘πŸ”΄πŸ”΄πŸ”΄πŸŸ‘πŸ”³   πŸŸ‘β€²s turn

>>> b.moves
17
>>> (b.WIDTH, b.HEIGHT)
(7, 6)

# canPlay() tells if a move is allowed.
# Attempting to play on a forbidden column raises a RuntimeError.
>>> b.canPlay(3)
True
>>> b.canPlay(4)
False
>>> b.play(4)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
RuntimeError: Cannot play there (3).

Score a Board using a Solver

The Solver class allows to compute a score for a given Board. The score is positive if the current player can force a win, negative if the opposite player can force a win, and 0 if the current player can force a draw. The absolute value represents the number of remaining stones at the end of the game. For a perfect play, a player should play on the column maximizing his score. Computing this score can be quite expensive given the exponential number of possible combinations; optimization aspects are discussed on the next session. In practice, it takes up to a few seconds after the first 8 moves. For solving boards with fewer moves, one should use an OpeningBook, see below.

>>> b = connectpy.Board(138934665985)
>>> s = connectpy.Solver()

# Compute the score with dichotomicSolve(). The score is positive.
# This means that the current player can win if playing perfectly.
>>> s.dichotomicSolve(b)
4

# One can also use negamax() to compute the score,
# but the dichotomicSolve() function is more efficient.
>>> s.negamax(b)
4

# One can play a given column and recompute the score to figure out
# which move is the good one. Here a "perfect move" is column 3 or 5.
>>> def score_if_play(board, solver, col):
...     if not board.canPlay(col):
...         return None
...     board2 = connectpy.Board(board.key()) # copy
...     board2.play(col)
...     return -solver.dichotomicSolve(board2)
>>> print("\n".join(f"play {col} -> score {score_if_play(b, s, col)}"
...                 for col in range(1, 8)))
play 1 -> score 2
play 2 -> score 3
play 3 -> score 4
play 4 -> score None
play 5 -> score 4
play 6 -> score 2
play 7 -> score 2

# Every Solver keeps track of the total number of positions explored
# for solving the boards. Also, it keeps in a transposition table the
# last scores computed for efficiency. Both can be reset.
>>> s.num_explored_pos
69294
>>> s.reset()
>>> s.num_explored_pos
0

The performance of the Solver can be assessed with the Benchmark class (requires to download the benchmark files, see above).

>>> connectpy.Benchmark().run_all()
=== Test_L3_R1 weak ===
mean compute time: 0.020 ms
mean explored pos: 35.98
K pos / seconds:   1769.41

=== Test_L3_R1 strong ===
mean compute time: 0.022 ms
mean explored pos: 67.73
K pos / seconds:   3081.20

=== Test_L2_R1 weak ===
mean compute time: 0.122 ms
mean explored pos: 647.34
K pos / seconds:   5313.83

(...)

Precomputed scores in an OpeningBook

Computing the score at the early stage of the game can take multiple minutes (because there are more possibilities to explore), which is not convenient for live computing scores. Example with an empty board:

>>> b = connectpy.Board()
>>> s = connectpy.Solver()
>>> %time s.dichotomicSolve(b)
CPU times: user 13min 45s, sys: 4.3 s, total: 13min 49s
Wall time: 13min 40s
Out: 1 # first player can force a win!

# The Solver explored almost 4 billion positions! Note that this is actually
# much less than the total numer of positions (almost 4532 billion) thanks to
# various optimizations.
>>> s.num_explored_pos
3928286512

Using an opening book, which stores precomputed scores for a given maximum number of moves is an alternative to use until the search space reduces enough for the computation of the score to be quick. An opening book up to 8 moves has been precomputed (it took roughly 33 hours to compute) and can be used directly.

>>> o = connectpy.OpeningBook("connectpy/opening_book_8.bin")

# "depth" gives the maximum number of moves precomputed.
>>> o.depth
8

# The score can be accessed for an eligible Board with the [] operator.
>>> (is_found, score) = o[connectpy.Board()]
>>> (is_found, score)
(True, 1)
>>> (is_found, score) = o[connectpy.Board("23")]
>>> (is_found, score)
(True, -1)
>>> (is_found, score) = o[connectpy.Board("123456712")] # 9 moves
>>> (is_found, score)
(False, 0) # not found, returned 0 score can be ignored

An opening book can be generated and saved to a file if desired.

>>> o = connectpy.OpeningBook(8)
Will now generate opening book for depth 8.
This will take some time...
moves=8, key=596, score=0
moves=8, key=16724, score=0
moves=8, key=2097492, score=-2
moves=8, key=268435796, score=3
moves=8, key=34359738708, score=4
moves=8, key=4398046511444, score=3
(... 33 hours later ...)
moves=5, key=85983232, score=-1
moves=4, key=41943040, score=1
moves=3, key=18874368, score=-1
moves=2, key=8388608, score=1
moves=1, key=2097152, score=-1
moves=0, key=0, score=1

# Now do not forget to save the result.
>>> o.dump("my_opening_book.bin")

InteractiveGame

One can run an interactive game with live computing of the score with connectpy.InteractiveGame().play() or python -m connectpy.

interactive_game_cast

πŸš€ Optimizations

The implementation is directly inspired from the great blog series Solving Connect 4: how to build a perfect AI, with added opening book, exposition to Python, and interactive game player. In more details:

  • Critical code written in C++ and exposed to Python with pybind11.
  • Brute-force search of the perfect game using the Negamax algorithm.
  • Alpha-beta version of the Negamax, and dichotomic research of the score based of depth.
  • The board position is represented as unsigned 64 bits integer, which allows much faster computation than with arrays.
  • Optimized ordering of column for exploration search, allowing to alpha-beta prune the search space.
  • Use of a 64MB transposition table to remember the recent computed scores, avoiding to re-compute old positions when it is found in the table.
  • Opening book pre-computed for the first 8 moves.

The bechmark was run at different stages of the development (see part* tags) and can directly be compared with the benchmarks from the refered blog (results in benchmarks/results.txt).

πŸ™‡ Acknowledgements

Huge thanks to Pascal Pons for his blog series on this topic and the corresponding source code.

Also thanks to the pybind11 developers for this great library. The ability to easily expose C++ objects to Python is fantastic.

➀ License

This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.

CC BY-NC-SA 4.0

About

Python module implementing the logic and an efficient solver for the Connect Four game, using optimized C++ code exposed with pybind11.

License:Other


Languages

Language:C++ 67.0%Language:Python 31.0%Language:Shell 1.1%Language:Makefile 0.9%